{/* Capability label */}
-
- {capability}
-
+
{capability}
{/* Drop zone */}
-
-
- {connection.provider!.name}
-
+
+ {connection.provider!.name}
@@ -139,13 +68,9 @@ function CapabilitySlotLegacy({
Provider missing
@@ -154,9 +79,7 @@ function CapabilitySlotLegacy({
-
- {isDropTarget ? 'Drop provider here' : 'Click to select or drag provider'}
-
+
{isDropTarget ? 'Drop provider here' : 'Click to select or drag provider'}
{onSelectProvider && !isDropTarget && (
@@ -167,66 +90,3 @@ function CapabilitySlotLegacy({
)
}
-
-// ============================================================================
-// Dropdown Mode Component
-// ============================================================================
-
-function CapabilitySlotDropdown({
- consumerId,
- capability,
- selectedOption,
- options,
- templates,
- loading,
- onSelect,
- onCreateConfig,
- onEditConfig,
- onDeleteConfig,
- onUpdateConfig,
- onRefresh,
- onCreateNew,
- onClear: _onClear, // Currently unused - dropdown handles clearing internally
- error,
-}: DropdownModeProps) {
- return (
-
- {/* Capability label */}
-
- {capability}
-
- {/* Dropdown with cascading submenu */}
-
-
- )
-}
-
-// ============================================================================
-// Main Component
-// ============================================================================
-
-export function CapabilitySlot(props: CapabilitySlotProps) {
- if (props.mode === 'dropdown') {
- return
- }
-
- // Default to legacy mode for backwards compatibility
- return
-}
diff --git a/ushadow/frontend/src/components/wiring/index.ts b/ushadow/frontend/src/components/wiring/index.ts
index 51005a82..799a8caf 100644
--- a/ushadow/frontend/src/components/wiring/index.ts
+++ b/ushadow/frontend/src/components/wiring/index.ts
@@ -3,10 +3,3 @@ export { ServiceTemplateCard } from './ServiceTemplateCard'
export { ServiceInstanceCard } from './ServiceInstanceCard'
export { CapabilitySlot } from './CapabilitySlot'
export { StatusIndicator } from './StatusIndicator'
-export { ProviderConfigDropdown } from './ProviderConfigDropdown'
-export { ProviderConfigForm } from './ProviderConfigForm'
-export type { ProviderConfigFormData, ProviderConfigFormProps } from './ProviderConfigForm'
-export { FlatServiceCard } from './FlatServiceCard'
-export type { FlatServiceCardProps } from './FlatServiceCard'
-export { SystemOverview } from './SystemOverview'
-export type { SystemOverviewProps } from './SystemOverview'
diff --git a/ushadow/frontend/src/contexts/ChronicleContext.tsx b/ushadow/frontend/src/contexts/ChronicleContext.tsx
index 19cfb1d7..7ea37021 100644
--- a/ushadow/frontend/src/contexts/ChronicleContext.tsx
+++ b/ushadow/frontend/src/contexts/ChronicleContext.tsx
@@ -68,11 +68,8 @@ export function ChronicleProvider({ children }: { children: ReactNode }) {
setConnectionError(null)
}, [recording])
- // Auto-check connection on mount so the header record button appears immediately
- useEffect(() => {
- // Only check once on mount
- checkConnection()
- }, []) // eslint-disable-line react-hooks/exhaustive-deps
+ // Don't auto-check on mount - let Chronicle pages explicitly call checkConnection()
+ // This avoids unnecessary requests when user is on non-Chronicle pages
// Re-check connection periodically (every 5 minutes) if connected
useEffect(() => {
diff --git a/ushadow/frontend/src/hooks/HOOK_PATTERNS.md b/ushadow/frontend/src/hooks/HOOK_PATTERNS.md
new file mode 100644
index 00000000..8fc63043
--- /dev/null
+++ b/ushadow/frontend/src/hooks/HOOK_PATTERNS.md
@@ -0,0 +1,233 @@
+# Hook Patterns
+
+> Patterns for separating logic from presentation. Follow these to keep components thin.
+
+## Core Principle
+
+**Components render, hooks decide.**
+
+```
+┌─────────────────┐ ┌─────────────────┐
+│ Component │────▶│ Hook │
+│ (presentation) │ │ (logic) │
+│ │◀────│ │
+│ - JSX layout │ │ - State │
+│ - Styling │ │ - API calls │
+│ - Event wiring │ │ - Computed │
+└─────────────────┘ └─────────────────┘
+```
+
+## Pattern 1: Derived State Hook
+
+Use when: Component needs to make decisions based on multiple inputs.
+
+**Example**: `useServiceStatus` - decides icon, color, label from raw data.
+
+```typescript
+// hooks/useServiceStatus.ts
+export function useServiceStatus(
+ service: ServiceConfig,
+ config: Record
| undefined,
+ containerStatus: ContainerStatus | undefined
+): ServiceStatusResult {
+ return useMemo(() => {
+ // All decision logic here
+ if (!isConfigured) {
+ return { state: 'not_configured', label: 'Missing Config', icon: AlertCircle }
+ }
+ // ... more decisions
+ }, [service, config, containerStatus])
+}
+
+// Component just renders what the hook returns
+function ServiceCard({ service, config, status }) {
+ const { label, icon: Icon, color } = useServiceStatus(service, config, status)
+ return {label}
+}
+```
+
+## Pattern 2: Data Fetching Hook
+
+Use when: Component needs server data with loading/error states.
+
+```typescript
+// hooks/useResource.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+
+export function useResource(id: string) {
+ const queryClient = useQueryClient()
+
+ const query = useQuery({
+ queryKey: ['resource', id],
+ queryFn: () => api.getResource(id),
+ staleTime: 30_000,
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: api.updateResource,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['resource', id] })
+ },
+ })
+
+ return {
+ data: query.data,
+ isLoading: query.isLoading,
+ error: query.error,
+ update: updateMutation.mutate,
+ isUpdating: updateMutation.isPending,
+ }
+}
+
+// Component stays simple
+function ResourcePage({ id }) {
+ const { data, isLoading, update } = useResource(id)
+ if (isLoading) return
+ return
+}
+```
+
+## Pattern 3: Form Logic Hook
+
+Use when: Form has validation, submission, and error handling.
+
+```typescript
+// hooks/useSettingsForm.ts
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { z } from 'zod'
+
+const schema = z.object({
+ apiKey: z.string().min(1, 'Required'),
+ endpoint: z.string().url('Must be a valid URL'),
+})
+
+export function useSettingsForm(defaults: SettingsData) {
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: defaults,
+ })
+
+ const onSubmit = async (data: SettingsData) => {
+ await api.saveSettings(data)
+ toast.success('Saved')
+ }
+
+ return {
+ ...form,
+ onSubmit: form.handleSubmit(onSubmit),
+ }
+}
+
+// Component wires up the form
+function SettingsPage() {
+ const { control, onSubmit, formState: { errors } } = useSettingsForm(defaults)
+
+ return (
+
+ )
+}
+```
+
+## Pattern 4: Action Hook with Confirmation
+
+Use when: Action needs confirmation dialog and async handling.
+
+```typescript
+// hooks/useDeleteWithConfirm.ts
+export function useDeleteWithConfirm(onDelete: (id: string) => Promise) {
+ const [pendingId, setPendingId] = useState(null)
+ const [isDeleting, setIsDeleting] = useState(false)
+
+ const requestDelete = (id: string) => setPendingId(id)
+ const cancelDelete = () => setPendingId(null)
+
+ const confirmDelete = async () => {
+ if (!pendingId) return
+ setIsDeleting(true)
+ try {
+ await onDelete(pendingId)
+ } finally {
+ setIsDeleting(false)
+ setPendingId(null)
+ }
+ }
+
+ return {
+ pendingId,
+ isDeleting,
+ isConfirmOpen: pendingId !== null,
+ requestDelete,
+ cancelDelete,
+ confirmDelete,
+ }
+}
+```
+
+## Pattern 5: UI State Hook
+
+Use when: Component has complex UI state (modals, tabs, expansion).
+
+```typescript
+// hooks/useExpandableList.ts
+export function useExpandableList(items: T[]) {
+ const [expandedIds, setExpandedIds] = useState>(new Set())
+
+ const toggle = (id: string) => {
+ setExpandedIds(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const expandAll = () => setExpandedIds(new Set(items.map(i => i.id)))
+ const collapseAll = () => setExpandedIds(new Set())
+ const isExpanded = (id: string) => expandedIds.has(id)
+
+ return { isExpanded, toggle, expandAll, collapseAll }
+}
+```
+
+## When to Extract a Hook
+
+Extract when you see:
+- `useState` + `useEffect` together doing something reusable
+- Complex conditional rendering logic (if/else chains)
+- API call + loading + error handling
+- Multiple pieces of state that change together
+- Same logic duplicated across components
+
+## Hook Composition
+
+Build complex hooks from simpler ones:
+
+```typescript
+// Compose multiple concerns
+export function useServiceManager(serviceId: string) {
+ const status = useServiceStatus(serviceId)
+ const { start, stop } = useServiceControls(serviceId)
+ const { config, updateConfig } = useServiceConfig(serviceId)
+ const deleteConfirm = useDeleteWithConfirm(deleteService)
+
+ return {
+ ...status,
+ start,
+ stop,
+ config,
+ updateConfig,
+ ...deleteConfirm,
+ }
+}
+```
+
+## File Size Guide
+
+- **Hooks**: Max 100 lines each
+- **If larger**: Split into multiple hooks and compose them
+- **Export**: Always export from `hooks/index.ts`
diff --git a/ushadow/frontend/src/pages/InterfacesPage.tsx b/ushadow/frontend/src/pages/InterfacesPage.tsx
index b0c54c52..b6d21e7a 100644
--- a/ushadow/frontend/src/pages/InterfacesPage.tsx
+++ b/ushadow/frontend/src/pages/InterfacesPage.tsx
@@ -3,18 +3,40 @@ import {
Server,
Cloud,
Layers,
+ CheckCircle,
AlertCircle,
+ ChevronDown,
+ ChevronUp,
+ Edit2,
+ Save,
X,
RefreshCw,
+ PlayCircle,
+ StopCircle,
+ Loader2,
+ HardDrive,
+ Pencil,
+ Plus,
+ Package,
+ Trash2,
+ BookOpen,
} from 'lucide-react'
import {
providersApi,
servicesApi,
svcConfigsApi,
+ settingsApi,
Capability,
+ ProviderWithStatus,
ComposeService,
+ EnvVarInfo,
+ EnvVarConfig,
+ ServiceConfig,
ServiceConfigSummary,
} from '../services/api'
+import ConfirmDialog from '../components/ConfirmDialog'
+import Modal from '../components/Modal'
+import { PortConflictDialog } from '../components/services'
type TabId = 'providers' | 'services' | 'deployed'
@@ -22,14 +44,71 @@ export default function InterfacesPage() {
// Tab state
const [activeTab, setActiveTab] = useState('providers')
- // Data state
+ // Providers state
const [capabilities, setCapabilities] = useState([])
+ const [expandedProviders, setExpandedProviders] = useState>(new Set())
+ const [editingProviderId, setEditingProviderId] = useState(null)
+ const [providerEditForm, setProviderEditForm] = useState>({})
+ const [changingProvider, setChangingProvider] = useState(null)
+ const [savingProvider, setSavingProvider] = useState(false)
+
+ // Services state
const [services, setServices] = useState([])
+ const [serviceStatuses, setServiceStatuses] = useState>({})
+ const [expandedServices, setExpandedServices] = useState>(new Set())
+ const [editingServiceId, setEditingServiceId] = useState(null)
+ const [envConfig, setEnvConfig] = useState<{
+ required_env_vars: EnvVarInfo[]
+ optional_env_vars: EnvVarInfo[]
+ } | null>(null)
+ const [envEditForm, setEnvEditForm] = useState>({})
+ const [customEnvVars, setCustomEnvVars] = useState>([])
+ const [startingService, setStartingService] = useState(null)
+ const [loadingEnvConfig, setLoadingEnvConfig] = useState(null)
+
+ // Deployed instances state
const [deployedInstances, setDeployedInstances] = useState([])
+ const [expandedInstances, setExpandedInstances] = useState>(new Set())
+ const [editingInstanceId, setEditingInstanceId] = useState(null)
+ const [instanceDetails, setInstanceDetails] = useState>({})
// General state
const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
+ const [serviceErrors, setServiceErrors] = useState>({})
+
+ // Dialog states
+ const [confirmDialog, setConfirmDialog] = useState<{
+ isOpen: boolean
+ serviceName: string | null
+ }>({ isOpen: false, serviceName: null })
+
+ const [portConflictDialog, setPortConflictDialog] = useState<{
+ isOpen: boolean
+ serviceId: string | null
+ serviceName: string | null
+ conflicts: Array<{
+ port: number
+ envVar: string | null
+ usedBy: string
+ suggestedPort: number
+ }>
+ }>({ isOpen: false, serviceId: null, serviceName: null, conflicts: [] })
+
+ const [portEditDialog, setPortEditDialog] = useState<{
+ isOpen: boolean
+ serviceId: string | null
+ serviceName: string | null
+ currentPort: number | null
+ envVar: string | null
+ newPort: string
+ }>({ isOpen: false, serviceId: null, serviceName: null, currentPort: null, envVar: null, newPort: '' })
+
+ const [showCatalog, setShowCatalog] = useState(false)
+ const [catalogServices, setCatalogServices] = useState([])
+ const [catalogLoading, setCatalogLoading] = useState(false)
+ const [installingService, setInstallingService] = useState(null)
// Tab definitions
const tabs = [
@@ -68,6 +147,9 @@ export default function InterfacesPage() {
setCapabilities(capsResponse.data)
setServices(servicesResponse.data)
setDeployedInstances(instancesResponse.data)
+
+ // Load Docker statuses for services
+ await loadServiceStatuses(servicesResponse.data)
} catch (error) {
console.error('Error loading data:', error)
setMessage({ type: 'error', text: 'Failed to load services' })
@@ -76,6 +158,142 @@ export default function InterfacesPage() {
}
}
+ const loadServiceStatuses = async (serviceList: ComposeService[]) => {
+ try {
+ const response = await servicesApi.getAllStatuses()
+ const statuses: Record = {}
+
+ for (const service of serviceList) {
+ statuses[service.service_name] = response.data[service.service_name] || { status: 'not_found' }
+ }
+
+ setServiceStatuses(statuses)
+ } catch (error) {
+ console.error('Failed to fetch Docker statuses:', error)
+ // Set fallback statuses
+ const fallbackStatuses: Record = {}
+ for (const service of serviceList) {
+ fallbackStatuses[service.service_name] = { status: 'not_found' }
+ }
+ setServiceStatuses(fallbackStatuses)
+ }
+ }
+
+ // Provider actions
+ const getProviderKey = (capId: string, providerId: string) => `${capId}:${providerId}`
+
+ const toggleProviderExpanded = (capId: string, providerId: string) => {
+ const key = getProviderKey(capId, providerId)
+ setExpandedProviders(prev => {
+ const next = new Set(prev)
+ if (next.has(key)) {
+ next.delete(key)
+ } else {
+ next.add(key)
+ }
+ return next
+ })
+ }
+
+ const handleProviderChange = async (capabilityId: string, providerId: string) => {
+ setChangingProvider(capabilityId)
+ try {
+ await providersApi.selectProvider(capabilityId, providerId)
+ const response = await providersApi.getCapabilities()
+ setCapabilities(response.data)
+ setMessage({ type: 'success', text: `Provider changed to ${providerId}` })
+ } catch (error: any) {
+ setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to change provider' })
+ } finally {
+ setChangingProvider(null)
+ }
+ }
+
+ const handleEditProvider = (capId: string, provider: ProviderWithStatus) => {
+ const key = getProviderKey(capId, provider.id)
+ const initialForm: Record = {}
+ ;(provider.credentials || []).forEach(cred => {
+ if (cred.type === 'secret') {
+ initialForm[cred.key] = ''
+ } else {
+ initialForm[cred.key] = cred.value || cred.default || ''
+ }
+ })
+ setProviderEditForm(initialForm)
+ setEditingProviderId(key)
+ setExpandedProviders(prev => new Set(prev).add(key))
+ }
+
+ const handleSaveProvider = async (_capId: string, provider: ProviderWithStatus) => {
+ setSavingProvider(true)
+ try {
+ const updates: Record = {}
+ ;(provider.credentials || []).forEach(cred => {
+ const value = providerEditForm[cred.key]
+ if (value && value.trim() && cred.settings_path) {
+ updates[cred.settings_path] = value.trim()
+ }
+ })
+
+ if (Object.keys(updates).length === 0) {
+ setMessage({ type: 'error', text: 'No changes to save' })
+ setSavingProvider(false)
+ return
+ }
+
+ await settingsApi.update(updates)
+ const response = await providersApi.getCapabilities()
+ setCapabilities(response.data)
+ setMessage({ type: 'success', text: `${provider.name} credentials saved` })
+ setEditingProviderId(null)
+ setProviderEditForm({})
+ } catch (error: any) {
+ setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to save credentials' })
+ } finally {
+ setSavingProvider(false)
+ }
+ }
+
+ const handleCancelProviderEdit = () => {
+ setEditingProviderId(null)
+ setProviderEditForm({})
+ }
+
+ // Service actions - these will be implemented as needed
+ // For now, just placeholder implementations
+ const handleStartService = async (serviceName: string) => {
+ setMessage({ type: 'error', text: 'Service start not yet implemented' })
+ }
+
+ const handleStopService = (serviceName: string) => {
+ setMessage({ type: 'error', text: 'Service stop not yet implemented' })
+ }
+
+ // Deployed instance actions - placeholders for now
+ const handleExpandInstance = async (instanceId: string) => {
+ setExpandedInstances(prev => new Set(prev).add(instanceId))
+ }
+
+ const handleCollapseInstance = (instanceId: string) => {
+ setExpandedInstances(prev => {
+ const next = new Set(prev)
+ next.delete(instanceId)
+ return next
+ })
+ }
+
+ const handleDeployInstance = async (instanceId: string) => {
+ setMessage({ type: 'error', text: 'Deploy not yet implemented' })
+ }
+
+ const handleUndeployInstance = async (instanceId: string) => {
+ setMessage({ type: 'error', text: 'Undeploy not yet implemented' })
+ }
+
+ const handleDeleteInstance = async (instanceId: string) => {
+ setMessage({ type: 'error', text: 'Delete not yet implemented' })
+ }
+
// Render
if (loading) {
return (
diff --git a/ushadow/frontend/src/pages/KubernetesClustersPage.tsx b/ushadow/frontend/src/pages/KubernetesClustersPage.tsx
index 4b042110..5abdc6b9 100644
--- a/ushadow/frontend/src/pages/KubernetesClustersPage.tsx
+++ b/ushadow/frontend/src/pages/KubernetesClustersPage.tsx
@@ -1,10 +1,10 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Server, Plus, RefreshCw, Trash2, CheckCircle, XCircle, Clock, Upload, X, Search, Database, AlertCircle, Rocket } from 'lucide-react'
-import { kubernetesApi, KubernetesCluster, DeployTarget, deploymentsApi } from '../services/api'
+import { kubernetesApi, KubernetesCluster } from '../services/api'
import Modal from '../components/Modal'
import ConfirmDialog from '../components/ConfirmDialog'
-import DeployModal from '../components/DeployModal'
+import DeployToK8sModal from '../components/DeployToK8sModal'
interface InfraService {
found: boolean
@@ -555,28 +555,13 @@ export default function KubernetesClustersPage() {
{/* Deploy to K8s Modal */}
{showDeployModal && selectedClusterForDeploy && (
- {
setShowDeployModal(false)
setSelectedClusterForDeploy(null)
}}
- target={{
- id: selectedClusterForDeploy.deployment_target_id,
- type: 'k8s',
- name: selectedClusterForDeploy.name,
- identifier: selectedClusterForDeploy.cluster_id,
- environment: selectedClusterForDeploy.environment || 'unknown',
- status: selectedClusterForDeploy.status || 'unknown',
- namespace: selectedClusterForDeploy.namespace,
- infrastructure: Object.keys(scanResults).find(key => key.startsWith(selectedClusterForDeploy.cluster_id))
- ? scanResults[Object.keys(scanResults).find(key => key.startsWith(selectedClusterForDeploy.cluster_id))!].infra_services
- : undefined,
- provider: selectedClusterForDeploy.labels?.provider,
- region: selectedClusterForDeploy.labels?.region,
- is_leader: undefined,
- raw_metadata: selectedClusterForDeploy
- }}
+ cluster={selectedClusterForDeploy}
infraServices={
Object.keys(scanResults).find(key => key.startsWith(selectedClusterForDeploy.cluster_id))
? scanResults[Object.keys(scanResults).find(key => key.startsWith(selectedClusterForDeploy.cluster_id))!].infra_services
diff --git a/ushadow/frontend/src/pages/ServiceConfigsPage.tsx b/ushadow/frontend/src/pages/ServiceConfigsPage.tsx
index 516ba1d4..0e1bde90 100644
--- a/ushadow/frontend/src/pages/ServiceConfigsPage.tsx
+++ b/ushadow/frontend/src/pages/ServiceConfigsPage.tsx
@@ -3,6 +3,7 @@ import {
Layers,
Plus,
RefreshCw,
+ ChevronDown,
ChevronUp,
AlertCircle,
CheckCircle,
@@ -12,14 +13,16 @@ import {
HardDrive,
Package,
Pencil,
+ Plug,
Settings,
Trash2,
+ PlayCircle,
+ ArrowRight,
Activity,
Database,
Zap,
- Save,
- PlayCircle,
- StopCircle,
+ Clock,
+ Lock,
} from 'lucide-react'
import {
svcConfigsApi,
@@ -29,19 +32,18 @@ import {
kubernetesApi,
clusterApi,
deploymentsApi,
- DeployTarget,
Template,
ServiceConfig,
ServiceConfigSummary,
Wiring,
+ ServiceConfigCreateRequest,
EnvVarInfo,
EnvVarConfig,
} from '../services/api'
import ConfirmDialog from '../components/ConfirmDialog'
import Modal from '../components/Modal'
-import { SystemOverview, FlatServiceCard } from '../components/wiring'
-import DeployModal from '../components/DeployModal'
-import EnvVarEditor from '../components/EnvVarEditor'
+import { WiringBoard } from '../components/wiring'
+import DeployToK8sModal from '../components/DeployToK8sModal'
/**
* Extract error message from FastAPI response.
@@ -67,9 +69,11 @@ function getErrorMessage(error: any, fallback: string): string {
export default function ServiceConfigsPage() {
// Templates state
const [templates, setTemplates] = useState([])
+ const [expandedTemplates, setExpandedTemplates] = useState>(new Set())
// ServiceConfigs state
const [instances, setServiceConfigs] = useState([])
+ const [expandedServiceConfigs, setExpandedServiceConfigs] = useState>(new Set())
const [instanceDetails, setServiceConfigDetails] = useState>({})
// Wiring state (per-service connections)
@@ -78,13 +82,8 @@ export default function ServiceConfigsPage() {
// Service status state for consumers
const [serviceStatuses, setServiceStatuses] = useState>({})
- // Deployments state
- const [deployments, setDeployments] = useState([])
- const [filterCurrentEnvOnly, setFilterCurrentEnvOnly] = useState(true)
-
// UI state
const [loading, setLoading] = useState(true)
- const [activeTab, setActiveTab] = useState<'services' | 'providers' | 'overview' | 'deployments'>('services')
const [creating, setCreating] = useState(null)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [confirmDialog, setConfirmDialog] = useState<{
@@ -111,25 +110,35 @@ export default function ServiceConfigsPage() {
const [envConfigs, setEnvConfigs] = useState>({})
const [loadingEnvConfig, setLoadingEnvConfig] = useState(false)
- // Inline editing state for Providers tab cards
- const [expandedProviderCard, setExpandedProviderCard] = useState(null)
- const [providerCardEnvVars, setProviderCardEnvVars] = useState([])
- const [providerCardEnvConfigs, setProviderCardEnvConfigs] = useState>({})
- const [loadingProviderCard, setLoadingProviderCard] = useState(false)
- const [savingProviderCard, setSavingProviderCard] = useState(false)
-
- // Unified deploy modal state
+ // Deploy modal state
const [deployModalState, setDeployModalState] = useState<{
isOpen: boolean
serviceId: string | null
- targetId?: string // Deploy target ID (for when we have a specific target selected)
+ targetType: 'local' | 'remote' | 'kubernetes' | null
+ selectedClusterId?: string
infraServices?: Record // Infrastructure data to pass to modal
}>({
isOpen: false,
serviceId: null,
+ targetType: null,
+ })
+
+ // Simple deploy confirmation modal (for local/remote)
+ const [simpleDeployModal, setSimpleDeployModal] = useState<{
+ isOpen: boolean
+ serviceId: string | null
+ targetType: 'local' | 'remote' | null
+ targetId?: string
+ }>({
+ isOpen: false,
+ serviceId: null,
+ targetType: null,
})
- const [availableTargets, setAvailableTargets] = useState([])
- const [loadingTargets, setLoadingTargets] = useState(false)
+ const [deployEnvVars, setDeployEnvVars] = useState([])
+ const [deployEnvConfigs, setDeployEnvConfigs] = useState>({})
+ const [loadingDeployEnv, setLoadingDeployEnv] = useState(false)
+ const [kubernetesClusters, setKubernetesClusters] = useState([])
+ const [loadingClusters, setLoadingClusters] = useState(false)
// Service catalog state
const [showCatalog, setShowCatalog] = useState(false)
@@ -162,15 +171,11 @@ export default function ServiceConfigsPage() {
const loadData = async () => {
try {
setLoading(true)
- const [templatesRes, instancesRes, wiringRes, statusesRes, deploymentsRes] = await Promise.all([
+ const [templatesRes, instancesRes, wiringRes, statusesRes] = await Promise.all([
svcConfigsApi.getTemplates(),
svcConfigsApi.getServiceConfigs(),
svcConfigsApi.getWiring(),
servicesApi.getAllStatuses().catch(() => ({ data: {} })),
- deploymentsApi.listDeployments().catch((err) => {
- console.error('Failed to load deployments:', err)
- return { data: [] }
- }),
])
console.log('Templates loaded:', templatesRes.data)
@@ -181,10 +186,28 @@ export default function ServiceConfigsPage() {
setServiceConfigs(instancesRes.data)
setWiring(wiringRes.data)
setServiceStatuses(statusesRes.data || {})
- setDeployments(deploymentsRes.data || [])
- // Note: instanceDetails are loaded lazily when needed (e.g., when user
- // clicks edit or switches to overview tab) to avoid N+1 API calls
+ // Load details for provider instances (instances that provide capabilities)
+ // This enables the wiring board to show config overrides
+ const providerTemplates = templatesRes.data.filter((t) => t.provides && t.source === 'provider')
+ const providerServiceConfigs = instancesRes.data.filter((i) =>
+ providerTemplates.some((t) => t.id === i.template_id)
+ )
+
+ if (providerServiceConfigs.length > 0) {
+ const detailsPromises = providerServiceConfigs.map((i) =>
+ svcConfigsApi.getServiceConfig(i.id).catch(() => null)
+ )
+ const detailsResults = await Promise.all(detailsPromises)
+
+ const newDetails: Record = {}
+ detailsResults.forEach((res, idx) => {
+ if (res?.data) {
+ newDetails[providerServiceConfigs[idx].id] = res.data
+ }
+ })
+ setServiceConfigDetails((prev) => ({ ...prev, ...newDetails }))
+ }
} catch (error) {
console.error('Error loading data:', error)
setMessage({ type: 'error', text: 'Failed to load instances data' })
@@ -193,16 +216,6 @@ export default function ServiceConfigsPage() {
}
}
- // Lightweight function to refresh just deployments without full page reload
- const refreshDeployments = async () => {
- try {
- const deploymentsRes = await deploymentsApi.listDeployments()
- setDeployments(deploymentsRes.data || [])
- } catch (err) {
- console.error('Failed to refresh deployments:', err)
- }
- }
-
// Service catalog functions
const openCatalog = async () => {
console.log('Opening catalog...')
@@ -258,7 +271,145 @@ export default function ServiceConfigsPage() {
}
}
+ // Template actions
+ const toggleTemplate = (templateId: string) => {
+ setExpandedTemplates((prev) => {
+ const next = new Set(prev)
+ if (next.has(templateId)) {
+ next.delete(templateId)
+ } else {
+ next.add(templateId)
+ }
+ return next
+ })
+ }
+
+ // Generate next available instance ID for a template
+ const generateServiceConfigId = (templateId: string): string => {
+ // Extract clean name from template ID (remove compose file prefix)
+ // For compose services: "chronicle-compose:chronicle-webui" -> "chronicle-webui"
+ // For providers: "openai" -> "openai"
+ const baseName = templateId.includes(':')
+ ? templateId.split(':').pop()!
+ : templateId
+
+ // Find all existing instances that start with this base name
+ const existingIds = instances
+ .map((i) => i.id)
+ .filter((id) => id.startsWith(`${baseName}-`))
+
+ // Extract numbers from existing IDs
+ const numbers = existingIds
+ .map((id) => {
+ const match = id.match(new RegExp(`^${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-(\\d+)$`))
+ return match ? parseInt(match[1], 10) : 0
+ })
+ .filter((n) => n > 0)
+
+ // Find next available number
+ const nextNum = numbers.length > 0 ? Math.max(...numbers) + 1 : 1
+ return `${baseName}-${nextNum}`
+ }
+
+ /**
+ * Create service config directly - unified for both + button and drag-drop
+ * @param template - The template to create instance from
+ * @param wiring - Optional wiring info (for drag-drop path)
+ */
+ const createServiceConfigDirectly = async (
+ template: Template,
+ wiring?: { capability: string; consumerId: string; consumerName: string }
+ ) => {
+ // Generate unique incremental ID (already uses clean name without compose prefix)
+ const generatedId = generateServiceConfigId(template.id)
+
+ setCreating(template.id)
+ try {
+ const data: ServiceConfigCreateRequest = {
+ id: generatedId,
+ template_id: template.id,
+ name: generatedId,
+ deployment_target: template.mode === 'cloud' ? 'cloud' : 'local',
+ config: {}, // Empty config - will be set during deployment
+ }
+
+ // Step 1: Create the service config
+ await svcConfigsApi.createServiceConfig(data)
+
+ // Step 2: If wiring info exists, create the wiring connection (drag-drop path)
+ if (wiring) {
+ const newWiring = await svcConfigsApi.createWiring({
+ source_config_id: generatedId,
+ source_capability: wiring.capability,
+ target_config_id: wiring.consumerId,
+ target_capability: wiring.capability,
+ })
+
+ // Update wiring state
+ setWiring((prev) => {
+ const existing = prev.findIndex(
+ (w) =>
+ w.target_config_id === wiring.consumerId &&
+ w.target_capability === wiring.capability
+ )
+ if (existing >= 0) {
+ const updated = [...prev]
+ updated[existing] = newWiring.data
+ return updated
+ }
+ return [...prev, newWiring.data]
+ })
+
+ setMessage({
+ type: 'success',
+ text: `Created ${generatedId} and connected to ${wiring.consumerName}`,
+ })
+ } else {
+ setMessage({ type: 'success', text: `Instance "${generatedId}" created` })
+ }
+
+ // Reload instances
+ const instancesRes = await svcConfigsApi.getServiceConfigs()
+ setServiceConfigs(instancesRes.data)
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || 'Failed to create instance',
+ })
+ } finally {
+ setCreating(null)
+ }
+ }
+
// ServiceConfig actions
+ const toggleServiceConfig = async (instanceId: string) => {
+ if (expandedServiceConfigs.has(instanceId)) {
+ setExpandedServiceConfigs((prev) => {
+ const next = new Set(prev)
+ next.delete(instanceId)
+ return next
+ })
+ } else {
+ // Load full instance details
+ if (!instanceDetails[instanceId]) {
+ try {
+ const res = await svcConfigsApi.getServiceConfig(instanceId)
+ setServiceConfigDetails((prev) => ({
+ ...prev,
+ [instanceId]: res.data,
+ }))
+ } catch (error) {
+ console.error('Failed to load instance details:', error)
+ }
+ }
+ setExpandedServiceConfigs((prev) => new Set(prev).add(instanceId))
+ }
+ }
+
+ const handleDeleteServiceConfig = (instanceId: string) => {
+ setConfirmDialog({ isOpen: true, instanceId })
+ }
+
const confirmDeleteServiceConfig = async () => {
const { instanceId } = confirmDialog
if (!instanceId) return
@@ -396,45 +547,6 @@ export default function ServiceConfigsPage() {
}
}
- // Lazy load instance details (for overview tab or when editing)
- const loadInstanceDetails = async () => {
- const providerTemplatesList = templates.filter((t) => t.provides && t.source === 'provider')
- const providerServiceConfigs = instances.filter((i) =>
- providerTemplatesList.some((t) => t.id === i.template_id)
- )
-
- // Only load configs that aren't already loaded
- const unloadedConfigs = providerServiceConfigs.filter((i) => !instanceDetails[i.id])
- if (unloadedConfigs.length === 0) return
-
- try {
- const detailsPromises = unloadedConfigs.map((i) =>
- svcConfigsApi.getServiceConfig(i.id).catch(() => null)
- )
- const detailsResults = await Promise.all(detailsPromises)
-
- const newDetails: Record = {}
- detailsResults.forEach((res, idx) => {
- if (res?.data) {
- newDetails[unloadedConfigs[idx].id] = res.data
- }
- })
- if (Object.keys(newDetails).length > 0) {
- setServiceConfigDetails((prev) => ({ ...prev, ...newDetails }))
- }
- } catch (error) {
- console.error('Error loading instance details:', error)
- }
- }
-
- // Handle tab switch - lazy load details for overview tab
- const handleTabChange = async (tab: 'services' | 'providers' | 'overview') => {
- setActiveTab(tab)
- if (tab === 'overview') {
- await loadInstanceDetails()
- }
- }
-
// Consumer/Service handlers for WiringBoard
const handleStartConsumer = async (consumerId: string) => {
try {
@@ -471,31 +583,42 @@ export default function ServiceConfigsPage() {
}
const handleDeployConsumer = async (consumerId: string, target: { type: 'local' | 'remote' | 'kubernetes'; id?: string }) => {
- // consumerId can be either an instance ID or a template ID (for templates without instances)
- // Try to find instance first, otherwise treat as template ID
+ // Get the consumer instance to find its template_id
const consumerInstance = instances.find(inst => inst.id === consumerId)
- const templateId = consumerInstance?.template_id || consumerId
+ if (!consumerInstance) {
+ setMessage({ type: 'error', text: `Service instance ${consumerId} not found` })
+ return
+ }
- // For Kubernetes, load available targets and filter to K8s only
+ // For Kubernetes, load available clusters first
if (target.type === 'kubernetes') {
- setLoadingTargets(true)
+ setLoadingClusters(true)
try {
- const targetsResponse = await deploymentsApi.listTargets()
- const k8sTargets = targetsResponse.data.filter(t => t.type === 'k8s')
- setAvailableTargets(k8sTargets)
-
- // If there's only one cluster, auto-select it and use its infrastructure from standardized field
- if (k8sTargets.length === 1) {
- const deployTarget = k8sTargets[0]
- const infraData = deployTarget.infrastructure || {}
-
- console.log(`🏗️ Using K8s infrastructure from ${deployTarget.name}:`, infraData)
+ const clustersResponse = await kubernetesApi.listClusters()
+ setKubernetesClusters(clustersResponse.data)
+
+ // If there's only one cluster, auto-select it and use its cached infrastructure scan
+ if (clustersResponse.data.length === 1) {
+ const cluster = clustersResponse.data[0]
+
+ // Use cached infrastructure scan results from cluster
+ // Infrastructure is cluster-wide, so use any available namespace scan
+ let infraData = {}
+ if (cluster.infra_scans && Object.keys(cluster.infra_scans).length > 0) {
+ // Use the first available scan (infra is typically accessible cluster-wide)
+ const firstNamespace = Object.keys(cluster.infra_scans)[0]
+ infraData = cluster.infra_scans[firstNamespace] || {}
+ console.log(`🏗️ Using cached K8s infrastructure from namespace '${firstNamespace}':`, infraData)
+ } else {
+ console.warn('No cached infrastructure scan found for cluster')
+ }
// Pass template_id as serviceId so the modal loads the right env vars
setDeployModalState({
isOpen: true,
- serviceId: templateId,
- targetId: deployTarget.id, // deployment_target_id
+ serviceId: consumerInstance.template_id,
+ targetType: target.type,
+ selectedClusterId: cluster.cluster_id,
infraServices: infraData,
})
} else {
@@ -503,48 +626,131 @@ export default function ServiceConfigsPage() {
// Infrastructure will be loaded when cluster is selected in modal
setDeployModalState({
isOpen: true,
- serviceId: templateId,
+ serviceId: consumerInstance.template_id,
+ targetType: target.type,
})
}
} catch (err) {
- console.error('Failed to load K8s targets:', err)
- setMessage({ type: 'error', text: 'Failed to load deployment targets' })
+ console.error('Failed to load K8s clusters:', err)
+ setMessage({ type: 'error', text: 'Failed to load Kubernetes clusters' })
} finally {
- setLoadingTargets(false)
+ setLoadingClusters(false)
}
} else if (target.type === 'local' || target.type === 'remote') {
- // Load Docker targets for unified modal
- setLoadingTargets(true)
- try {
- const targetsResponse = await deploymentsApi.listTargets()
- const dockerTargets = targetsResponse.data.filter(t => t.type === 'docker')
- setAvailableTargets(dockerTargets)
-
- // Determine which target to use
- let selectedTarget: DeployTarget | undefined
- if (target.type === 'local') {
- // Find local leader
- selectedTarget = dockerTargets.find(t => t.is_leader) || dockerTargets[0]
- } else if (target.id) {
- // Use specified remote target
- selectedTarget = dockerTargets.find(t => t.identifier === target.id || t.id === target.id)
- }
+ // Show deploy confirmation modal with env vars
+ setSimpleDeployModal({
+ isOpen: true,
+ serviceId: consumerId,
+ targetType: target.type,
+ targetId: target.id,
+ })
- // Open unified modal with the selected target
- setDeployModalState({
- isOpen: true,
- serviceId: templateId,
- targetId: selectedTarget?.id,
+ // Load env config
+ setLoadingDeployEnv(true)
+ try {
+ const response = await servicesApi.getEnvConfig(consumerId)
+ const allVars = [...response.data.required_env_vars, ...response.data.optional_env_vars]
+ setDeployEnvVars(allVars)
+
+ // Initialize env configs
+ const formData: Record = {}
+ allVars.forEach(ev => {
+ formData[ev.name] = {
+ name: ev.name,
+ source: (ev.source as 'setting' | 'literal' | 'default') || 'default',
+ setting_path: ev.setting_path,
+ value: ev.value
+ }
})
- } catch (err) {
- console.error('Failed to load Docker targets:', err)
- setMessage({ type: 'error', text: 'Failed to load deployment targets' })
+ setDeployEnvConfigs(formData)
+ } catch (error) {
+ console.error('Failed to load env config:', error)
} finally {
- setLoadingTargets(false)
+ setLoadingDeployEnv(false)
}
}
}
+ const handleConfirmDeploy = async () => {
+ if (!simpleDeployModal.serviceId || !simpleDeployModal.targetType) return
+
+ const consumerId = simpleDeployModal.serviceId
+ const targetType = simpleDeployModal.targetType
+
+ setCreating(`deploy-${consumerId}`)
+ setSimpleDeployModal({ isOpen: false, serviceId: null, targetType: null })
+
+ try {
+ let targetHostname: string
+
+ if (targetType === 'local') {
+ const leaderResponse = await clusterApi.getLeaderInfo()
+ targetHostname = leaderResponse.data.hostname
+ } else {
+ // Remote
+ if (!simpleDeployModal.targetId) {
+ setMessage({ type: 'error', text: 'Remote unode deployment requires selecting a target unode.' })
+ setCreating(null)
+ return
+ }
+ targetHostname = simpleDeployModal.targetId
+ }
+
+ console.log(`🚀 Deploying ${consumerId} to ${targetType} unode: ${targetHostname}`)
+
+ // Generate unique instance ID for this deployment
+ const template = templates.find(t => t.id === consumerId)
+ const sanitizedServiceId = consumerId.replace(/[^a-z0-9-]/g, '-')
+ const timestamp = Date.now()
+ const instanceId = `${sanitizedServiceId}-unode-${timestamp}`
+
+ // Build config from env var settings
+ const config: Record = {}
+ Object.entries(deployEnvConfigs).forEach(([name, envConfig]) => {
+ if (envConfig.source === 'setting' && envConfig.setting_path) {
+ config[name] = { _from_setting: envConfig.setting_path }
+ } else if (envConfig.source === 'new_setting' && envConfig.value) {
+ config[name] = envConfig.value
+ if (envConfig.new_setting_path) {
+ config[`_save_${name}`] = envConfig.new_setting_path
+ }
+ } else if (envConfig.value) {
+ config[name] = envConfig.value
+ }
+ })
+
+ // Step 1: Create instance with deployment target and config
+ await svcConfigsApi.createServiceConfig({
+ id: instanceId,
+ template_id: consumerId,
+ name: `${template?.name || consumerId} (${targetHostname})`,
+ description: `uNode deployment to ${targetHostname}`,
+ config,
+ deployment_target: targetHostname
+ })
+
+ // Step 2: Deploy the service config
+ await svcConfigsApi.deployServiceConfig(instanceId)
+
+ console.log('✅ Deployment successful')
+ setMessage({ type: 'success', text: `Successfully deployed ${template?.name || consumerId} to ${targetType} unode` })
+
+ // Refresh instances and service statuses
+ const [instancesRes, statusesRes] = await Promise.all([
+ svcConfigsApi.getServiceConfigs(),
+ servicesApi.getAllStatuses()
+ ])
+ setServiceConfigs(instancesRes.data)
+ setServiceStatuses(statusesRes.data || {})
+
+ } catch (err: any) {
+ console.error(`Failed to deploy to ${targetType} unode:`, err)
+ const errorMsg = getErrorMessage(err, `Failed to deploy to ${targetType} unode`)
+ setMessage({ type: 'error', text: errorMsg })
+ } finally {
+ setCreating(null)
+ }
+ }
const handleEditConsumer = async (consumerId: string) => {
// Edit a consumer service - load its env config and show in modal
@@ -632,12 +838,10 @@ export default function ServiceConfigsPage() {
// Use API response data (setting mapping or default)
initialEnvConfigs[envVar.name] = {
name: envVar.name,
- source: envVar.source || 'default',
+ source: (envVar.source as 'setting' | 'new_setting' | 'literal' | 'default') || 'default',
setting_path: envVar.setting_path,
- value: envVar.resolved_value || envVar.value,
+ value: envVar.value,
new_setting_path: undefined,
- locked: envVar.locked,
- provider_name: envVar.provider_name,
}
}
})
@@ -661,85 +865,292 @@ export default function ServiceConfigsPage() {
}
}
- // Provider and compose templates
+ // Transform data for WiringBoard
+ // Providers: provider templates (both configured and unconfigured) + custom instances
const providerTemplates = templates
.filter((t) => t.source === 'provider' && t.provides)
- const composeTemplates = templates.filter((t) => t.source === 'compose' && t.installed)
+ const wiringProviders = [
+ // Templates (defaults) - only show configured ones
+ ...providerTemplates
+ .filter((t) => t.configured) // Only show providers that have been set up
+ .map((t) => {
+ // Extract config vars from schema - include all fields with required indicator
+ const configVars: Array<{ key: string; label: string; value: string; isSecret: boolean; required?: boolean }> =
+ t.config_schema
+ ?.map((field: any) => {
+ const isSecret = field.type === 'secret'
+ const hasValue = field.has_value || !!field.value
+ let displayValue = ''
+ if (hasValue) {
+ if (isSecret) {
+ displayValue = '••••••'
+ } else if (field.value) {
+ displayValue = String(field.value)
+ } else if (field.has_value) {
+ // Has a value but we can't display it - show brief indicator
+ displayValue = '(set)'
+ }
+ }
+ return {
+ key: field.key,
+ label: field.label || field.key,
+ value: displayValue,
+ isSecret,
+ required: field.required,
+ }
+ }) || []
+
+ // Cloud services: status is based on configuration, not Docker
+ // Local services: status is based on Docker availability
+ let status: string
+ if (t.mode === 'cloud') {
+ // Cloud services are either configured or need setup
+ status = t.configured ? 'configured' : 'needs_setup'
+ } else {
+ // Local services use availability (from Docker)
+ status = t.available ? 'running' : 'stopped'
+ }
- // Handle inline provider card editing (Providers tab)
- const handleExpandProviderCard = async (providerId: string) => {
- if (expandedProviderCard === providerId) {
- // Collapse
- setExpandedProviderCard(null)
- setProviderCardEnvVars([])
- setProviderCardEnvConfigs({})
- return
- }
+ // For LLM providers, append model to name for clarity
+ let displayName = t.name
+ if (t.provides === 'llm') {
+ const modelVar = configVars.find(v => v.key === 'model')
+ if (modelVar && modelVar.value && modelVar.value !== '(set)') {
+ displayName = `${t.name}-${modelVar.value}`
+ }
+ }
- // Expand and load env config
- setExpandedProviderCard(providerId)
- setLoadingProviderCard(true)
+ return {
+ id: t.id,
+ name: displayName,
+ capability: t.provides!,
+ status,
+ mode: t.mode,
+ isTemplate: true,
+ templateId: t.id,
+ configVars,
+ configured: t.configured,
+ }
+ }),
+ // Custom instances from provider templates
+ ...instances
+ .filter((i) => {
+ const template = providerTemplates.find((t) => t.id === i.template_id)
+ return template && template.provides
+ })
+ .map((i) => {
+ const template = providerTemplates.find((t) => t.id === i.template_id)!
+ // Get instance config from instanceDetails if loaded
+ const details = instanceDetails[i.id]
+ const schema = template.config_schema || []
+ const configVars: Array<{ key: string; label: string; value: string; isSecret: boolean; required?: boolean }> = []
+
+ // Build config vars from schema, merging with instance overrides
+ schema.forEach((field: any) => {
+ const overrideValue = details?.config?.values?.[field.key]
+ const isSecret = field.type === 'secret'
+ let displayValue = ''
+ if (overrideValue) {
+ // Instance has an override value
+ displayValue = isSecret ? '••••••' : String(overrideValue)
+ } else if (field.value) {
+ // Inherited from template - show the actual value
+ displayValue = isSecret ? '••••••' : String(field.value)
+ } else if (field.has_value) {
+ // Template has a value but we can't display it
+ displayValue = isSecret ? '••••••' : '(set)'
+ }
+ configVars.push({
+ key: field.key,
+ label: field.label || field.key,
+ value: displayValue,
+ isSecret,
+ required: field.required,
+ })
+ })
- try {
- const response = await svcConfigsApi.getTemplateEnvConfig(providerId)
- const data = response.data
-
- setProviderCardEnvVars(data)
-
- // Initialize configs from backend response
- const initial: Record = {}
- for (const ev of data) {
- initial[ev.name] = {
- name: ev.name,
- source: (ev.source as 'setting' | 'literal' | 'default') || 'default',
- setting_path: ev.setting_path,
- value: ev.value,
+ // Determine status based on mode
+ let instanceStatus: string
+ if (template.mode === 'cloud') {
+ // Cloud instances use config-based status
+ // Check if all required fields have values
+ const hasAllRequired = schema.every((field: any) => {
+ if (!field.required) return true
+ const overrideValue = details?.config?.values?.[field.key]
+ return !!(overrideValue || field.has_value || field.value)
+ })
+ instanceStatus = hasAllRequired ? 'configured' : 'needs_setup'
+ } else {
+ // Local instances use Docker status
+ instanceStatus = i.status === 'running' ? 'running' : i.status
}
- }
- setProviderCardEnvConfigs(initial)
- } catch (err) {
- console.error('Failed to load provider env config:', err)
- setMessage({ type: 'error', text: 'Failed to load provider configuration' })
- } finally {
- setLoadingProviderCard(false)
- }
- }
- const handleSaveProviderCard = async (providerId: string) => {
- setSavingProviderCard(true)
- try {
- // Build settings updates from env configs
- const settingsUpdates: Record> = {}
-
- for (const [name, cfg] of Object.entries(providerCardEnvConfigs)) {
- if (cfg.source === 'new_setting' && cfg.value && cfg.new_setting_path) {
- const parts = cfg.new_setting_path.split('.')
- if (parts.length === 2) {
- const [section, key] = parts
- if (!settingsUpdates[section]) settingsUpdates[section] = {}
- settingsUpdates[section][key] = cfg.value
+ // For LLM providers, append model to name for clarity
+ let displayName = i.name
+ if (template.provides === 'llm') {
+ const modelVar = configVars.find(v => v.key === 'model')
+ if (modelVar && modelVar.value && modelVar.value !== '(set)') {
+ displayName = `${i.name}-${modelVar.value}`
}
}
- }
- // Save settings if any
- if (Object.keys(settingsUpdates).length > 0) {
- await settingsApi.update(settingsUpdates)
+ return {
+ id: i.id,
+ name: displayName,
+ capability: template.provides!,
+ status: instanceStatus,
+ mode: template.mode,
+ isTemplate: false,
+ templateId: i.template_id,
+ configVars,
+ configured: template.configured, // ServiceConfig inherits template's configured status
+ }
+ }),
+ ]
+
+ // Consumers: compose service templates
+ const composeTemplates = templates.filter((t) => t.source === 'compose' && t.installed)
+
+ const wiringConsumers = [
+ // Templates
+ ...composeTemplates.map((t) => {
+ // Get actual status from Docker
+ // Extract service name from template ID (format: "compose_file:service_name")
+ const serviceName = t.id.includes(':') ? t.id.split(':').pop()! : t.id
+ const dockerStatus = serviceStatuses[serviceName]
+ const status = dockerStatus?.status || 'not_running'
+
+ // Build config vars from schema
+ const configVars = (t.config_schema || []).map((field: any) => {
+ const isSecret = field.type === 'secret'
+ let displayValue = ''
+ if (field.has_value) {
+ displayValue = isSecret ? '••••••' : (field.value ? String(field.value) : '(default)')
+ } else if (field.value) {
+ displayValue = isSecret ? '••••••' : String(field.value)
+ }
+ return {
+ key: field.key,
+ label: field.label || field.key,
+ value: displayValue,
+ isSecret,
+ required: field.required,
+ }
+ })
+
+ return {
+ id: t.id,
+ name: t.name,
+ requires: t.requires!,
+ status,
+ mode: t.mode || 'local',
+ configVars,
+ configured: t.configured,
+ description: t.description,
+ isTemplate: true,
+ templateId: t.id,
}
+ }),
+ // ServiceConfig instances from compose templates
+ ...instances
+ .filter((i) => {
+ const template = composeTemplates.find((t) => t.id === i.template_id)
+ return template && template.requires
+ })
+ .map((i) => {
+ const template = composeTemplates.find((t) => t.id === i.template_id)!
+ const details = instanceDetails[i.id]
+
+ // Build config vars from instance details if available
+ const configVars = details?.config?.values
+ ? Object.entries(details.config.values).map(([key, value]) => ({
+ key,
+ label: key,
+ value: String(value),
+ isSecret: false,
+ required: false,
+ }))
+ : []
+
+ return {
+ id: i.id,
+ name: i.name,
+ requires: template.requires!,
+ status: i.status,
+ mode: i.deployment_target === 'kubernetes' ? 'cloud' : 'local',
+ configVars,
+ configured: true,
+ description: template.description,
+ isTemplate: false,
+ templateId: i.template_id,
+ }
+ }),
+ ]
+
+ // Handle provider drop - show modal for templates, direct wire for instances
+ const handleProviderDrop = async (dropInfo: {
+ provider: { id: string; name: string; capability: string; isTemplate: boolean; templateId: string }
+ consumerId: string
+ capability: string
+ }) => {
+ const consumer = wiringConsumers.find((c) => c.id === dropInfo.consumerId)
+
+ // If it's an instance (not a template), wire directly without showing modal
+ if (!dropInfo.provider.isTemplate) {
+ try {
+ const newWiring = await svcConfigsApi.createWiring({
+ source_config_id: dropInfo.provider.id,
+ source_capability: dropInfo.capability,
+ target_config_id: dropInfo.consumerId,
+ target_capability: dropInfo.capability,
+ })
+ setWiring((prev) => {
+ const existing = prev.findIndex(
+ (w) => w.target_config_id === dropInfo.consumerId &&
+ w.target_capability === dropInfo.capability
+ )
+ if (existing >= 0) {
+ const updated = [...prev]
+ updated[existing] = newWiring.data
+ return updated
+ }
+ return [...prev, newWiring.data]
+ })
+ } catch (err) {
+ console.error('Failed to create wiring:', err)
+ }
+ return
+ }
- // Refresh data
- await loadData()
+ // For templates, create instance directly with wiring info
+ const template = templates.find((t) => t.id === dropInfo.provider.id)
+ if (template) {
+ await createServiceConfigDirectly(template, {
+ capability: dropInfo.capability,
+ consumerId: dropInfo.consumerId,
+ consumerName: consumer?.name || dropInfo.consumerId,
+ })
+ }
+ }
- setMessage({ type: 'success', text: 'Provider configuration saved' })
- setExpandedProviderCard(null)
- setProviderCardEnvVars([])
- setProviderCardEnvConfigs({})
- } catch (err: any) {
- console.error('Failed to save provider config:', err)
- setMessage({ type: 'error', text: err.response?.data?.detail || 'Failed to save configuration' })
- } finally {
- setSavingProviderCard(false)
+ const handleDeleteWiringFromBoard = async (consumerId: string, capability: string) => {
+ // Find the wiring to delete
+ const wire = wiring.find(
+ (w) => w.target_config_id === consumerId && w.target_capability === capability
+ )
+ if (!wire) return
+
+ try {
+ await svcConfigsApi.deleteWiring(wire.id)
+ setWiring((prev) => prev.filter((w) => w.id !== wire.id))
+ setMessage({ type: 'success', text: `${capability} disconnected` })
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || 'Failed to clear provider',
+ })
+ throw error
}
}
@@ -861,12 +1272,10 @@ export default function ServiceConfigsPage() {
// Use service default configuration
initialEnvConfigs[envVar.name] = {
name: envVar.name,
- source: envVar.source || 'default',
+ source: (envVar.source as 'setting' | 'new_setting' | 'literal' | 'default') || 'default',
setting_path: envVar.setting_path,
- value: envVar.resolved_value || envVar.value,
+ value: envVar.value,
new_setting_path: undefined,
- locked: envVar.locked,
- provider_name: envVar.provider_name,
}
}
}
@@ -893,47 +1302,6 @@ export default function ServiceConfigsPage() {
}
}
- // Handle edit saved config from dropdown
- const handleEditSavedConfig = (configId: string) => {
- // Use existing handler for editing instances
- handleEditProviderFromBoard(configId, false)
- }
-
- // Handle delete saved config from dropdown
- const handleDeleteSavedConfig = async (configId: string) => {
- try {
- await svcConfigsApi.deleteServiceConfig(configId)
- // Refresh configs list
- const instancesRes = await svcConfigsApi.getServiceConfigs()
- setServiceConfigs(instancesRes.data)
- // Also refresh wiring in case deleted config was wired
- const wiringRes = await svcConfigsApi.getWiring()
- setWiring(wiringRes.data)
- setMessage({ type: 'success', text: 'Configuration deleted' })
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to delete configuration',
- })
- }
- }
-
- // Handle update saved config from dropdown submenu
- const handleUpdateSavedConfig = async (configId: string, configValues: Record) => {
- try {
- await svcConfigsApi.updateServiceConfig(configId, { config: configValues })
- // Refresh configs list
- const instancesRes = await svcConfigsApi.getServiceConfigs()
- setServiceConfigs(instancesRes.data)
- setMessage({ type: 'success', text: 'Configuration updated' })
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to update configuration',
- })
- }
- }
-
// Handle save edit from modal
const handleSaveEdit = async () => {
if (!editingProvider) return
@@ -1029,26 +1397,89 @@ export default function ServiceConfigsPage() {
}
}
+ // Handle update template config vars from wiring board inline editor
+ const handleUpdateTemplateConfigVars = async (
+ templateId: string,
+ configVars: Array<{ key: string; label: string; value: string; isSecret: boolean; required?: boolean }>
+ ) => {
+ const template = templates.find((t) => t.id === templateId)
+ if (!template) return
+
+ try {
+ // Check if this is a compose service template (has env vars) or provider template
+ if (template.source === 'compose') {
+ // Compose service template - save env configs
+ const envVarConfigs = configVars
+ .filter((v) => v.value && v.value.trim())
+ .map((v) => ({
+ source: 'new_setting' as const,
+ value: v.value,
+ new_setting_path: `service_env.${template.id}.${v.key}`,
+ }))
+
+ if (envVarConfigs.length > 0) {
+ await servicesApi.updateEnvConfig(template.id, envVarConfigs)
+ setMessage({ type: 'success', text: `${template.name} configuration updated` })
+ }
+ } else {
+ // Provider template - update settings via settings_path
+ const updates: Record> = {}
+ const schema = template.config_schema || []
+
+ for (const configVar of configVars) {
+ const schemaField = schema.find((f: any) => f.key === configVar.key)
+ if (schemaField?.settings_path && configVar.value && configVar.value.trim()) {
+ const parts = schemaField.settings_path.split('.')
+ if (parts.length === 2) {
+ const [section, key] = parts
+ if (!updates[section]) updates[section] = {}
+ updates[section][key] = configVar.value
+ }
+ }
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await settingsApi.update(updates)
+ setMessage({ type: 'success', text: `${template.name} settings updated` })
+ }
+ }
+
+ // Refresh templates to get updated values
+ const templatesRes = await svcConfigsApi.getTemplates()
+ setTemplates(templatesRes.data)
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || 'Failed to update configuration',
+ })
+ throw error
+ }
+ }
+
+ // Handle create instance from wiring board (via "+" button)
+ const handleCreateServiceConfigFromBoard = async (templateId: string) => {
+ const template = templates.find((t) => t.id === templateId)
+ if (template) {
+ await createServiceConfigDirectly(template)
+ }
+ }
+
// Group templates by source - only show installed services
const allProviderTemplates = templates.filter((t) => t.source === 'provider')
- // Filter deployments by current environment
- // Use VITE_ENV_NAME from environment variables (e.g., "purple", "orange")
- const currentEnv = import.meta.env.VITE_ENV_NAME || 'ushadow'
- const currentComposeProject = `ushadow-${currentEnv}`
-
- const filteredDeployments = filterCurrentEnvOnly
- ? deployments.filter((d) => {
- // Match deployments from the current environment only
- // Check if the deployment's hostname matches this environment's compose project or env name
- return d.unode_hostname && (
- d.unode_hostname === currentEnv ||
- d.unode_hostname === currentComposeProject ||
- d.unode_hostname.startsWith(`${currentComposeProject}.`)
- )
- })
- : deployments
+ // Group instances by their template_id for hierarchical display
+ const instancesByTemplate = instances.reduce((acc, instance) => {
+ if (!acc[instance.template_id]) {
+ acc[instance.template_id] = []
+ }
+ acc[instance.template_id].push(instance)
+ return acc
+ }, {} as Record)
+ // Providers shown in grid: configured OR user has added them
+ const visibleProviders = allProviderTemplates.filter(
+ (t) => (t.configured && t.available) || addedProviderIds.has(t.id)
+ )
// Providers in "Add" menu: not configured and not yet added
const availableToAdd = allProviderTemplates.filter(
(t) => (!t.configured || !t.available) && !addedProviderIds.has(t.id)
@@ -1059,100 +1490,64 @@ export default function ServiceConfigsPage() {
setShowAddProviderModal(false)
}
- // Deployment action handlers
- const handleStopDeployment = async (deploymentId: string) => {
- // Optimistic update
- setDeployments((prev) =>
- prev.map((d) => (d.id === deploymentId ? { ...d, status: 'stopping' } : d))
- )
-
- try {
- await deploymentsApi.stopDeployment(deploymentId)
- setMessage({ type: 'success', text: 'Deployment stopped' })
- // Refresh just deployments, not entire page
- await refreshDeployments()
- } catch (error: any) {
- console.error('Failed to stop deployment:', error)
- setMessage({ type: 'error', text: 'Failed to stop deployment' })
- // Revert optimistic update on error
- await refreshDeployments()
- }
- }
-
- const handleRestartDeployment = async (deploymentId: string) => {
- // Optimistic update
- setDeployments((prev) =>
- prev.map((d) => (d.id === deploymentId ? { ...d, status: 'starting' } : d))
- )
-
- try {
- await deploymentsApi.restartDeployment(deploymentId)
- setMessage({ type: 'success', text: 'Deployment restarted' })
- // Refresh just deployments, not entire page
- await refreshDeployments()
- } catch (error: any) {
- console.error('Failed to restart deployment:', error)
- setMessage({ type: 'error', text: 'Failed to restart deployment' })
- // Revert optimistic update on error
- await refreshDeployments()
- }
- }
-
- const handleRemoveDeployment = async (deploymentId: string, serviceName: string) => {
- if (!confirm(`Remove deployment ${serviceName}?`)) return
-
- // Optimistic update - remove from list immediately
- setDeployments((prev) => prev.filter((d) => d.id !== deploymentId))
-
- try {
- await deploymentsApi.removeDeployment(deploymentId)
- setMessage({ type: 'success', text: 'Deployment removed' })
- // Refresh to ensure consistency
- await refreshDeployments()
- } catch (error: any) {
- console.error('Failed to remove deployment:', error)
- setMessage({ type: 'error', text: 'Failed to remove deployment' })
- // Revert optimistic update on error
- await refreshDeployments()
- }
- }
-
- const handleEditDeployment = async (deployment: any) => {
- const template = templates.find((t) => t.id === deployment.service_id)
- if (!template) return
-
- try {
- setLoadingEnvConfig(true)
-
- // Load environment variable configuration for this service
- const envResponse = await servicesApi.getEnvConfig(template.id)
- const envData = envResponse.data
-
- const allEnvVars = [...envData.required_env_vars, ...envData.optional_env_vars]
- setDeploymentEnvVars(allEnvVars)
-
- // Initialize env configs from deployment's current config
- const initialEnvConfigs: Record = {}
- const deployedEnv = deployment.deployed_config?.environment || {}
-
- allEnvVars.forEach((envVar) => {
- const currentValue = deployedEnv[envVar.name] || envVar.default_value || ''
- initialEnvConfigs[envVar.name] = {
- name: envVar.name,
- source: 'literal',
- value: currentValue,
- setting_path: undefined,
- new_setting_path: undefined,
- }
- })
-
- setDeploymentEnvConfigs(initialEnvConfigs)
- setEditingDeployment(deployment)
- } catch (error) {
- console.error('Failed to load deployment config:', error)
- setMessage({ type: 'error', text: 'Failed to load deployment configuration' })
- } finally {
- setLoadingEnvConfig(false)
+ // Get status badge for instance
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case 'running':
+ return (
+
+
+ Running
+
+ )
+ case 'deploying':
+ return (
+
+
+ Starting
+
+ )
+ case 'pending':
+ return (
+
+
+ Pending
+
+ )
+ case 'stopped':
+ return (
+
+ Stopped
+
+ )
+ case 'error':
+ return (
+
+
+ Error
+
+ )
+ case 'n/a':
+ return (
+
+
+ Cloud
+
+ )
+ case 'not_found':
+ case 'not_running':
+ return (
+
+
+ Not Running
+
+ )
+ default:
+ return (
+
+ {status}
+
+ )
}
}
@@ -1175,10 +1570,7 @@ export default function ServiceConfigsPage() {
-
Services
-
- BETA
-
+ ServiceConfigs
Create and manage service instances from templates
@@ -1223,7 +1615,7 @@ export default function ServiceConfigsPage() {
-
Services
+
ServiceConfigs
{instances.length}
@@ -1263,498 +1655,50 @@ export default function ServiceConfigsPage() {
)}
- {/* Tab Navigation */}
-
-
-
-
- {/* Tab Content */}
- {activeTab === 'services' ? (
- /* Services Tab */
-
-
-
- Select providers for each service capability
-
-
-
- {/* Service Cards Grid */}
-
- {composeTemplates
- .filter((t) => t.requires && t.requires.length > 0)
- .map((template) => {
- // Find the config for this template (if any)
- const config = instances.find((i) => i.template_id === template.id) || null
- const consumerId = config?.id || template.id
-
- // Get service status from Docker
- const serviceName = template.id.includes(':') ? template.id.split(':').pop()! : template.id
- const status = serviceStatuses[serviceName]
-
- // Filter wiring for this consumer
- const consumerWiring = wiring.filter((w) => w.target_config_id === consumerId)
-
- // Get deployments for this service (filtered by environment)
- const serviceDeployments = filteredDeployments.filter(d => d.service_id === template.id)
-
- return (
- {
- try {
- const newWiring = await svcConfigsApi.createWiring({
- source_config_id: sourceConfigId,
- source_capability: capability,
- target_config_id: consumerId,
- target_capability: capability,
- })
- setWiring((prev) => {
- const existing = prev.findIndex(
- (w) => w.target_config_id === consumerId && w.target_capability === capability
- )
- if (existing >= 0) {
- const updated = [...prev]
- updated[existing] = newWiring.data
- return updated
- }
- return [...prev, newWiring.data]
- })
- setMessage({ type: 'success', text: `${capability} provider connected` })
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to connect provider',
- })
- }
- }}
- onWiringClear={async (capability) => {
- const wire = wiring.find(
- (w) => w.target_config_id === consumerId && w.target_capability === capability
- )
- if (!wire) return
- try {
- await svcConfigsApi.deleteWiring(wire.id)
- setWiring((prev) => prev.filter((w) => w.id !== wire.id))
- setMessage({ type: 'success', text: `${capability} provider disconnected` })
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to disconnect provider',
- })
- }
- }}
- onConfigCreate={async (templateId, name, configValues) => {
- // Generate valid ID from name (lowercase, alphanumeric + hyphens)
- const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || `config-${Date.now()}`
- try {
- await svcConfigsApi.createServiceConfig({
- id,
- template_id: templateId,
- name,
- deployment_target: 'local',
- config: configValues,
- })
- const instancesRes = await svcConfigsApi.getServiceConfigs()
- setServiceConfigs(instancesRes.data)
- setMessage({ type: 'success', text: `Configuration "${name}" created` })
- return id
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to create configuration',
- })
- throw error // Re-throw so caller knows it failed
- }
- }}
- onEditConfig={handleEditSavedConfig}
- onDeleteConfig={handleDeleteSavedConfig}
- onUpdateConfig={handleUpdateSavedConfig}
- onStart={async () => {
- await handleDeployConsumer(template.id, { type: 'local' })
- }}
- onStop={async () => {
- await handleStopConsumer(template.id)
- }}
- onEdit={() => handleEditConsumer(template.id)}
- onDeploy={(target) => handleDeployConsumer(template.id, target)}
- />
- )
- })}
-
-
- {composeTemplates.filter((t) => t.requires && t.requires.length > 0).length === 0 && (
-
-
-
- No services installed yet. Click "Browse Services" to add some.
-
-
- )}
+ {/* Wiring Board - Drag and Drop Interface */}
+
+
+
+
+ Wiring
+
+
+ Drag providers to connect them to service capability slots
+
- ) : activeTab === 'providers' ? (
- /* Providers Tab - Card-based UI grouped by capability */
-
- {/* Group providers by capability type */}
- {(() => {
- const configuredProviders = providerTemplates.filter((p) => p.configured)
- const grouped = configuredProviders.reduce((acc, provider) => {
- const capability = provider.provides || 'other'
- if (!acc[capability]) acc[capability] = []
- acc[capability].push(provider)
- return acc
- }, {} as Record
)
-
- const capabilityOrder = ['llm', 'transcription', 'memory', 'embedding', 'tts', 'other']
- const sortedCapabilities = Object.keys(grouped).sort((a, b) => {
- const aIndex = capabilityOrder.indexOf(a)
- const bIndex = capabilityOrder.indexOf(b)
- if (aIndex === -1 && bIndex === -1) return a.localeCompare(b)
- if (aIndex === -1) return 1
- if (bIndex === -1) return -1
- return aIndex - bIndex
- })
-
- if (sortedCapabilities.length === 0) {
- return (
-
-
-
- No providers configured yet.
-
-
- Configure a provider from a service's dropdown to see it here.
-
-
- )
- }
- return sortedCapabilities.map((capability) => (
-
-
- {capability}
-
-
- {grouped[capability].map((provider) => {
- const isExpanded = expandedProviderCard === provider.id
- return (
-
- {/* Card Header */}
-
handleExpandProviderCard(provider.id)}
- >
-
- {provider.mode === 'cloud' ? (
-
- ) : (
-
- )}
-
-
-
- {provider.name}
-
- {provider.description && !isExpanded && (
-
- {provider.description}
-
- )}
-
-
-
-
-
- {/* Expanded Content - EnvVarEditor */}
- {isExpanded && (
-
- {loadingProviderCard ? (
-
-
- Loading...
-
- ) : providerCardEnvVars.length > 0 ? (
- <>
-
- {providerCardEnvVars.map((envVar) => {
- const config = providerCardEnvConfigs[envVar.name] || {
- name: envVar.name,
- source: 'default',
- }
- return (
- {
- setProviderCardEnvConfigs((prev) => ({
- ...prev,
- [envVar.name]: { ...prev[envVar.name], ...updates } as EnvVarConfig,
- }))
- }}
- />
- )
- })}
-
- {/* Footer Actions */}
-
-
-
-
- >
- ) : (
-
- No configuration options available.
-
- )}
-
- )}
-
- )
- })}
-
-
- ))
- })()}
-
- ) : activeTab === 'overview' ? (
- /* System Overview - Read-only Visualization */
-
- ) : activeTab === 'deployments' ? (
- /* Deployments Tab */
-
-
-
- Active deployments across all services ({filteredDeployments.length} total)
-
-
-
-
- {filteredDeployments.length === 0 ? (
-
-
-
No deployments found
-
- Deploy services from the Services tab
-
-
- ) : (
-
- {filteredDeployments.map((deployment) => {
- const template = templates.find(t => t.id === deployment.service_id)
- return (
-
-
-
-
-
- {template?.name || deployment.service_id}
-
-
- {deployment.status}
-
-
- {/* Stop/Restart button next to status */}
- {(deployment.status === 'running' || deployment.status === 'deploying') ? (
-
- ) : (
-
- )}
-
-
-
-
- {deployment.unode_hostname}
-
- {deployment.exposed_port && (
-
- :{deployment.exposed_port}
-
- )}
-
-
-
- {/* Edit */}
-
-
- {/* Remove */}
-
-
-
-
- )
- })}
-
- )}
+
+ {
+ if (isTemplate) {
+ // For templates, we can't deploy them directly - need to create instance first
+ // This case shouldn't happen as templates don't have start buttons in current UI
+ return
+ }
+ await handleDeployServiceConfig(providerId)
+ }}
+ onStopProvider={async (providerId, isTemplate) => {
+ if (isTemplate) {
+ return
+ }
+ await handleUndeployServiceConfig(providerId)
+ }}
+ onEditConsumer={handleEditConsumer}
+ onStartConsumer={handleStartConsumer}
+ onStopConsumer={handleStopConsumer}
+ onDeployConsumer={handleDeployConsumer}
+ />
- ) : null}
+
{/* Edit Provider/ServiceConfig Modal */}
- {/* Edit Deployment Modal */}
- {
- setEditingDeployment(null)
- setDeploymentEnvVars([])
- setDeploymentEnvConfigs({})
- }}
- title="Edit Deployment"
- titleIcon={}
- maxWidth="lg"
- testId="edit-deployment-modal"
- >
- {editingDeployment && (
-
- {/* Deployment info */}
-
-
- {templates.find(t => t.id === editingDeployment.service_id)?.name || editingDeployment.service_id}
-
-
-
- {editingDeployment.unode_hostname}
-
- {editingDeployment.exposed_port && (
-
- :{editingDeployment.exposed_port}
-
- )}
-
-
-
- {/* Environment variables */}
- {loadingEnvConfig ? (
-
-
- Loading configuration...
-
- ) : deploymentEnvVars.length > 0 ? (
-
-
-
- {deploymentEnvVars.map((envVar) => {
- const config = deploymentEnvConfigs[envVar.name] || {
- name: envVar.name,
- source: 'default',
- value: undefined,
- setting_path: undefined,
- new_setting_path: undefined,
- }
-
- return (
- {
- setDeploymentEnvConfigs((prev) => ({
- ...prev,
- [envVar.name]: { ...prev[envVar.name], ...updates },
- }))
- }}
- for_deploy={true}
- />
- )
- })}
-
-
- ) : (
-
No environment variables to configure
- )}
-
- {/* Action buttons */}
-
-
-
-
-
- )}
-
-
{/* Add Provider Modal */}
setConfirmDialog({ isOpen: false, instanceId: null })}
/>
- {/* Unified Deploy Modal (K8s and Docker) */}
- {deployModalState.isOpen && (
- setDeployModalState({ isOpen: false, serviceId: null })}
- target={deployModalState.targetId ? availableTargets.find((t) => t.id === deployModalState.targetId) : undefined}
- availableTargets={availableTargets}
+ onClose={() => setDeployModalState({ isOpen: false, serviceId: null, targetType: null })}
+ cluster={deployModalState.selectedClusterId ? kubernetesClusters.find((c) => c.cluster_id === deployModalState.selectedClusterId) : undefined}
+ availableClusters={kubernetesClusters}
infraServices={deployModalState.infraServices || {}}
preselectedServiceId={deployModalState.serviceId || undefined}
/>
@@ -2132,6 +1973,74 @@ export default function ServiceConfigsPage() {
)}
+ {/* Simple Deploy Modal (for local/remote with env vars) */}
+ setSimpleDeployModal({ isOpen: false, serviceId: null, targetType: null })}
+ title={`Deploy to ${simpleDeployModal.targetType === 'local' ? 'Local' : 'Remote'} uNode`}
+ maxWidth="lg"
+ testId="simple-deploy-modal"
+ >
+
+ {loadingDeployEnv && (
+
+
+ Loading configuration...
+
+ )}
+
+ {!loadingDeployEnv && deployEnvVars.length > 0 && (
+ <>
+
+ Configure environment variables for this deployment:
+
+
+ {deployEnvVars.map((ev) => (
+ {
+ setDeployEnvConfigs((prev) => ({
+ ...prev,
+ [ev.name]: { ...prev[ev.name], ...updates },
+ }))
+ }}
+ />
+ ))}
+
+ >
+ )}
+
+ {!loadingDeployEnv && deployEnvVars.length === 0 && (
+
+ No environment variables to configure.
+
+ )}
+
+
+
+
+
+
+
)
}
@@ -2247,3 +2156,349 @@ function ConfigFieldRow({ field, value, onChange, readOnly: _readOnly = false }:
)
}
+
+// =============================================================================
+// Template Card Component
+// =============================================================================
+
+interface TemplateCardProps {
+ template: Template
+ isExpanded: boolean
+ onToggle: () => void
+ onCreate: () => void
+ onRemove?: () => void
+}
+
+function TemplateCard({ template, isExpanded, onToggle, onCreate, onRemove }: TemplateCardProps) {
+ const isCloud = template.mode === 'cloud'
+ // Integrations provide "memory_source" capability and config is per-instance
+ const isIntegration = template.provides === 'memory_source'
+ // Integrations are always "ready" - config is per-instance
+ const isReady = isIntegration ? true : (template.configured && template.available)
+ const needsConfig = isIntegration ? false : !template.configured
+ const notRunning = isIntegration ? false : (template.configured && !template.available)
+
+ return (
+
+
+
+
+ {isCloud ? (
+
+ ) : (
+
+ )}
+
+
+
+ {template.name}
+
+ {isCloud ? 'Cloud' : 'Self-Hosted'}
+
+
+ {/* Status badge */}
+ {needsConfig && (
+
+
+ Configure
+
+ )}
+ {notRunning && (
+
+
+ Not Running
+
+ )}
+ {isReady && template.provides && (
+
+ {template.provides}
+
+ )}
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {!isExpanded && template.description && (
+
{template.description}
+ )}
+
+
+ {isExpanded && (
+
+
+ {template.description && (
+
{template.description}
+ )}
+
+ {/* Requires */}
+ {template.requires && template.requires.length > 0 && (
+
+
Requires:
+
+ {template.requires.map((req) => (
+
+ {req}
+
+ ))}
+
+
+ )}
+
+ {/* Config schema preview */}
+ {template.config_schema && template.config_schema.length > 0 && (
+
+
+
+ {template.config_schema.length} config field
+ {template.config_schema.length !== 1 ? 's' : ''}
+
+
+ )}
+
+
+ {/* Action buttons */}
+
+
+ {onRemove && (
+
+ )}
+
+
+ )}
+
+ )
+}
+
+// =============================================================================
+// Env Var Row Component (matches ServicesPage env var editor)
+// =============================================================================
+
+interface EnvVarRowProps {
+ envVar: EnvVarInfo
+ config: EnvVarConfig
+ onChange: (updates: Partial) => void
+}
+
+function EnvVarRow({ envVar, config, onChange }: EnvVarRowProps) {
+ const [editing, setEditing] = useState(false)
+ const [showMapping, setShowMapping] = useState(config.source === 'setting' && !config.locked)
+
+ const isSecret = envVar.name.includes('KEY') || envVar.name.includes('SECRET') || envVar.name.includes('PASSWORD')
+ const hasDefault = envVar.has_default && envVar.default_value
+ const isUsingDefault = config.source === 'default' || (!config.value && !config.setting_path && hasDefault)
+ const isLocked = config.locked || false
+
+ // Generate setting path from env var name for auto-creating settings
+ const autoSettingPath = () => {
+ const name = envVar.name.toLowerCase()
+ if (name.includes('api_key') || name.includes('key') || name.includes('secret') || name.includes('token')) {
+ return `api_keys.${name}`
+ }
+ return `settings.${name}`
+ }
+
+ // Handle value input - auto-create setting
+ const handleValueChange = (value: string) => {
+ if (value) {
+ onChange({ source: 'new_setting', new_setting_path: autoSettingPath(), value, setting_path: undefined })
+ } else {
+ onChange({ source: 'default', value: undefined, setting_path: undefined, new_setting_path: undefined })
+ }
+ }
+
+ // Check if there's a matching suggestion for auto-mapping
+ const matchingSuggestion = envVar.suggestions.find((s) => {
+ const envName = envVar.name.toLowerCase()
+ const pathParts = s.path.toLowerCase().split('.')
+ const lastPart = pathParts[pathParts.length - 1]
+ return envName.includes(lastPart) || lastPart.includes(envVar.name.replace(/_/g, ''))
+ })
+
+ // Auto-map if matching and not yet configured
+ const effectiveSettingPath = config.setting_path || (matchingSuggestion?.has_value ? matchingSuggestion.path : undefined)
+
+ // Locked fields - provided by wired providers or infrastructure
+ if (isLocked) {
+ const displayValue = config.value || ''
+ const isMaskedSecret = isSecret && displayValue.length > 0
+ const maskedValue = isMaskedSecret ? '•'.repeat(Math.min(displayValue.length, 20)) : displayValue
+
+ return (
+
+ {/* Label */}
+
+ {envVar.name}
+ {envVar.is_required && *}
+
+
+ {/* Padlock icon */}
+
+
+
+
+ {/* Value display */}
+
+
+ {maskedValue}
+
+
+ {config.provider_name || 'infrastructure'}
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Label */}
+
+ {envVar.name}
+ {envVar.is_required && *}
+
+
+ {/* Map button - LEFT of input */}
+
+
+ {/* Input area */}
+
+ {showMapping ? (
+ // Mapping mode - styled dropdown
+
+ ) : hasDefault && isUsingDefault && !editing ? (
+ // Default value display
+ <>
+
+
{envVar.default_value}
+
+ default
+
+ >
+ ) : (
+ // Value input
+
handleValueChange(e.target.value)}
+ placeholder="enter value"
+ className="flex-1 px-2 py-1.5 text-xs rounded border-0 bg-neutral-700/50 text-neutral-200 focus:outline-none focus:ring-1 focus:ring-primary-500 placeholder:text-neutral-500"
+ autoFocus={editing}
+ onBlur={() => {
+ if (!config.value && hasDefault) setEditing(false)
+ }}
+ data-testid={`value-input-${envVar.name}`}
+ />
+ )}
+
+
+ )
+}
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts
index 42b7bd86..ce316eff 100644
--- a/ushadow/frontend/src/services/api.ts
+++ b/ushadow/frontend/src/services/api.ts
@@ -265,14 +265,14 @@ export const servicesApi = {
getConfig: (name: string) => api.get(`/api/services/${name}/config`),
/** Get environment variable configuration with suggestions */
- getEnvConfig: (name: string, deployTarget?: string) => api.get<{
+ getEnvConfig: (name: string) => api.get<{
service_id: string
service_name: string
compose_file: string
requires: string[]
required_env_vars: EnvVarInfo[]
optional_env_vars: EnvVarInfo[]
- }>(`/api/services/${name}/env${deployTarget ? `?deploy_target=${encodeURIComponent(deployTarget)}` : ''}`),
+ }>(`/api/services/${name}/env`),
/** Save environment variable configuration */
updateEnvConfig: (name: string, envVars: EnvVarConfig[]) =>
@@ -318,12 +318,10 @@ export const servicesApi = {
// Compose service configuration endpoints
export interface EnvVarConfig {
name: string
- // Old sources: 'setting' | 'new_setting' | 'literal' | 'default'
- // New v2 sources: 'config_default' | 'compose_default' | 'env_file' | 'capability' | 'deploy_env' | 'user_override' | 'not_found'
- source: string
+ source: 'setting' | 'new_setting' | 'literal' | 'default'
setting_path?: string // For source='setting' - existing setting to map
new_setting_path?: string // For source='new_setting' - new setting path to create
- value?: string // For source='literal' or 'new_setting', or resolved value
+ value?: string // For source='literal' or 'new_setting'
locked?: boolean // For provider-supplied values that cannot be edited
provider_name?: string // Name of the provider supplying this value
}
@@ -340,12 +338,13 @@ export interface EnvVarSuggestion {
export interface EnvVarInfo {
name: string
is_required: boolean
+ has_default: boolean
+ default_value?: string
source: string
setting_path?: string
+ value?: string
resolved_value?: string
suggestions: EnvVarSuggestion[]
- locked?: boolean
- provider_name?: string
}
/** Missing key that needs to be configured for a provider */
@@ -550,7 +549,6 @@ export interface KubernetesCluster {
namespace: string
labels: Record
infra_scans?: Record
- deployment_target_id?: string // Unified deployment target ID: {name}.k8s.{environment}
}
export const kubernetesApi = {
@@ -642,34 +640,7 @@ export interface Deployment {
exposed_port?: number
}
-export interface DeployTarget {
- // Core identity fields (always present)
- id: string // deployment_target_id format: {identifier}.{type}.{environment}
- type: 'docker' | 'k8s'
- name: string // Human-readable name
- identifier: string // hostname (docker) or cluster_id (k8s)
- environment: string // e.g., 'purple', 'production'
-
- // Status and health
- status: string // online/offline/healthy/unknown
-
- // Platform-specific fields (optional)
- namespace?: string // K8s namespace (k8s only)
- infrastructure?: Record // Infrastructure scan data (k8s only)
-
- // Common metadata
- provider?: string // local/remote/eks/gke/aks
- region?: string // Region or location
- is_leader?: boolean // Is this the leader node (docker only)
-
- // Raw data for advanced use cases
- raw_metadata: Record // Original UNode or KubernetesCluster data
-}
-
export const deploymentsApi = {
- // Deployment targets (unified)
- listTargets: () => api.get('/api/deployments/targets'),
-
// Service definitions
createService: (data: Omit) =>
api.post('/api/deployments/services', data),
@@ -680,8 +651,8 @@ export const deploymentsApi = {
deleteService: (serviceId: string) => api.delete(`/api/deployments/services/${serviceId}`),
// Deployments
- deploy: (serviceId: string, unodeHostname: string, configId?: string) =>
- api.post('/api/deployments/deploy', { service_id: serviceId, unode_hostname: unodeHostname, config_id: configId }),
+ deploy: (serviceId: string, unodeHostname: string) =>
+ api.post('/api/deployments/deploy', { service_id: serviceId, unode_hostname: unodeHostname }),
listDeployments: (params?: { service_id?: string; unode_hostname?: string }) =>
api.get('/api/deployments', { params }),
getDeployment: (deploymentId: string) => api.get(`/api/deployments/${deploymentId}`),
@@ -1235,10 +1206,6 @@ export const svcConfigsApi = {
getTemplate: (templateId: string) =>
api.get(`/api/svc-configs/templates/${templateId}`),
- /** Get env var config with suggestions for a template (same process as services) */
- getTemplateEnvConfig: (templateId: string) =>
- api.get(`/api/svc-configs/templates/${templateId}/env`),
-
// ServiceConfigs
/** List all instances */
getServiceConfigs: () =>
@@ -1698,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 = {
@@ -1756,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,
}
}),
diff --git a/ushadow/frontend/src/testing/ui-contract.ts b/ushadow/frontend/src/testing/ui-contract.ts
new file mode 100644
index 00000000..1bca2d67
--- /dev/null
+++ b/ushadow/frontend/src/testing/ui-contract.ts
@@ -0,0 +1,259 @@
+/**
+ * UI Contract - Single source of truth for component testid patterns.
+ *
+ * This file defines the contract between:
+ * - React components (which generate data-testid attributes)
+ * - Playwright POMs (which locate elements by testid)
+ * - AI agents (which reference these patterns when writing code)
+ *
+ * If you change a pattern here, TypeScript will surface breakages.
+ *
+ * @example
+ * // In React component:
+ * import { modal } from '@/testing/ui-contract'
+ *
+ *
+ * // In Playwright POM:
+ * import { modal } from '../../src/testing/ui-contract'
+ * page.getByTestId(modal.container('my-modal'))
+ */
+
+// =============================================================================
+// MODAL
+// =============================================================================
+
+/**
+ * Modal component - REQUIRED for all modal dialogs.
+ *
+ * Import: `import Modal from '@/components/Modal'`
+ *
+ * @example
+ *
setIsOpen(false)}
+ * title="Confirm Action"
+ * maxWidth="sm" // 'sm' | 'md' | 'lg' | 'xl' | '2xl'
+ * testId="confirm-delete"
+ * >
+ * Are you sure?
+ *
+ *
+ *
+ * @forbidden Do NOT create custom modals with `fixed inset-0` divs.
+ */
+export const modal = {
+ /** The root container: `data-testid="my-modal"` */
+ container: (id: string) => id,
+ /** Clickable backdrop: `data-testid="my-modal-backdrop"` */
+ backdrop: (id: string) => `${id}-backdrop`,
+ /** Inner content wrapper: `data-testid="my-modal-content"` */
+ content: (id: string) => `${id}-content`,
+ /** Close X button: `data-testid="my-modal-close"` */
+ close: (id: string) => `${id}-close`,
+} as const
+
+// =============================================================================
+// SERVICE CARD
+// =============================================================================
+
+/**
+ * ServiceCard component - Displays a service with status, toggle, and config.
+ *
+ * Import: `import { ServiceCard } from '@/components/services/ServiceCard'`
+ *
+ * @example
+ *
toggleExpanded(service.service_id)}
+ * onStart={() => startService(service.service_id)}
+ * onStop={() => stopService(service.service_id)}
+ * onToggleEnabled={() => toggleEnabled(service.service_id)}
+ * onStartEdit={() => startEditing(service.service_id)}
+ * onSave={() => saveConfig(service.service_id)}
+ * onCancelEdit={cancelEditing}
+ * onFieldChange={setEditFormField}
+ * onRemoveField={removeField}
+ * />
+ *
+ * @note Currently uses `id=` instead of `data-testid=` - migration pending
+ */
+export const serviceCard = {
+ /** Card container: `id="service-card-{serviceId}"` */
+ container: (serviceId: string) => `service-card-${serviceId}`,
+ /** Enable/disable toggle: `id="toggle-enabled-{serviceId}"` */
+ toggleEnabled: (serviceId: string) => `toggle-enabled-${serviceId}`,
+} as const
+
+// =============================================================================
+// ENV VAR EDITOR
+// =============================================================================
+
+/**
+ * EnvVarEditor component - Edit environment variable mappings.
+ *
+ * Import: `import EnvVarEditor from '@/components/EnvVarEditor'`
+ *
+ * Used for:
+ * - Docker service configuration (ServicesPage)
+ * - K8s deployment configuration (DeployToK8sModal)
+ * - Instance configuration (ServiceConfigsPage)
+ *
+ * @example
+ * setConfig({ ...config, ...updates })}
+ * />
+ */
+export const envVarEditor = {
+ /** Row container: `data-testid="env-var-editor-{varName}"` */
+ container: (varName: string) => `env-var-editor-${varName}`,
+ /** Map to setting button: `data-testid="map-button-{varName}"` */
+ mapButton: (varName: string) => `map-button-${varName}`,
+ /** Setting path dropdown: `data-testid="map-select-{varName}"` */
+ mapSelect: (varName: string) => `map-select-${varName}`,
+ /** Value text input: `data-testid="value-input-{varName}"` */
+ valueInput: (varName: string) => `value-input-${varName}`,
+} as const
+
+// =============================================================================
+// FORM COMPONENTS (from settings/)
+// =============================================================================
+
+/**
+ * SecretInput - API key/password input with visibility toggle.
+ *
+ * Import: `import { SecretInput } from '@/components/settings/SecretInput'`
+ *
+ * @example
+ * // Standalone
+ *
+ *
+ * @example
+ * // With react-hook-form
+ * (
+ *
+ * )}
+ * />
+ */
+export const secretInput = {
+ container: (id: string) => `secret-input-${id}`,
+ field: (id: string) => `secret-input-${id}-field`,
+ toggle: (id: string) => `secret-input-${id}-toggle`,
+ error: (id: string) => `secret-input-${id}-error`,
+} as const
+
+/**
+ * SettingField - Generic setting field supporting multiple input types.
+ *
+ * Import: `import { SettingField } from '@/components/settings/SettingField'`
+ *
+ * @example
+ *
+ */
+export const settingField = {
+ container: (id: string) => `setting-field-${id}`,
+ input: (id: string) => `setting-field-${id}-input`,
+ select: (id: string) => `setting-field-${id}-select`,
+ toggle: (id: string) => `setting-field-${id}-toggle`,
+ error: (id: string) => `setting-field-${id}-error`,
+ label: (id: string) => `setting-field-${id}-label`,
+} as const
+
+/**
+ * SettingsSection - Container for grouping related settings.
+ *
+ * Import: `import { SettingsSection } from '@/components/settings/SettingsSection'`
+ *
+ * @example
+ *
+ *
+ *
+ *
+ */
+export const settingsSection = {
+ container: (id: string) => `settings-section-${id}`,
+ header: (id: string) => `settings-section-${id}-header`,
+ content: (id: string) => `settings-section-${id}-content`,
+} as const
+
+// =============================================================================
+// PAGE-LEVEL PATTERNS
+// =============================================================================
+
+/**
+ * Standard page container testid.
+ * Every page should have a root element with this testid.
+ */
+export const page = {
+ container: (name: string) => `${name}-page`,
+} as const
+
+/**
+ * Tab navigation patterns.
+ */
+export const tabs = {
+ button: (id: string) => `tab-${id}`,
+ panel: (id: string) => `tab-panel-${id}`,
+} as const
+
+/**
+ * Wizard step patterns.
+ */
+export const wizard = {
+ step: (wizardId: string, stepId: string) => `${wizardId}-step-${stepId}`,
+ nextButton: (wizardId: string) => `${wizardId}-next`,
+ backButton: (wizardId: string) => `${wizardId}-back`,
+ submitButton: (wizardId: string) => `${wizardId}-submit`,
+} as const
+
+// =============================================================================
+// GENERIC PATTERNS
+// =============================================================================
+
+/**
+ * Confirm dialog patterns.
+ * Use ConfirmDialog component from '@/components/ConfirmDialog'
+ */
+export const confirmDialog = {
+ container: (id: string) => `${id}-dialog`,
+ title: (id: string) => `${id}-title`,
+ message: (id: string) => `${id}-message`,
+ confirmButton: (id: string) => `${id}-confirm`,
+ cancelButton: (id: string) => `${id}-cancel`,
+} as const
+
+/**
+ * Action button pattern - for any clickable action within a context.
+ *
+ * @example
+ *
+ * // Results in: "settings-save"
+ */
+export const actionButton = (context: string, action: string) =>
+ `${context}-${action}` as const
diff --git a/ushadow/frontend/src/wizards/TailscaleWizard.tsx b/ushadow/frontend/src/wizards/TailscaleWizard.tsx
index 18240e14..74dc78e6 100644
--- a/ushadow/frontend/src/wizards/TailscaleWizard.tsx
+++ b/ushadow/frontend/src/wizards/TailscaleWizard.tsx
@@ -1227,101 +1227,99 @@ export default function TailscaleWizard() {
{/* Complete Step */}
{wizard.currentStep.id === 'complete' && (
-
-
-
-
- Level 2 Complete!
-
-
- Your ushadow instance is now accessible securely from anywhere
-
+
+ {/* Compact header with icon inline */}
+
+
+
+
+ Level 2 Complete!
+
+
+ Secure access enabled from anywhere
+
+
-
-
Your secure access URL:
+ {/* Access URL - more compact */}
+
-
-
- What's been configured:
-
-
- {/* Dev Tools Panel */}
- {showDevTools && (
-
-
-
- )}
-
{/* Main Content */}
{appMode === 'quick' ? (
@@ -1160,7 +1419,11 @@ function App() {
installingItem={installingItem}
onInstall={handleInstall}
onStartDocker={handleStartDocker}
+ showDevTools={showDevTools}
+ onToggleDevTools={() => setShowDevTools(!showDevTools)}
/>
+ {/* Dev Tools Panel - appears below Prerequisites */}
+ {showDevTools && }
setShowNewEnvDialog(true)}
onOpenInApp={handleOpenInApp}
+ onMerge={handleMerge}
+ onDelete={handleDelete}
+ onAttachTmux={handleAttachTmux}
onDismissError={(name) => setCreatingEnvs(prev => prev.filter(e => e.name !== name))}
loadingEnv={loadingEnv}
+ tmuxStatuses={tmuxStatuses}
/>
@@ -1219,10 +1486,22 @@ function App() {
isOpen={showNewEnvDialog}
projectRoot={projectRoot}
onClose={() => setShowNewEnvDialog(false)}
- onClone={handleNewEnvClone}
onLink={handleNewEnvLink}
onWorktree={handleNewEnvWorktree}
/>
+
+ {/* Tmux Manager Dialog */}
+
setShowTmuxManager(false)}
+ onRefresh={() => refreshDiscovery(true)}
+ />
+
+ {/* Settings Dialog */}
+ setShowSettingsDialog(false)}
+ />
)
}
diff --git a/ushadow/launcher/src/components/BranchSelector.tsx b/ushadow/launcher/src/components/BranchSelector.tsx
new file mode 100644
index 00000000..6120fb7f
--- /dev/null
+++ b/ushadow/launcher/src/components/BranchSelector.tsx
@@ -0,0 +1,156 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { ChevronDown, GitBranch, Sparkles } from 'lucide-react'
+
+interface BranchSelectorProps {
+ branches: string[]
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ testId?: string
+}
+
+/**
+ * Branch selector with autocomplete dropdown
+ * Highlights Claude-created branches (starting with "claude/")
+ */
+export function BranchSelector({ branches, value, onChange, placeholder = 'Type or select branch...', testId = 'branch-selector' }: BranchSelectorProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [filteredBranches, setFilteredBranches] = useState
(branches)
+ const inputRef = useRef(null)
+ const dropdownRef = useRef(null)
+
+ // Filter branches based on input value
+ useEffect(() => {
+ if (value.trim() === '') {
+ setFilteredBranches(branches)
+ } else {
+ const lowerValue = value.toLowerCase()
+ setFilteredBranches(
+ branches.filter(branch =>
+ branch.toLowerCase().includes(lowerValue)
+ )
+ )
+ }
+ }, [value, branches])
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node) &&
+ inputRef.current &&
+ !inputRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false)
+ }
+ }
+
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [])
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ onChange(e.target.value)
+ setIsOpen(true)
+ }
+
+ const handleSelectBranch = (branch: string) => {
+ onChange(branch)
+ setIsOpen(false)
+ inputRef.current?.blur()
+ }
+
+ const handleInputFocus = () => {
+ setIsOpen(true)
+ }
+
+ const isClaudeBranch = (branch: string) => {
+ return branch.startsWith('claude/')
+ }
+
+ // Sort branches: Claude branches first, then alphabetically
+ const sortedBranches = [...filteredBranches].sort((a, b) => {
+ const aIsClaude = isClaudeBranch(a)
+ const bIsClaude = isClaudeBranch(b)
+
+ if (aIsClaude && !bIsClaude) return -1
+ if (!aIsClaude && bIsClaude) return 1
+ return a.localeCompare(b)
+ })
+
+ return (
+
+
+
+
+
+
+ {isOpen && sortedBranches.length > 0 && (
+
+ {sortedBranches.map((branch) => {
+ const isClaude = isClaudeBranch(branch)
+ return (
+
+ )
+ })}
+
+ )}
+
+ {isOpen && filteredBranches.length === 0 && value.trim() !== '' && (
+
+
+ No branches match "{value}". Press Enter to create it.
+
+
+ )}
+
+ )
+}
diff --git a/ushadow/launcher/src/components/EmbeddedView.tsx b/ushadow/launcher/src/components/EmbeddedView.tsx
index eb11ccd6..a28c8036 100644
--- a/ushadow/launcher/src/components/EmbeddedView.tsx
+++ b/ushadow/launcher/src/components/EmbeddedView.tsx
@@ -1,14 +1,15 @@
-import { X, ExternalLink, RefreshCw, ArrowLeft } from 'lucide-react'
+import { X, ExternalLink, RefreshCw, ArrowLeft, Terminal } from 'lucide-react'
import { tauri } from '../hooks/useTauri'
interface EmbeddedViewProps {
url: string
envName: string
envColor?: string
+ envPath: string | null
onClose: () => void
}
-export function EmbeddedView({ url, envName, envColor, onClose }: EmbeddedViewProps) {
+export function EmbeddedView({ url, envName, envColor, envPath, onClose }: EmbeddedViewProps) {
const handleOpenExternal = () => {
tauri.openBrowser(url)
}
@@ -20,6 +21,19 @@ export function EmbeddedView({ url, envName, envColor, onClose }: EmbeddedViewPr
}
}
+ const handleOpenVscode = async () => {
+ if (envPath) {
+ await tauri.openInVscode(envPath, envName)
+ }
+ }
+
+ const handleOpenTerminal = async () => {
+ if (envPath) {
+ const windowName = `ushadow-${envName}`
+ await tauri.openTmuxInTerminal(windowName, envPath)
+ }
+ }
+
return (
{/* Header bar */}
@@ -49,6 +63,29 @@ export function EmbeddedView({ url, envName, envColor, onClose }: EmbeddedViewPr
>
+
+ {/* VSCode and Terminal buttons - only show if envPath exists */}
+ {envPath && (
+ <>
+
+
+ >
+ )}
+
)
)}
+
+ {/* Tmux Manager Dialog */}
+ setShowTmuxManager(false)}
+ />
)
}
@@ -242,12 +278,49 @@ interface EnvironmentCardProps {
onStart: () => void
onStop: () => void
onOpenInApp: () => void
+ onMerge?: () => void
+ onDelete?: () => void
+ onAttachTmux?: () => void
isLoading: boolean
+ tmuxStatus?: TmuxStatus
}
-function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, isLoading }: EnvironmentCardProps) {
+function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, onMerge, onDelete, onAttachTmux, isLoading, tmuxStatus }: EnvironmentCardProps) {
+ const [showTmuxWindows, setShowTmuxWindows] = useState(false)
+ const [tmuxWindows, setTmuxWindows] = useState>([])
+ const [showTmuxOutput, setShowTmuxOutput] = useState(false)
+ const [tmuxOutput, setTmuxOutput] = useState('')
+ const [claudeStatus, setClaudeStatus] = useState(null)
+ const [loadingClaudeStatus, setLoadingClaudeStatus] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
const colors = getColors(environment.color || environment.name)
+ // Load Claude status when tmux is active
+ useEffect(() => {
+ if (tmuxStatus && tmuxStatus.exists && environment.is_worktree) {
+ loadClaudeStatus()
+ // Poll every 10 seconds
+ const interval = setInterval(loadClaudeStatus, 10000)
+ return () => clearInterval(interval)
+ }
+ }, [tmuxStatus?.exists, environment.name])
+
+ const loadClaudeStatus = async () => {
+ if (loadingClaudeStatus) return
+ setLoadingClaudeStatus(true)
+ try {
+ const windowName = `ushadow-${environment.name.toLowerCase()}`
+ console.log(`[${environment.name}] Checking Claude status for window: ${windowName}`)
+ const status = await tauri.getClaudeStatus(windowName)
+ console.log(`[${environment.name}] Claude status:`, status)
+ setClaudeStatus(status)
+ } catch (err) {
+ console.error('Failed to load Claude status:', err)
+ } finally {
+ setLoadingClaudeStatus(false)
+ }
+ }
+
const localhostUrl = environment.localhost_url || (environment.backend_port ? `http://localhost:${environment.webui_port || environment.backend_port}` : null)
const handleOpenUrl = (url: string) => {
@@ -261,12 +334,98 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, isLoading
}
}
+ const loadTmuxWindows = async () => {
+ try {
+ const sessions = await tauri.getTmuxSessions()
+ const workmuxSession = sessions.find(s => s.name === 'workmux')
+ if (workmuxSession) {
+ // Filter windows for this environment
+ const envWindowPrefix = `ushadow-${environment.name.toLowerCase()}`
+ const envWindows = workmuxSession.windows.filter(w =>
+ w.name.toLowerCase().startsWith(envWindowPrefix)
+ )
+ setTmuxWindows(envWindows)
+ } else {
+ setTmuxWindows([])
+ }
+ } catch (err) {
+ console.error('Failed to load tmux windows:', err)
+ setTmuxWindows([])
+ }
+ }
+
+ const handleToggleTmuxWindows = async () => {
+ if (!showTmuxWindows) {
+ await loadTmuxWindows()
+ }
+ setShowTmuxWindows(!showTmuxWindows)
+ }
+
+ const handleKillWindow = async (windowName: string) => {
+ try {
+ await tauri.killTmuxWindow(windowName)
+ await loadTmuxWindows()
+ } catch (err) {
+ console.error('Failed to kill window:', err)
+ }
+ }
+
+ const handleOpenTmuxWindow = async (windowName: string) => {
+ try {
+ await tauri.openTmuxInTerminal(windowName)
+ } catch (err) {
+ console.error('Failed to open tmux window:', err)
+ }
+ }
+
+ const handleToggleTmuxOutput = async () => {
+ if (!showTmuxOutput) {
+ // Load tmux output
+ try {
+ const windowName = `ushadow-${environment.name.toLowerCase()}`
+ const output = await tauri.captureTmuxPane(windowName)
+ setTmuxOutput(output)
+ } catch (err) {
+ console.error('Failed to capture tmux output:', err)
+ setTmuxOutput('Failed to capture tmux output')
+ }
+ }
+ setShowTmuxOutput(!showTmuxOutput)
+ }
+
+ const handleRefreshTmuxOutput = async () => {
+ try {
+ const windowName = `ushadow-${environment.name.toLowerCase()}`
+ const output = await tauri.captureTmuxPane(windowName)
+ setTmuxOutput(output)
+ } catch (err) {
+ console.error('Failed to capture tmux output:', err)
+ }
+ }
+
+ const handleDelete = () => {
+ if (onDelete) {
+ setIsDeleting(true)
+ // Wait for animation to complete before actually deleting
+ setTimeout(() => {
+ onDelete()
+ }, 300) // Match animation duration
+ }
+ }
+
return (
@@ -296,7 +455,33 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, isLoading
{environment.branch}
)}
+ {/* Tmux status badge */}
+ {tmuxStatus && tmuxStatus.exists && (
+
+ {getTmuxStatusIcon(tmuxStatus)} {tmuxStatus.current_command || 'tmux'}
+
+ )}
+ {/* Claude Code status badge */}
+ {claudeStatus && claudeStatus.is_running && (
+
+
+ Claude
+
+ )}
+ {/* Claude current task */}
+ {claudeStatus && claudeStatus.is_running && (
+
+ 🤖 {claudeStatus.current_task || 'Active (click Claude badge to view output)'}
+
+ )}
{/* Container tags */}
{environment.containers.length > 0 && (
@@ -344,6 +529,21 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, isLoading
{/* Top right buttons */}
+ {/* Terminal button - Opens iTerm2 or Terminal.app for all worktrees */}
+ {environment.is_worktree && environment.path && (
+
{
+ const windowName = `ushadow-${environment.name}`
+ await tauri.openTmuxInTerminal(windowName, environment.path)
+ }}
+ className="p-2 rounded bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 transition-colors"
+ title="Open in iTerm2 / Terminal.app"
+ data-testid={`terminal-${environment.name}`}
+ >
+
+
+ )}
+
{/* VS Code button - small */}
{environment.path && (
- {/* Start/Stop button - bottom */}
-
- {environment.running ? (
-
- {isLoading ? : }
- Stop
-
- ) : (
-
- {isLoading ? : }
- Start
-
- )}
+ {/* Tmux windows list */}
+ {showTmuxWindows && (
+
+
Tmux Windows:
+ {tmuxWindows.length === 0 ? (
+
No tmux windows found for this environment
+ ) : (
+
+ {tmuxWindows.map((window) => (
+
+
+ {window.index}
+
+ {window.name}
+
+ {window.active && (active)}
+
+
+ handleOpenTmuxWindow(window.name)}
+ className="px-2 py-1 rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 transition-colors text-xs font-medium"
+ title="Open this window in Terminal.app"
+ data-testid={`open-window-${window.name}`}
+ >
+ Open
+
+ handleKillWindow(window.name)}
+ className="px-2 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors text-xs font-medium"
+ title="Kill this window"
+ data-testid={`kill-window-${window.name}`}
+ >
+ Kill
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Tmux output drawer */}
+ {showTmuxOutput && (
+
+
+
Terminal Output:
+
+
+ Refresh
+
+ setShowTmuxOutput(false)}
+ className="text-xs px-2 py-1 rounded bg-surface-700 text-text-300 hover:bg-surface-600 transition-colors"
+ >
+
+
+
+
+
+ {tmuxOutput || 'Loading...'}
+
+
+ )}
+
+ {/* Action buttons - bottom */}
+
+ {/* Left side: Merge, Stop, Delete buttons */}
+
+ {environment.is_worktree && onMerge && (
+
+
+ Merge & Cleanup
+
+ )}
+ {environment.running && (
+
+ {isLoading ? : }
+ Stop
+
+ )}
+ {onDelete && (
+
+
+ Delete
+
+ )}
+
+
+ {/* Right side: Start button */}
+
+ {!environment.running && (
+
+ {isLoading ? : }
+ Start
+
+ )}
+
)
diff --git a/ushadow/launcher/src/components/NewEnvironmentDialog.tsx b/ushadow/launcher/src/components/NewEnvironmentDialog.tsx
index 30acbda7..d60cd98d 100644
--- a/ushadow/launcher/src/components/NewEnvironmentDialog.tsx
+++ b/ushadow/launcher/src/components/NewEnvironmentDialog.tsx
@@ -1,14 +1,12 @@
import { useState, useEffect } from 'react'
-import { X, Download, FolderOpen, GitBranch, Flame, Package } from 'lucide-react'
-
-type CreateMode = 'clone' | 'link' | 'worktree'
-type ServerMode = 'dev' | 'prod'
+import { X, GitBranch } from 'lucide-react'
+import { tauri } from '../hooks/useTauri'
+import { BranchSelector } from './BranchSelector'
interface NewEnvironmentDialogProps {
isOpen: boolean
projectRoot: string
onClose: () => void
- onClone: (name: string, serverMode: ServerMode) => void
onLink: (name: string, path: string) => void
onWorktree: (name: string, branch: string) => void
}
@@ -17,61 +15,191 @@ export function NewEnvironmentDialog({
isOpen,
projectRoot,
onClose,
- onClone,
onLink,
onWorktree,
}: NewEnvironmentDialogProps) {
const [name, setName] = useState('')
- const [mode, setMode] = useState
('clone')
- const [serverMode, setServerMode] = useState('dev')
- const [linkPath, setLinkPath] = useState('')
const [branch, setBranch] = useState('')
-
- // Set default link path when mode changes to 'link'
- useEffect(() => {
- if (mode === 'link' && !linkPath && projectRoot) {
- // Default to parent directory + /ushadow (sibling to current repo)
- const parentDir = projectRoot.split('/').slice(0, -1).join('/')
- setLinkPath(parentDir ? `${parentDir}/ushadow` : '')
- }
- }, [mode, projectRoot, linkPath])
+ const [branches, setBranches] = useState([])
+ const [isLoadingBranches, setIsLoadingBranches] = useState(false)
+ const [showConflictDialog, setShowConflictDialog] = useState(false)
+ const [existingWorktree, setExistingWorktree] = useState<{ path: string; name: string } | null>(null)
+ const [isChecking, setIsChecking] = useState(false)
+ const [manualNameEdit, setManualNameEdit] = useState(false)
// Reset form when dialog closes
useEffect(() => {
if (!isOpen) {
setName('')
- setLinkPath('')
setBranch('')
- setMode('clone')
- setServerMode('dev')
+ setShowConflictDialog(false)
+ setExistingWorktree(null)
+ setManualNameEdit(false)
}
}, [isOpen])
+ // Load branches when dialog opens
+ useEffect(() => {
+ if (isOpen && projectRoot) {
+ setIsLoadingBranches(true)
+ tauri.listGitBranches(projectRoot)
+ .then(setBranches)
+ .catch(err => {
+ console.error('Failed to load branches:', err)
+ setBranches([])
+ })
+ .finally(() => setIsLoadingBranches(false))
+ }
+ }, [isOpen, projectRoot])
+
+ /**
+ * Clean branch name for environment name
+ * Examples:
+ * "claude/github-docker-import-SlXNo" -> "github-docker-import-slxno"
+ * "feature/auth" -> "auth"
+ * "main" -> "main"
+ */
+ const cleanBranchNameForEnv = (branchName: string): string => {
+ // Remove "claude/" prefix if present
+ let cleaned = branchName.replace(/^claude\//, '')
+
+ // For other prefixes like "feature/", "fix/", etc., take the part after the last "/"
+ const parts = cleaned.split('/')
+ cleaned = parts[parts.length - 1]
+
+ // Convert to lowercase and remove the random suffix pattern (e.g., "-SlXNo")
+ cleaned = cleaned.toLowerCase().replace(/-[a-z0-9]{5}$/i, '')
+
+ return cleaned
+ }
+
+ // Auto-fill environment name from branch if not manually edited
+ const handleBranchChange = (newBranch: string) => {
+ setBranch(newBranch)
+
+ // Auto-fill name from branch if user hasn't manually edited it
+ if (!manualNameEdit && newBranch) {
+ setName(cleanBranchNameForEnv(newBranch))
+ }
+ }
+
+ // Track manual edits to the name field
+ const handleNameChange = (newName: string) => {
+ setName(newName)
+ setManualNameEdit(true)
+ }
+
if (!isOpen) return null
- const handleSubmit = () => {
+ const handleSubmit = async () => {
if (!name.trim()) return
+ if (isChecking) return
+
+ const envName = name.trim()
+ const branchName = branch.trim() || envName
+
+ // Check if worktree exists for this branch
+ setIsChecking(true)
+ try {
+ const existing = await tauri.checkWorktreeExists(projectRoot, branchName)
- switch (mode) {
- case 'clone':
- onClone(name.trim(), serverMode)
- break
- case 'link':
- onLink(name.trim(), linkPath.trim())
- break
- case 'worktree':
- onWorktree(name.trim(), branch.trim() || name.trim())
- break
+ if (existing) {
+ // Worktree exists - show conflict dialog
+ setExistingWorktree({ path: existing.path, name: existing.name })
+ setShowConflictDialog(true)
+ } else {
+ // No conflict - create new worktree
+ onWorktree(envName, branchName)
+ // Reset form
+ setName('')
+ setBranch('')
+ }
+ } catch (error) {
+ console.error('Error checking worktree:', error)
+ // If check fails, proceed with creation anyway
+ onWorktree(envName, branchName)
+ setName('')
+ setBranch('')
+ } finally {
+ setIsChecking(false)
}
+ }
- // Reset form
+ const handleLinkToExisting = () => {
+ if (!existingWorktree) return
+ onLink(name.trim(), existingWorktree.path)
+ setShowConflictDialog(false)
+ setExistingWorktree(null)
setName('')
- setLinkPath('')
setBranch('')
- setServerMode('dev')
}
- const isValid = name.trim() && (mode !== 'link' || linkPath.trim())
+ const handleRemakeWorktree = () => {
+ onWorktree(name.trim(), branch.trim() || name.trim())
+ setShowConflictDialog(false)
+ setExistingWorktree(null)
+ setName('')
+ setBranch('')
+ }
+
+ const isValid = name.trim()
+
+ // Show conflict dialog if there's an existing worktree
+ if (showConflictDialog && existingWorktree) {
+ return (
+
+
+
+
Worktree Already Exists
+ setShowConflictDialog(false)}
+ className="p-1 rounded hover:bg-surface-700 transition-colors"
+ >
+
+
+
+
+
+ A worktree for branch {branch.trim() || name.trim()} already exists at:
+
+
+
+ {existingWorktree.path}
+
+
+
+ Would you like to link to the existing worktree or remake it?
+
+
+
+ setShowConflictDialog(false)}
+ className="flex-1 py-2 rounded-lg bg-surface-700 hover:bg-surface-600 transition-colors"
+ >
+ Cancel
+
+
+ Link Existing
+
+
+ Remake
+
+
+
+
+ )
+ }
return (
{/* Header */}
-
New Environment
+
+
+ New Environment
+
Repository: {projectRoot || 'Not set'}
- {/* Environment Name */}
+ {/* Branch Name - Now first to enable auto-naming */}
+
+
+ Select a Claude-created branch or any existing branch, or type a new name. If it doesn't exist, it will be created from main.
+
+
+
+ {/* Environment Name - Auto-filled from branch */}
+
+
setName(e.target.value)}
+ onChange={(e) => handleNameChange(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && isValid && !isChecking && handleSubmit()}
className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50"
placeholder="e.g., dev, staging, feature-x"
- autoFocus
data-testid="env-name-input"
/>
+
+ Auto-filled from branch name. Edit if you prefer a different name.
+
- {/* Mode Selection */}
-
-
-
- setMode('clone')}
- />
- setMode('link')}
- />
- setMode('worktree')}
- />
-
-
-
- {/* Server Mode - only for clone */}
- {mode === 'clone' && (
-
-
-
-
setServerMode('dev')}
- className={`flex items-center gap-2 p-3 rounded-lg transition-all ${
- serverMode === 'dev'
- ? 'bg-primary-500/20 border-2 border-primary-500'
- : 'bg-surface-700 border-2 border-transparent hover:bg-surface-600'
- }`}
- data-testid="server-mode-dev"
- >
-
-
-
Hot Reload
-
Development server
-
-
-
setServerMode('prod')}
- className={`flex items-center gap-2 p-3 rounded-lg transition-all ${
- serverMode === 'prod'
- ? 'bg-primary-500/20 border-2 border-primary-500'
- : 'bg-surface-700 border-2 border-transparent hover:bg-surface-600'
- }`}
- data-testid="server-mode-prod"
- >
-
-
-
Production
-
Nginx build
-
-
-
-
- )}
-
- {/* Mode-specific inputs */}
- {mode === 'link' && (
-
-
- setLinkPath(e.target.value)}
- className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50"
- placeholder="/path/to/existing/ushadow"
- data-testid="link-path-input"
- />
-
- )}
-
- {mode === 'worktree' && (
-
-
- setBranch(e.target.value)}
- className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50"
- placeholder={name || 'feature/my-branch'}
- data-testid="branch-input"
- />
-
- )}
-
{/* Helper text */}
- {mode === 'clone' && 'Creates a fresh clone of the repository'}
- {mode === 'link' && 'Links to an existing Ushadow folder'}
- {mode === 'worktree' && 'Creates a git worktree for parallel development'}
+ Creates a git worktree for parallel development. If a worktree already exists for this branch, you'll be asked to link or remake it.
{/* Actions */}
@@ -230,40 +278,14 @@ export function NewEnvironmentDialog({
- Create
+ {isChecking ? 'Checking...' : 'Create'}
)
}
-
-function ModeButton({
- icon: Icon,
- label,
- active,
- onClick,
-}: {
- icon: typeof Download
- label: string
- active: boolean
- onClick: () => void
-}) {
- return (
-
-
- {label}
-
- )
-}
diff --git a/ushadow/launcher/src/components/PrerequisitesPanel.tsx b/ushadow/launcher/src/components/PrerequisitesPanel.tsx
index 423716dd..6d0a0947 100644
--- a/ushadow/launcher/src/components/PrerequisitesPanel.tsx
+++ b/ushadow/launcher/src/components/PrerequisitesPanel.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { CheckCircle, XCircle, AlertCircle, Loader2, ChevronDown, ChevronRight, Download } from 'lucide-react'
+import { CheckCircle, XCircle, AlertCircle, Loader2, ChevronDown, ChevronRight, ChevronUp, Download } from 'lucide-react'
import type { Prerequisites } from '../hooks/useTauri'
interface PrerequisitesPanelProps {
@@ -9,6 +9,8 @@ interface PrerequisitesPanelProps {
installingItem: string | null
onInstall: (item: 'git' | 'docker' | 'tailscale' | 'homebrew' | 'python') => void
onStartDocker: () => void
+ showDevTools: boolean
+ onToggleDevTools: () => void
}
export function PrerequisitesPanel({
@@ -18,6 +20,8 @@ export function PrerequisitesPanel({
installingItem,
onInstall,
onStartDocker,
+ showDevTools,
+ onToggleDevTools,
}: PrerequisitesPanelProps) {
const getOverallStatus = () => {
if (!prerequisites) return 'checking'
@@ -33,17 +37,17 @@ export function PrerequisitesPanel({
return (
{/* Header */}
-
setExpanded(!expanded)}
- className="w-full flex items-center justify-between p-4"
- data-testid="prerequisites-toggle"
- >
-
+
+ setExpanded(!expanded)}
+ className="flex items-center gap-2 flex-1"
+ data-testid="prerequisites-toggle"
+ >
Prerequisites
{expanded ? : }
-
+
-
+
{/* Content */}
{expanded && (
@@ -109,6 +113,18 @@ export function PrerequisitesPanel({
/>
)}
+
+ {/* Drawer toggle - centered at bottom */}
+
+
+ {showDevTools ? : }
+
+
)
}
diff --git a/ushadow/launcher/src/components/SettingsDialog.tsx b/ushadow/launcher/src/components/SettingsDialog.tsx
new file mode 100644
index 00000000..b9b40cbc
--- /dev/null
+++ b/ushadow/launcher/src/components/SettingsDialog.tsx
@@ -0,0 +1,194 @@
+import { useState, useEffect } from 'react'
+import { X, Settings, Eye, EyeOff, Save } from 'lucide-react'
+import { tauri, type LauncherSettings } from '../hooks/useTauri'
+
+interface SettingsDialogProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
+ const [settings, setSettings] = useState