+
handleReprocessTranscript(conversation)}
disabled={!conversation.conversation_id || reprocessingTranscript.has(conversation.conversation_id)}
diff --git a/ushadow/frontend/src/components/layout/Layout.tsx b/ushadow/frontend/src/components/layout/Layout.tsx
index 0cf9501c..2a33dae0 100644
--- a/ushadow/frontend/src/components/layout/Layout.tsx
+++ b/ushadow/frontend/src/components/layout/Layout.tsx
@@ -11,6 +11,7 @@ import { useMobileQrCode } from '../../hooks/useQrCode'
import FeatureFlagsDrawer from './FeatureFlagsDrawer'
import { StatusBadge, type BadgeVariant } from '../StatusBadge'
import Modal from '../Modal'
+import TailscaleOriginBanner from '../TailscaleOriginBanner'
import type { LucideIcon } from 'lucide-react'
interface NavigationItem {
@@ -65,7 +66,7 @@ export default function Layout() {
{ path: '/agent-zero', label: 'Agent Zero', icon: Bot, featureFlag: 'agent_zero' },
{ path: '/n8n', label: 'n8n Workflows', icon: Workflow, featureFlag: 'n8n_workflows' },
{ path: '/services', label: 'Services', icon: Server },
- { path: '/instances', label: 'Instances', icon: Layers, featureFlag: 'instances_management' },
+ { path: '/instances', label: 'ServiceConfigs', icon: Layers, featureFlag: 'instances_management' },
...(isEnabled('memories_page') ? [
{ path: '/memories', label: 'Memories', icon: Brain },
] : []),
@@ -427,6 +428,9 @@ export default function Layout() {
+ {/* Tailscale Origin Banner */}
+
+
{/* Main Container */}
diff --git a/ushadow/frontend/src/components/memories/MemoryTable.tsx b/ushadow/frontend/src/components/memories/MemoryTable.tsx
index 12961785..75f6be35 100644
--- a/ushadow/frontend/src/components/memories/MemoryTable.tsx
+++ b/ushadow/frontend/src/components/memories/MemoryTable.tsx
@@ -212,10 +212,10 @@ export function MemoryTable({ memories, isLoading }: MemoryTableProps) {
{actionMenuId === memory.id && (
<>
setActionMenuId(null)}
/>
-
+
diff --git a/ushadow/frontend/src/components/services/ServiceCard.tsx b/ushadow/frontend/src/components/services/ServiceCard.tsx
index a40edc09..71a37b0b 100644
--- a/ushadow/frontend/src/components/services/ServiceCard.tsx
+++ b/ushadow/frontend/src/components/services/ServiceCard.tsx
@@ -9,7 +9,7 @@ import {
Loader2,
} from 'lucide-react'
import { useServiceStatus } from '../../hooks/useServiceStatus'
-import type { ServiceInstance, ContainerStatus } from '../../contexts/ServicesContext'
+import type { ServiceServiceConfig, ContainerStatus } from '../../contexts/ServicesContext'
import { ServiceStatusBadge } from './ServiceStatusBadge'
import { ServiceConfigForm } from './ServiceConfigForm'
@@ -19,7 +19,7 @@ import { ServiceConfigForm } from './ServiceConfigForm'
interface ServiceCardProps {
/** The service instance */
- service: ServiceInstance
+ service: ServiceServiceConfig
/** Current saved config for this service */
config: Record
/** Container status for this service (local services only) */
@@ -62,7 +62,7 @@ interface ServiceCardProps {
// Helper Functions
// ============================================================================
-function getBorderClasses(service: ServiceInstance, state: string): string {
+function getBorderClasses(service: ServiceServiceConfig, state: string): string {
// Disabled services get grayed out appearance
if (!service.enabled) {
return 'border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800/50 shadow-sm opacity-60'
diff --git a/ushadow/frontend/src/components/services/ServiceCategoryList.tsx b/ushadow/frontend/src/components/services/ServiceCategoryList.tsx
index 877800e9..0eed276b 100644
--- a/ushadow/frontend/src/components/services/ServiceCategoryList.tsx
+++ b/ushadow/frontend/src/components/services/ServiceCategoryList.tsx
@@ -1,6 +1,6 @@
import { ReactNode } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
-import type { ServiceInstance } from '../../contexts/ServicesContext'
+import type { ServiceServiceConfig } from '../../contexts/ServicesContext'
// ============================================================================
// Types
@@ -16,13 +16,13 @@ interface ServiceCategoryListProps {
/** Service categories to display */
categories: ServiceCategory[]
/** Services grouped by category ID */
- servicesByCategory: Record
+ servicesByCategory: Record
/** Set of expanded category IDs */
expandedCategories: Set
/** Callback when category is toggled */
onToggleCategory: (categoryId: string) => void
/** Render function for each service card */
- renderServiceCard: (service: ServiceInstance) => ReactNode
+ renderServiceCard: (service: ServiceServiceConfig) => ReactNode
}
// ============================================================================
diff --git a/ushadow/frontend/src/components/services/ServiceConfigForm.tsx b/ushadow/frontend/src/components/services/ServiceConfigForm.tsx
index 4b090663..b69aa4a0 100644
--- a/ushadow/frontend/src/components/services/ServiceConfigForm.tsx
+++ b/ushadow/frontend/src/components/services/ServiceConfigForm.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Edit2, Save, X, Loader2, Plus, Trash2, Key, FileText } from 'lucide-react'
-import type { ConfigField, ServiceInstance } from '../../contexts/ServicesContext'
+import type { ConfigField, ServiceServiceConfig } from '../../contexts/ServicesContext'
import { shouldShowField, maskValue } from '../../hooks/useServiceStatus'
import { SecretInput, SettingField } from '../settings'
@@ -10,7 +10,7 @@ import { SecretInput, SettingField } from '../settings'
interface ServiceConfigFormProps {
/** The service being configured */
- service: ServiceInstance
+ service: ServiceServiceConfig
/** Current saved config values */
config: Record
/** Whether we're in edit mode */
diff --git a/ushadow/frontend/src/components/services/ServiceStatsCards.tsx b/ushadow/frontend/src/components/services/ServiceStatsCards.tsx
index ba36cdd1..37f321b2 100644
--- a/ushadow/frontend/src/components/services/ServiceStatsCards.tsx
+++ b/ushadow/frontend/src/components/services/ServiceStatsCards.tsx
@@ -22,7 +22,7 @@ interface ServiceStatsCardsProps {
*
* @example
*
diff --git a/ushadow/frontend/src/components/services/ServiceStatusBadge.tsx b/ushadow/frontend/src/components/services/ServiceStatusBadge.tsx
index 3a88fce9..5b9f901d 100644
--- a/ushadow/frontend/src/components/services/ServiceStatusBadge.tsx
+++ b/ushadow/frontend/src/components/services/ServiceStatusBadge.tsx
@@ -1,6 +1,6 @@
import { Loader2, PlayCircle, StopCircle, LucideIcon } from 'lucide-react'
import type { ServiceStatusResult } from '../../hooks/useServiceStatus'
-import type { ServiceInstance } from '../../contexts/ServicesContext'
+import type { ServiceServiceConfig } from '../../contexts/ServicesContext'
// ============================================================================
// Types
@@ -8,7 +8,7 @@ import type { ServiceInstance } from '../../contexts/ServicesContext'
interface ServiceStatusBadgeProps {
/** The service instance */
- service: ServiceInstance
+ service: ServiceServiceConfig
/** Computed status from useServiceStatus hook */
status: ServiceStatusResult
/** Whether service is currently starting */
diff --git a/ushadow/frontend/src/components/wiring/CapabilitySlot.tsx b/ushadow/frontend/src/components/wiring/CapabilitySlot.tsx
new file mode 100644
index 00000000..d65f03db
--- /dev/null
+++ b/ushadow/frontend/src/components/wiring/CapabilitySlot.tsx
@@ -0,0 +1,92 @@
+import { useDroppable } from '@dnd-kit/core'
+import { Cloud, AlertCircle, X, Plug, ChevronDown } from 'lucide-react'
+
+interface ProviderInfo {
+ id: string
+ name: string
+ capability: string
+}
+
+interface CapabilitySlotProps {
+ consumerId: string
+ capability: string
+ connection: { provider?: ProviderInfo; capability: string } | null
+ isDropTarget: boolean
+ onClear: () => void
+ onSelectProvider?: () => void // Click-to-select callback
+}
+
+export function CapabilitySlot({ consumerId, capability, connection, isDropTarget, onClear, onSelectProvider }: CapabilitySlotProps) {
+ const dropId = `slot::${consumerId}::${capability}`
+ const { isOver, setNodeRef } = useDroppable({ id: dropId })
+
+ const hasProvider = connection?.provider
+ const isOrphaned = connection && !connection.provider
+
+ const handleEmptySlotClick = () => {
+ if (onSelectProvider && !hasProvider && !isOrphaned) {
+ onSelectProvider()
+ }
+ }
+
+ return (
+
+ {/* Capability label */}
+
{capability}
+ {/* Drop zone */}
+
+ {hasProvider ? (
+
+
+
+ {connection.provider!.name}
+
+
+
+
+
+ ) : isOrphaned ? (
+
+ ) : (
+
+
+
+
{isDropTarget ? 'Drop provider here' : 'Click to select or drag provider'}
+
+ {onSelectProvider && !isDropTarget && (
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/ushadow/frontend/src/components/wiring/ServiceInstanceCard.tsx b/ushadow/frontend/src/components/wiring/ServiceInstanceCard.tsx
new file mode 100644
index 00000000..dcfc5c87
--- /dev/null
+++ b/ushadow/frontend/src/components/wiring/ServiceInstanceCard.tsx
@@ -0,0 +1,347 @@
+import { useState, useEffect } from 'react'
+import { createPortal } from 'react-dom'
+import {
+ Cloud,
+ HardDrive,
+ Loader2,
+ Settings,
+ PlayCircle,
+ StopCircle,
+ Plus,
+ Pencil,
+ Package,
+ Trash2,
+} from 'lucide-react'
+import { CapabilitySlot } from './CapabilitySlot'
+import { StatusIndicator } from './StatusIndicator'
+
+interface ConfigVar {
+ key: string
+ label: string
+ value: string
+ isSecret: boolean
+ required?: boolean
+}
+
+interface ProviderInfo {
+ id: string
+ name: string
+ capability: string
+}
+
+interface ServiceInstanceCardProps {
+ instance: {
+ id: string
+ name: string
+ requires: string[]
+ status: string
+ mode?: string
+ description?: string
+ }
+ configVars?: ConfigVar[]
+ activeProvider: { id: string; name: string; capability: string } | null
+ getProviderForSlot: (instanceId: string, capability: string) => { provider?: ProviderInfo; capability: string } | null
+ onDeleteWiring: (instanceId: string, capability: string) => Promise
+ onSelectProvider?: (instanceId: string, capability: string) => void // Click-to-select
+ onEdit?: (instanceId: string) => void
+ onStart?: (instanceId: string) => Promise
+ onStop?: (instanceId: string) => Promise
+ onDeploy?: (instanceId: string, target: { type: 'local' | 'remote' | 'kubernetes'; id?: string }) => void
+ onDelete?: (instanceId: string) => void
+}
+
+/**
+ * Service instance card - has capability slots for wiring providers
+ * Instances are the deployed configs, templates are just metadata
+ */
+export function ServiceInstanceCard({
+ instance,
+ configVars = [],
+ activeProvider,
+ getProviderForSlot,
+ onDeleteWiring,
+ onSelectProvider,
+ onEdit,
+ onStart,
+ onStop,
+ onDeploy,
+ onDelete,
+}: ServiceInstanceCardProps) {
+ const [isStarting, setIsStarting] = useState(false)
+ const [showDeployMenu, setShowDeployMenu] = useState(false)
+ const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null)
+
+ const missingRequiredVars = configVars.filter((v) => v.required && !v.value)
+ const configuredVars = configVars.filter((v) => v.value)
+ const needsSetup = missingRequiredVars.length > 0
+
+ const isCloud = instance.mode === 'cloud'
+ const canStart = !isCloud && (instance.status === 'stopped' || instance.status === 'pending' || instance.status === 'exited' || instance.status === 'not_running' || instance.status === 'not_found')
+ const canStop = !isCloud && (instance.status === 'running' || instance.status === 'starting')
+
+ // Close deploy menu when clicking outside
+ useEffect(() => {
+ if (!showDeployMenu) return
+
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as HTMLElement
+ if (!target.closest('[data-deploy-menu]')) {
+ setShowDeployMenu(false)
+ }
+ }
+
+ document.addEventListener('click', handleClickOutside)
+ return () => document.removeEventListener('click', handleClickOutside)
+ }, [showDeployMenu])
+
+ const handleDeployClick = (e: React.MouseEvent) => {
+ const button = e.currentTarget
+ const rect = button.getBoundingClientRect()
+ setMenuPosition({
+ top: rect.bottom + 4,
+ left: rect.right - 192, // 192px = w-48
+ })
+ setShowDeployMenu(!showDeployMenu)
+ }
+
+ const handleStartClick = async () => {
+ if (!onStart) return
+ setIsStarting(true)
+ try {
+ await onStart(instance.id)
+ } finally {
+ setIsStarting(false)
+ }
+ }
+
+ const handleStopClick = async () => {
+ if (!onStop) return
+ setIsStarting(true)
+ try {
+ await onStop(instance.id)
+ } finally {
+ setIsStarting(false)
+ }
+ }
+
+ const getCardClasses = () => {
+ if (instance.status === 'running') {
+ return 'border-success-400 dark:border-success-600'
+ }
+ return 'border-neutral-200 dark:border-neutral-700'
+ }
+
+ // Parse instance name to extract node/service info
+ // Format: "service-name" or "node/service-name"
+ const parseInstanceName = () => {
+ const nameParts = instance.id.split('/')
+ if (nameParts.length === 2) {
+ return { node: nameParts[0], service: nameParts[1] }
+ }
+ return { node: null, service: instance.id }
+ }
+
+ const { node, service } = parseInstanceName()
+
+ return (
+
+ {/* Header */}
+
0 ? 'border-b border-neutral-200 dark:border-neutral-700' : ''}`}>
+
+
+ {/* Mode icon */}
+ {isCloud ? (
+
+ ) : (
+
+ )}
+
+ {/* Service name and node */}
+
+ {node && (
+
+ {node}/
+
+ )}
+
+ {instance.name}
+
+
+
+
+
+
+ {/* Actions */}
+
+ {/* Start/Stop */}
+ {!isCloud && onStart && onStop && (
+ <>
+ {isStarting ? (
+
+
+
+ ) : needsSetup && canStart ? (
+
onEdit?.(instance.id)}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-300 hover:bg-warning-200"
+ data-testid={`instance-setup-${instance.id}`}
+ >
+
+ Setup
+
+ ) : canStart ? (
+
+
+ Start
+
+ ) : canStop ? (
+
+
+ Stop
+
+ ) : null}
+ >
+ )}
+
+ {/* Deploy menu */}
+ {onDeploy && (
+ <>
+
+
+ Deploy
+
+ {showDeployMenu &&
+ menuPosition &&
+ createPortal(
+
+
{
+ onDeploy(instance.id, { type: 'local' })
+ setShowDeployMenu(false)
+ }}
+ className="w-full px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-t-lg flex items-center gap-2"
+ >
+
+ Local (Leader uNode)
+
+
{
+ onDeploy(instance.id, { type: 'remote' })
+ setShowDeployMenu(false)
+ }}
+ className="w-full px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
+ >
+
+ Remote uNode
+
+
{
+ onDeploy(instance.id, { type: 'kubernetes' })
+ setShowDeployMenu(false)
+ }}
+ className="w-full px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-b-lg flex items-center gap-2"
+ >
+
+ Kubernetes
+
+
,
+ document.body
+ )}
+ >
+ )}
+
+ {/* Edit */}
+ {onEdit && (
+
onEdit(instance.id)}
+ className="p-1.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
+ title="Edit settings"
+ data-testid={`instance-edit-${instance.id}`}
+ >
+
+
+ )}
+
+ {/* Delete */}
+ {onDelete && (
+
onDelete(instance.id)}
+ className="p-1.5 text-neutral-400 hover:text-error-600 dark:hover:text-error-400 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
+ title="Delete instance"
+ data-testid={`instance-delete-${instance.id}`}
+ >
+
+
+ )}
+
+
+
+
+ {/* Capability Slots - only show if there are slots */}
+ {instance.requires.length > 0 && (
+
+ {instance.requires.map((capability) => {
+ const connection = getProviderForSlot(instance.id, capability)
+ const isDropTarget = activeProvider?.capability === capability
+
+ return (
+ onDeleteWiring(instance.id, capability)}
+ onSelectProvider={onSelectProvider ? () => onSelectProvider(instance.id, capability) : undefined}
+ />
+ )
+ })}
+
+ )}
+
+ {/* Config Vars */}
+ {(missingRequiredVars.length > 0 || configuredVars.length > 0) && (
+
0 ? 'border-t border-neutral-200 dark:border-neutral-700' : ''}`}>
+
+ {/* Missing required fields */}
+ {missingRequiredVars.map((v) => (
+
+ *
+ {v.label}: Not set
+
+ ))}
+ {/* Configured fields */}
+ {configuredVars.slice(0, 4).map((v) => (
+
+ {v.required && * }
+ {v.label}: {v.value}
+
+ ))}
+ {configuredVars.length > 4 &&
+{configuredVars.length - 4} more
}
+
+
+ )}
+
+ )
+}
diff --git a/ushadow/frontend/src/components/wiring/ServiceTemplateCard.tsx b/ushadow/frontend/src/components/wiring/ServiceTemplateCard.tsx
new file mode 100644
index 00000000..dd575f94
--- /dev/null
+++ b/ushadow/frontend/src/components/wiring/ServiceTemplateCard.tsx
@@ -0,0 +1,301 @@
+import { useState, useEffect } from 'react'
+import { Plus, Package, Edit2, Save, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
+import { servicesApi } from '../../services/api'
+
+interface ConfigVar {
+ key: string
+ label: string
+ value: string
+ isSecret: boolean
+ required?: boolean
+}
+
+interface EnvVarInfo {
+ name: string
+ is_required: boolean
+ resolved_value?: string
+ default_value?: string
+ value?: string
+ setting_path?: string
+ source?: string
+}
+
+interface ServiceTemplateCardProps {
+ template: {
+ id: string
+ name: string
+ description?: string
+ requires?: string[]
+ }
+ configVars?: ConfigVar[]
+ onCreateInstance: () => void
+ onUpdateConfigVars?: (vars: ConfigVar[]) => void
+ alwaysShowConfig?: boolean // Force show expand button even if configVars is empty
+}
+
+/**
+ * Service template card - shows service info with + button to create instances
+ * Templates don't have slots - instances do
+ */
+export function ServiceTemplateCard({
+ template,
+ configVars = [],
+ onCreateInstance,
+ onUpdateConfigVars,
+ alwaysShowConfig = false
+}: ServiceTemplateCardProps) {
+ const [isExpanded, setIsExpanded] = useState(false)
+ const [isEditing, setIsEditing] = useState(false)
+ const [editForm, setEditForm] = useState>({})
+ const [isLoadingEnv, setIsLoadingEnv] = useState(false)
+ const [loadedEnvVars, setLoadedEnvVars] = useState([])
+
+ const missingRequiredVars = configVars.filter((v) => v.required && !v.value)
+ const configuredVars = configVars.filter((v) => v.value)
+
+ // Load env config when expanded (for compose services)
+ useEffect(() => {
+ if (isExpanded && !isLoadingEnv && loadedEnvVars.length === 0) {
+ loadEnvConfig()
+ }
+ }, [isExpanded])
+
+ const loadEnvConfig = async () => {
+ setIsLoadingEnv(true)
+ try {
+ const response = await servicesApi.getEnvConfig(template.id)
+ const allVars = [...response.data.required_env_vars, ...response.data.optional_env_vars]
+ setLoadedEnvVars(allVars)
+ } catch (error) {
+ console.error('Failed to load env config:', error)
+ // If API fails, fall back to configVars
+ } finally {
+ setIsLoadingEnv(false)
+ }
+ }
+
+ const handleEdit = () => {
+ // Initialize form with current values from loaded env vars
+ const formData: Record = {}
+ if (loadedEnvVars.length > 0) {
+ loadedEnvVars.forEach((v) => {
+ formData[v.name] = v.value || v.resolved_value || ''
+ })
+ } else {
+ configVars.forEach((v) => {
+ formData[v.key] = v.value || ''
+ })
+ }
+ setEditForm(formData)
+ setIsEditing(true)
+ }
+
+ const handleSave = async () => {
+ if (onUpdateConfigVars) {
+ // Convert loaded env vars to ConfigVar format for saving
+ const varsToSave = loadedEnvVars.length > 0
+ ? loadedEnvVars.map((v) => ({
+ key: v.name,
+ label: v.name,
+ value: editForm[v.name] || '',
+ isSecret: v.name.includes('KEY') || v.name.includes('SECRET') || v.name.includes('PASSWORD'),
+ required: v.is_required
+ }))
+ : configVars.map((v) => ({
+ ...v,
+ value: editForm[v.key] || ''
+ }))
+
+ await onUpdateConfigVars(varsToSave)
+ // Reload env config to get updated values
+ await loadEnvConfig()
+ }
+ setIsEditing(false)
+ setEditForm({})
+ }
+
+ const handleCancel = () => {
+ setIsEditing(false)
+ setEditForm({})
+ }
+
+ const toggleExpanded = () => {
+ if (!isEditing) {
+ setIsExpanded(!isExpanded)
+ }
+ }
+
+ // Use loaded env vars if available, otherwise fall back to configVars
+ const displayVars = loadedEnvVars.length > 0
+ ? loadedEnvVars.map((v) => ({
+ key: v.name,
+ label: v.name,
+ value: v.resolved_value || v.value || '',
+ isSecret: v.name.includes('KEY') || v.name.includes('SECRET') || v.name.includes('PASSWORD'),
+ required: v.is_required
+ }))
+ : configVars
+
+ return (
+
+
+
+ {/* Expand/Collapse Arrow - Left side */}
+ {(displayVars.length > 0 || alwaysShowConfig) && (
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Service Info - clickable to expand */}
+
+
+
+
+ {template.name}
+
+
+ {template.description && (
+
+ {template.description}
+
+ )}
+ {template.requires && template.requires.length > 0 && (
+
+ {template.requires.map((cap) => (
+
+ {cap}
+
+ ))}
+
+ )}
+
+
+ {/* Deploy Button */}
+
{
+ e.stopPropagation()
+ onCreateInstance()
+ }}
+ className="flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg bg-primary-600 hover:bg-primary-700 text-white transition-colors flex-shrink-0"
+ title="Create service instance"
+ data-testid={`deploy-${template.id}`}
+ >
+
+ Deploy
+
+
+
+
+ {/* Expanded Configuration Section */}
+ {isExpanded && (displayVars.length > 0 || alwaysShowConfig || isLoadingEnv) && (
+
+
+ {/* Loading State */}
+ {isLoadingEnv && (
+
+
+ Loading configuration...
+
+ )}
+
+ {/* View Mode */}
+ {!isEditing && !isLoadingEnv && (
+ <>
+ {displayVars.length > 0 ? (
+ displayVars.map((v) => (
+
+
+ {v.required && * }
+ {v.label}:
+
+
+ {v.isSecret && v.value ? '••••••' : v.value || (
+ Not set
+ )}
+
+
+ ))
+ ) : (
+
+ No environment variables configured.
+
+ )}
+ {displayVars.length > 0 && (
+
+
+
+ Edit Configuration
+
+
+ )}
+ >
+ )}
+
+ {/* Edit Mode */}
+ {isEditing && !isLoadingEnv && (
+ <>
+ {displayVars.map((v) => (
+
+
+ {v.required && * }
+ {v.label}
+
+ setEditForm({ ...editForm, [v.key]: e.target.value })}
+ placeholder={v.isSecret ? '••••••••' : `Enter ${v.label.toLowerCase()}`}
+ className="input flex-1 text-sm"
+ data-testid={`input-${template.id}-${v.key}`}
+ />
+
+ ))}
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+ >
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/ushadow/frontend/src/components/wiring/StatusIndicator.tsx b/ushadow/frontend/src/components/wiring/StatusIndicator.tsx
new file mode 100644
index 00000000..3253b42a
--- /dev/null
+++ b/ushadow/frontend/src/components/wiring/StatusIndicator.tsx
@@ -0,0 +1,47 @@
+import { AlertCircle, Settings } from 'lucide-react'
+
+export function StatusIndicator({ status }: { status: string }) {
+ switch (status) {
+ case 'running':
+ return (
+
+
+ Running
+
+ )
+ case 'configured':
+ return (
+
+
+ Ready
+
+ )
+ case 'needs_setup':
+ return (
+
+
+ Setup
+
+ )
+ case 'stopped':
+ case 'not_running':
+ return (
+
+ Stopped
+
+ )
+ case 'error':
+ return (
+
+
+ Error
+
+ )
+ default:
+ return (
+
+ {status}
+
+ )
+ }
+}
diff --git a/ushadow/frontend/src/components/wiring/WiringBoard.tsx b/ushadow/frontend/src/components/wiring/WiringBoard.tsx
index 83b7b185..03ea423b 100644
--- a/ushadow/frontend/src/components/wiring/WiringBoard.tsx
+++ b/ushadow/frontend/src/components/wiring/WiringBoard.tsx
@@ -26,7 +26,6 @@ import {
Cloud,
HardDrive,
AlertCircle,
- Plug,
X,
GripVertical,
Pencil,
@@ -36,7 +35,14 @@ import {
StopCircle,
Settings,
Loader2,
+ ChevronDown,
+ ChevronUp,
+ Plug,
+ Package,
} from 'lucide-react'
+import { ServiceTemplateCard } from './ServiceTemplateCard'
+import { ServiceInstanceCard } from './ServiceInstanceCard'
+import { StatusIndicator } from './StatusIndicator'
// Per-service wiring model - each consumer has its own connections
interface ConfigVar {
@@ -68,13 +74,15 @@ interface ConsumerInfo {
configVars?: ConfigVar[]
configured?: boolean
description?: string // Service description
+ isTemplate?: boolean // True for templates, false for instances
+ templateId?: string // For templates: own ID; for instances: parent template ID
}
interface WiringInfo {
id: string
- source_instance_id: string
+ source_config_id: string
source_capability: string
- target_instance_id: string
+ target_config_id: string
target_capability: string
}
@@ -91,14 +99,16 @@ interface WiringBoardProps {
onProviderDrop: (dropInfo: DropInfo) => void
onDeleteWiring: (consumerId: string, capability: string) => Promise
onEditProvider: (providerId: string, isTemplate: boolean) => void
- onCreateInstance: (templateId: string) => void
- onDeleteInstance: (instanceId: string) => void
+ onCreateServiceConfig: (templateId: string) => void
+ onUpdateTemplateConfigVars?: (templateId: string, configVars: ConfigVar[]) => Promise
+ onDeleteServiceConfig: (instanceId: string) => void
onStartProvider?: (providerId: string, isTemplate: boolean) => Promise
onStopProvider?: (providerId: string, isTemplate: boolean) => Promise
// Consumer/Service callbacks
onEditConsumer?: (consumerId: string) => void
onStartConsumer?: (consumerId: string) => Promise
onStopConsumer?: (consumerId: string) => Promise
+ onDeployConsumer?: (consumerId: string, target: { type: 'local' | 'remote' | 'kubernetes'; id?: string }) => void
}
export default function WiringBoard({
@@ -108,16 +118,54 @@ export default function WiringBoard({
onProviderDrop,
onDeleteWiring,
onEditProvider,
- onCreateInstance,
- onDeleteInstance,
+ onCreateServiceConfig,
+ onUpdateTemplateConfigVars,
+ onDeleteServiceConfig,
onStartProvider,
onStopProvider,
onEditConsumer,
onStartConsumer,
+ onDeployConsumer,
onStopConsumer,
}: WiringBoardProps) {
const [activeProvider, setActiveProvider] = useState(null)
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
+ const [collapsedTemplates, setCollapsedTemplates] = useState>(new Set())
+ // Provider selection modal state (for click-to-select alternative to drag-drop)
+ const [selectingSlot, setSelectingSlot] = useState<{ consumerId: string; capability: string } | null>(null)
+
+ const toggleTemplateCollapse = (templateId: string) => {
+ setCollapsedTemplates(prev => {
+ const next = new Set(prev)
+ if (next.has(templateId)) {
+ next.delete(templateId)
+ } else {
+ next.add(templateId)
+ }
+ return next
+ })
+ }
+
+ // Open provider selection modal for a slot
+ const handleSelectProviderClick = (consumerId: string, capability: string) => {
+ setSelectingSlot({ consumerId, capability })
+ }
+
+ // Get available providers for a specific capability
+ const getProvidersForCapability = (capability: string) => {
+ return providers.filter(p => p.capability === capability)
+ }
+
+ // Handle selecting a provider from the modal
+ const handleProviderSelected = (provider: ProviderInfo) => {
+ if (!selectingSlot) return
+ onProviderDrop({
+ provider,
+ consumerId: selectingSlot.consumerId,
+ capability: selectingSlot.capability,
+ })
+ setSelectingSlot(null)
+ }
// Configure sensors for proper drag handling
const mouseSensor = useSensor(MouseSensor, {
@@ -211,11 +259,11 @@ export default function WiringBoard({
// Get provider for a specific consumer's capability slot
const getProviderForSlot = (consumerId: string, capability: string) => {
const wire = wiring.find(
- (w) => w.target_instance_id === consumerId && w.target_capability === capability
+ (w) => w.target_config_id === consumerId && w.target_capability === capability
)
if (wire) {
return {
- provider: providers.find((p) => p.id === wire.source_instance_id),
+ provider: providers.find((p) => p.id === wire.source_config_id),
capability,
}
}
@@ -259,25 +307,45 @@ export default function WiringBoard({
{Object.values(templates).map(({ template, instances }) => {
if (!template) return null // Skip if template not loaded
const templateConnectionCount = wiring.filter(
- (w) => w.source_instance_id === template.id
+ (w) => w.source_config_id === template.id
).length
+ const isCollapsed = collapsedTemplates.has(template.id)
+ const hasInstances = instances.length > 0
return (
- {/* Template (default) */}
-
onEditProvider(template.id, true)}
- onCreateInstance={() => onCreateInstance(template.id)}
- onStart={onStartProvider ? () => onStartProvider(template.id, true) : undefined}
- onStop={onStopProvider ? () => onStopProvider(template.id, true) : undefined}
- />
- {/* Nested instances */}
- {instances.length > 0 && (
+ {/* Template header with collapse toggle */}
+
+ {hasInstances && (
+
toggleTemplateCollapse(template.id)}
+ className="p-0.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
+ title={isCollapsed ? `Show ${instances.length} instance(s)` : 'Collapse instances'}
+ data-testid={`collapse-provider-${template.id}`}
+ >
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ onEditProvider(template.id, true)}
+ onCreateServiceConfig={() => onCreateServiceConfig(template.id)}
+ onStart={onStartProvider ? () => onStartProvider(template.id, true) : undefined}
+ onStop={onStopProvider ? () => onStopProvider(template.id, true) : undefined}
+ />
+
+
+ {/* Nested instances - collapsible */}
+ {hasInstances && !isCollapsed && (
{instances.map((instance) => {
const instanceConnectionCount = wiring.filter(
- (w) => w.source_instance_id === instance.id
+ (w) => w.source_config_id === instance.id
).length
return (
onEditProvider(instance.id, false)}
- onDelete={() => onDeleteInstance(instance.id)}
+ onDelete={() => onDeleteServiceConfig(instance.id)}
onStart={onStartProvider ? () => onStartProvider(instance.id, false) : undefined}
onStop={onStopProvider ? () => onStopProvider(instance.id, false) : undefined}
templateProvider={template}
@@ -294,6 +362,15 @@ export default function WiringBoard({
})}
)}
+ {/* Collapsed indicator */}
+ {hasInstances && isCollapsed && (
+ toggleTemplateCollapse(template.id)}
+ >
+ {instances.length} instance{instances.length > 1 ? 's' : ''} hidden
+
+ )}
)
})}
@@ -317,33 +394,106 @@ export default function WiringBoard({
{consumers.length > 0 ? (
- {consumers.map((consumer) => {
- const configVars = consumer.configVars || []
- const missingRequiredVars = configVars.filter((v) => v.required && !v.value)
- const configuredVars = configVars.filter((v) => v.value)
- const needsSetup = consumer.configured === false || missingRequiredVars.length > 0
- const isCloud = consumer.mode === 'cloud'
- const canStart = !isCloud && (consumer.status === 'stopped' || consumer.status === 'pending' || consumer.status === 'exited' || consumer.status === 'not_running' || consumer.status === 'not_found')
- const canStop = !isCloud && (consumer.status === 'running' || consumer.status === 'starting')
+ {/* Group consumers by template */}
+ {(() => {
+ // Separate templates and instances
+ const templates = consumers.filter((c) => c.isTemplate)
+ const instances = consumers.filter((c) => !c.isTemplate)
- return (
-
- )
- })}
+ // Group instances by template
+ const instancesByTemplate = instances.reduce((acc, instance) => {
+ const templateId = instance.templateId || 'unknown'
+ if (!acc[templateId]) acc[templateId] = []
+ acc[templateId].push(instance)
+ return acc
+ }, {} as Record
)
+
+ return templates.map((template) => {
+ const templateInstances = instancesByTemplate[template.id] || []
+ const isCollapsed = collapsedTemplates.has(template.id)
+ const hasInstances = templateInstances.length > 0
+
+ return (
+
+ {/* Template header with collapse toggle */}
+
+ {hasInstances && (
+
toggleTemplateCollapse(template.id)}
+ className="p-0.5 mt-4 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
+ title={isCollapsed ? `Show ${templateInstances.length} instance(s)` : 'Collapse instances'}
+ data-testid={`collapse-service-${template.id}`}
+ >
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ onCreateServiceConfig(template.id)}
+ onUpdateConfigVars={
+ onUpdateTemplateConfigVars
+ ? (vars) => onUpdateTemplateConfigVars(template.id, vars)
+ : undefined
+ }
+ alwaysShowConfig={true}
+ />
+
+
+
+ {/* Nested instances - collapsible */}
+ {hasInstances && !isCollapsed && (
+
+ {templateInstances.map((instance) => {
+ return (
+ onDeleteServiceConfig(instance.id)}
+ />
+ )
+ })}
+
+ )}
+ {/* Collapsed indicator */}
+ {hasInstances && isCollapsed && (
+
toggleTemplateCollapse(template.id)}
+ >
+ {templateInstances.length} instance{templateInstances.length > 1 ? 's' : ''} hidden
+
+ )}
+
+ )
+ })
+ })()}
) : (
@@ -376,6 +526,81 @@ export default function WiringBoard({
document.body
)}
+ {/* Provider Selection Modal (click-to-select alternative to drag-drop) */}
+ {selectingSlot && createPortal(
+
setSelectingSlot(null)}
+ >
+
e.stopPropagation()}
+ data-testid="provider-selection-modal"
+ >
+
+
+
+ Select Provider
+
+
+ Choose a {selectingSlot.capability} provider
+
+
+
setSelectingSlot(null)}
+ className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 rounded"
+ >
+
+
+
+
+ {(() => {
+ const availableProviders = getProvidersForCapability(selectingSlot.capability)
+ if (availableProviders.length === 0) {
+ return (
+
+
+
No providers available for {selectingSlot.capability}
+
Add a provider first
+
+ )
+ }
+ return (
+
+ {availableProviders.map((provider) => (
+
handleProviderSelected(provider)}
+ className="w-full p-3 text-left rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
+ data-testid={`select-provider-${provider.id}`}
+ >
+
+ {provider.mode === 'local' ? (
+
+ ) : (
+
+ )}
+
+
+ {provider.name}
+
+
+ {provider.isTemplate ? 'Default' : 'Instance'} • {provider.mode}
+
+
+
+
+
+ ))}
+
+ )
+ })()}
+
+
+
,
+ document.body
+ )}
+
)
}
@@ -388,14 +613,14 @@ interface DraggableProviderProps {
provider: ProviderInfo
connectionCount: number
onEdit: () => void
- onCreateInstance?: () => void // Only for templates
+ onCreateServiceConfig?: () => void // Only for templates
onDelete?: () => void // Only for instances
onStart?: () => Promise
onStop?: () => Promise
templateProvider?: ProviderInfo // Parent template for instances
}
-function DraggableProvider({ provider, connectionCount, onEdit, onCreateInstance, onDelete, onStart, onStop, templateProvider }: DraggableProviderProps) {
+function DraggableProvider({ provider, connectionCount, onEdit, onCreateServiceConfig, onDelete, onStart, onStop, templateProvider }: DraggableProviderProps) {
const [isStarting, setIsStarting] = useState(false)
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: provider.id,
@@ -525,9 +750,9 @@ function DraggableProvider({ provider, connectionCount, onEdit, onCreateInstance
>
- {provider.isTemplate && onCreateInstance && (
+ {provider.isTemplate && onCreateServiceConfig && (
handleButtonClick(e, onCreateInstance)}
+ onClick={(e) => handleButtonClick(e, onCreateServiceConfig)}
className="p-1 text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
title="Create new instance"
data-testid={`provider-create-instance-${provider.id}`}
@@ -602,9 +827,6 @@ function DraggableProvider({ provider, connectionCount, onEdit, onCreateInstance
)
}
-// =============================================================================
-// Service Card Component (vertical slot layout per wireframe)
-// =============================================================================
interface ServiceCardProps {
consumer: ConsumerInfo
@@ -619,6 +841,7 @@ interface ServiceCardProps {
onEdit?: (consumerId: string) => void
onStart?: (consumerId: string) => Promise
onStop?: (consumerId: string) => Promise
+ onDeploy?: (consumerId: string, target: { type: 'local' | 'remote' | 'kubernetes'; id?: string }) => void
}
function ConsumerCard({
@@ -634,8 +857,36 @@ function ConsumerCard({
onEdit,
onStart,
onStop,
+ onDeploy,
}: ServiceCardProps) {
const [isStarting, setIsStarting] = useState(false)
+ const [showDeployMenu, setShowDeployMenu] = useState(false)
+ const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null)
+
+ // Close deploy menu when clicking outside
+ useEffect(() => {
+ if (!showDeployMenu) return
+
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as HTMLElement
+ if (!target.closest('[data-deploy-menu]')) {
+ setShowDeployMenu(false)
+ }
+ }
+
+ document.addEventListener('click', handleClickOutside)
+ return () => document.removeEventListener('click', handleClickOutside)
+ }, [showDeployMenu])
+
+ const handleDeployClick = (e: React.MouseEvent) => {
+ const button = e.currentTarget
+ const rect = button.getBoundingClientRect()
+ setMenuPosition({
+ top: rect.bottom + 4,
+ left: rect.right - 192 // 192px = 48 * 4 (w-48)
+ })
+ setShowDeployMenu(!showDeployMenu)
+ }
const handleStartClick = async () => {
if (!onStart) return
@@ -735,6 +986,59 @@ function ConsumerCard({
) : null}
>
)}
+ {onDeploy && (
+ <>
+
+
+ Deploy
+
+ {showDeployMenu && menuPosition && createPortal(
+
+
{
+ onDeploy(consumer.id, { type: 'local' })
+ setShowDeployMenu(false)
+ }}
+ className="w-full px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-t-lg flex items-center gap-2"
+ >
+
+ Local (Leader uNode)
+
+
{
+ onDeploy(consumer.id, { type: 'remote' })
+ setShowDeployMenu(false)
+ }}
+ className="w-full px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
+ >
+
+ Remote uNode
+
+
{
+ onDeploy(consumer.id, { type: 'kubernetes' })
+ setShowDeployMenu(false)
+ }}
+ className="w-full px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-b-lg flex items-center gap-2"
+ >
+
+ Kubernetes
+
+
,
+ document.body
+ )}
+ >
+ )}
{onEdit && (
onEdit(consumer.id)}
@@ -926,53 +1230,3 @@ function CapabilitySlot({
)
}
-
-// =============================================================================
-// Status Indicator
-// =============================================================================
-
-function StatusIndicator({ status }: { status: string }) {
- switch (status) {
- case 'running':
- return (
-
-
- Running
-
- )
- case 'configured':
- return (
-
-
- Ready
-
- )
- case 'needs_setup':
- return (
-
-
- Setup
-
- )
- case 'stopped':
- case 'not_running':
- return (
-
- Stopped
-
- )
- case 'error':
- return (
-
-
- Error
-
- )
- default:
- return (
-
- {status}
-
- )
- }
-}
diff --git a/ushadow/frontend/src/components/wiring/index.ts b/ushadow/frontend/src/components/wiring/index.ts
index a166debe..799a8caf 100644
--- a/ushadow/frontend/src/components/wiring/index.ts
+++ b/ushadow/frontend/src/components/wiring/index.ts
@@ -1 +1,5 @@
export { default as WiringBoard } from './WiringBoard'
+export { ServiceTemplateCard } from './ServiceTemplateCard'
+export { ServiceInstanceCard } from './ServiceInstanceCard'
+export { CapabilitySlot } from './CapabilitySlot'
+export { StatusIndicator } from './StatusIndicator'
diff --git a/ushadow/frontend/src/contexts/ChronicleContext.tsx b/ushadow/frontend/src/contexts/ChronicleContext.tsx
index 96bd1c28..7ea37021 100644
--- a/ushadow/frontend/src/contexts/ChronicleContext.tsx
+++ b/ushadow/frontend/src/contexts/ChronicleContext.tsx
@@ -20,7 +20,7 @@ const ChronicleContext = createContext(undefin
export function ChronicleProvider({ children }: { children: ReactNode }) {
const [isConnected, setIsConnected] = useState(false)
- const [isCheckingConnection, setIsCheckingConnection] = useState(true)
+ const [isCheckingConnection, setIsCheckingConnection] = useState(false) // Start false - only true during explicit check
const [connectionError, setConnectionError] = useState(null)
// Lift recording hook to context level
@@ -68,10 +68,8 @@ export function ChronicleProvider({ children }: { children: ReactNode }) {
setConnectionError(null)
}, [recording])
- // Check connection on mount and when URL changes
- useEffect(() => {
- checkConnection()
- }, [checkConnection])
+ // 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/contexts/ServicesContext.tsx b/ushadow/frontend/src/contexts/ServicesContext.tsx
index 1c56e427..7fabc52b 100644
--- a/ushadow/frontend/src/contexts/ServicesContext.tsx
+++ b/ushadow/frontend/src/contexts/ServicesContext.tsx
@@ -7,7 +7,7 @@ import type { PortConflict } from '../hooks/useServiceStart'
// Types
// ============================================================================
-export interface ServiceInstance {
+export interface ServiceServiceConfig {
service_id: string
name: string
description: string
@@ -58,7 +58,7 @@ export interface PortConflictDialogState {
interface ServicesContextType {
// State
- serviceInstances: ComposeService[]
+ serviceServiceConfigs: ComposeService[]
serviceConfigs: Record>
serviceStatuses: Record
loading: boolean
@@ -106,7 +106,7 @@ const ServicesContext = createContext(undefined
export function ServicesProvider({ children }: { children: ReactNode }) {
// Core state
- const [serviceInstances, setServiceInstances] = useState([])
+ const [serviceServiceConfigs, setServiceServiceConfigs] = useState([])
const [serviceConfigs, setServiceConfigs] = useState>>({})
const [serviceStatuses, setServiceStatuses] = useState>({})
@@ -190,7 +190,7 @@ export function ServicesProvider({ children }: { children: ReactNode }) {
instances.forEach((service) => {
effectiveConfigs[service.service_id] = {}
- // Note: config_schema is available on ServiceInstance (legacy), not ComposeService
+ // Note: config_schema is available on ServiceServiceConfig (legacy), not ComposeService
const configSchema = (service as any).config_schema as ConfigField[] | undefined
configSchema?.forEach((field: ConfigField) => {
if (field.env_var) {
@@ -208,7 +208,7 @@ export function ServicesProvider({ children }: { children: ReactNode }) {
})
})
- setServiceInstances(instances)
+ setServiceServiceConfigs(instances)
setServiceConfigs(effectiveConfigs)
await loadServiceStatuses(instances, effectiveConfigs)
} catch (error) {
@@ -223,7 +223,7 @@ export function ServicesProvider({ children }: { children: ReactNode }) {
// --------------------------------------------------------------------------
const startService = useCallback(async (serviceId: string) => {
- const service = serviceInstances.find(s => s.service_id === serviceId)
+ const service = serviceServiceConfigs.find(s => s.service_id === serviceId)
setStartingService(serviceId)
try {
@@ -255,16 +255,16 @@ export function ServicesProvider({ children }: { children: ReactNode }) {
setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to start service' })
setStartingService(null)
}
- }, [serviceInstances])
+ }, [serviceServiceConfigs])
const stopService = useCallback((serviceId: string) => {
- const service = serviceInstances.find(s => s.service_id === serviceId)
+ const service = serviceServiceConfigs.find(s => s.service_id === serviceId)
setConfirmDialog({
isOpen: true,
serviceId,
serviceName: service?.service_name || serviceId,
})
- }, [serviceInstances])
+ }, [serviceServiceConfigs])
const confirmStopService = useCallback(async () => {
const { serviceId } = confirmDialog
@@ -328,7 +328,7 @@ export function ServicesProvider({ children }: { children: ReactNode }) {
const newEnabled = !currentEnabled
await servicesApi.setEnabled(serviceId, newEnabled)
- setServiceInstances(prev =>
+ setServiceServiceConfigs(prev =>
prev.map(s => s.service_id === serviceId ? { ...s, enabled: newEnabled } : s)
)
@@ -353,7 +353,7 @@ export function ServicesProvider({ children }: { children: ReactNode }) {
}, [serviceConfigs])
const saveConfig = useCallback(async (serviceId: string) => {
- const service = serviceInstances.find(s => s.service_id === serviceId)
+ const service = serviceServiceConfigs.find(s => s.service_id === serviceId)
if (!service) return
setValidationErrors({})
@@ -391,7 +391,7 @@ export function ServicesProvider({ children }: { children: ReactNode }) {
} finally {
setSaving(false)
}
- }, [serviceInstances, editForm])
+ }, [serviceServiceConfigs, editForm])
const cancelEditing = useCallback(() => {
setEditingService(null)
@@ -437,7 +437,7 @@ export function ServicesProvider({ children }: { children: ReactNode }) {
const value: ServicesContextType = {
// State
- serviceInstances,
+ serviceServiceConfigs,
serviceConfigs,
serviceStatuses,
loading,
diff --git a/ushadow/frontend/src/hooks/useServiceStatus.ts b/ushadow/frontend/src/hooks/useServiceStatus.ts
index 3c52c1c7..96148e3c 100644
--- a/ushadow/frontend/src/hooks/useServiceStatus.ts
+++ b/ushadow/frontend/src/hooks/useServiceStatus.ts
@@ -6,7 +6,7 @@ import {
PlayCircle,
LucideIcon,
} from 'lucide-react'
-import type { ServiceInstance, ContainerStatus, ConfigField } from '../contexts/ServicesContext'
+import type { ServiceServiceConfig, ContainerStatus, ConfigField } from '../contexts/ServicesContext'
// ============================================================================
// Types
@@ -31,7 +31,7 @@ export interface ServiceStatusResult {
// ============================================================================
function hasRequiredConfig(
- service: ServiceInstance,
+ service: ServiceServiceConfig,
config: Record | undefined
): boolean {
// No config needed (like mem0-ui) - always "configured"
@@ -72,7 +72,7 @@ function hasRequiredConfig(
* 3. Local services - Check Docker container status
*/
export function useServiceStatus(
- service: ServiceInstance,
+ service: ServiceServiceConfig,
config: Record | undefined,
containerStatus: ContainerStatus | undefined
): ServiceStatusResult {
diff --git a/ushadow/frontend/src/pages/ClusterPage.tsx b/ushadow/frontend/src/pages/ClusterPage.tsx
index f8e30459..609c2a95 100644
--- a/ushadow/frontend/src/pages/ClusterPage.tsx
+++ b/ushadow/frontend/src/pages/ClusterPage.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
-import { Server, Plus, RefreshCw, Copy, Trash2, CheckCircle, XCircle, Clock, Monitor, HardDrive, Cpu, Check, Play, Square, RotateCcw, Package, FileText, ArrowUpCircle, X, Unlink, ExternalLink, AlertTriangle, QrCode, Smartphone } from 'lucide-react'
+import { Server, Plus, RefreshCw, Copy, Trash2, CheckCircle, XCircle, Clock, Monitor, HardDrive, Cpu, Check, Play, Square, RotateCcw, Package, FileText, ArrowUpCircle, X, Unlink, ExternalLink, AlertTriangle, QrCode, Smartphone, Info } from 'lucide-react'
import { clusterApi, deploymentsApi, servicesApi, Deployment } from '../services/api'
import { useMobileQrCode } from '../hooks/useQrCode'
import Modal from '../components/Modal'
@@ -336,8 +336,21 @@ export default function ClusterPage() {
setShowLogsModal(true)
setLoadingLogs(true)
try {
+ // Get deployment details for error message
+ const deployment = deployments.find(d => d.id === deploymentId)
+
const response = await deploymentsApi.getDeploymentLogs(deploymentId)
- setLogs(response.data.logs)
+ let logsText = response.data.logs
+
+ // If deployment failed and no logs, show the error message
+ if (deployment?.status === 'failed' && (!logsText || logsText.trim() === 'No logs available')) {
+ logsText = `Deployment failed: ${deployment.error || 'Unknown error'}\n\nNo container logs available (container may not have started)`
+ } else if (deployment?.status === 'failed' && deployment.error) {
+ // Prepend error message to logs
+ logsText = `Deployment Error: ${deployment.error}\n\n--- Container Logs ---\n${logsText}`
+ }
+
+ setLogs(logsText)
} catch (err: any) {
setLogs(`Failed to load logs: ${err.response?.data?.detail || err.message}`)
} finally {
@@ -588,10 +601,9 @@ export default function ClusterPage() {
{unodes.map((node) => {
const isNodeOffline = node.status !== 'online' && node.status !== 'connecting'
return (
-
{/* Node Header */}
@@ -612,7 +624,22 @@ export default function ClusterPage() {
- {getStatusIcon(node.status)}
+
+ {node.role === 'leader' && (
+ {
+ e.stopPropagation()
+ fetchLeaderInfo()
+ }}
+ className="p-2 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
+ title="View leader details"
+ data-testid={`node-info-${node.hostname}`}
+ >
+
+
+ )}
+ {getStatusIcon(node.status)}
+
{/* Node IP */}
@@ -679,7 +706,20 @@ export default function ClusterPage() {
return (
{
+ if (deployment.status === 'failed') {
+ e.stopPropagation()
+ handleViewLogs(deployment.id)
+ }
+ }}
+ title={deployment.status === 'failed' ? 'Click to view logs and error details' : ''}
>
{isNodeOffline && (
@@ -702,6 +742,7 @@ export default function ClusterPage() {
href={deployment.access_url}
target="_blank"
rel="noopener noreferrer"
+ onClick={(e) => e.stopPropagation()}
className="p-1 text-neutral-500 hover:text-primary-600 rounded"
title={`Open ${deployment.access_url}`}
>
@@ -710,7 +751,10 @@ export default function ClusterPage() {
)}
{deployment.status === 'running' ? (
handleStopDeployment(deployment.id)}
+ onClick={(e) => {
+ e.stopPropagation()
+ handleStopDeployment(deployment.id)
+ }}
className="p-1 text-neutral-500 hover:text-warning-600 rounded"
title="Stop"
>
@@ -718,7 +762,10 @@ export default function ClusterPage() {
) : deployment.status === 'stopped' ? (
handleRestartDeployment(deployment.id)}
+ onClick={(e) => {
+ e.stopPropagation()
+ handleRestartDeployment(deployment.id)
+ }}
className="p-1 text-neutral-500 hover:text-success-600 rounded"
title="Start"
>
@@ -726,21 +773,30 @@ export default function ClusterPage() {
) : null}
handleRestartDeployment(deployment.id)}
+ onClick={(e) => {
+ e.stopPropagation()
+ handleRestartDeployment(deployment.id)
+ }}
className="p-1 text-neutral-500 hover:text-primary-600 rounded"
title="Restart"
>
handleViewLogs(deployment.id)}
+ onClick={(e) => {
+ e.stopPropagation()
+ handleViewLogs(deployment.id)
+ }}
className="p-1 text-neutral-500 hover:text-primary-600 rounded"
title="View Logs"
>
handleRemoveDeployment(deployment.id)}
+ onClick={(e) => {
+ e.stopPropagation()
+ handleRemoveDeployment(deployment.id)
+ }}
className="p-1 text-neutral-500 hover:text-danger-600 rounded"
title="Remove"
>
@@ -757,7 +813,10 @@ export default function ClusterPage() {
{node.role !== 'leader' && node.status === 'online' && (
openDeployModal(node.hostname)}
+ onClick={(e) => {
+ e.stopPropagation()
+ openDeployModal(node.hostname)
+ }}
className="text-sm text-primary-600 dark:text-primary-400 hover:underline flex items-center"
>
@@ -768,7 +827,10 @@ export default function ClusterPage() {
{node.role !== 'leader' && node.status === 'online' && (
openUpgradeModal(node.hostname)}
+ onClick={(e) => {
+ e.stopPropagation()
+ openUpgradeModal(node.hostname)
+ }}
className="p-2 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded transition-colors"
title="Upgrade manager"
data-testid={`upgrade-node-${node.hostname}`}
@@ -778,7 +840,10 @@ export default function ClusterPage() {
)}
{node.role !== 'leader' && (
handleReleaseNode(node.hostname)}
+ onClick={(e) => {
+ e.stopPropagation()
+ handleReleaseNode(node.hostname)
+ }}
className="p-2 text-neutral-600 dark:text-neutral-400 hover:text-warning-600 dark:hover:text-warning-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded transition-colors"
title="Release for another leader"
data-testid={`release-node-${node.hostname}`}
@@ -788,7 +853,10 @@ export default function ClusterPage() {
)}
{node.role !== 'leader' && (
handleRemoveNode(node.hostname)}
+ onClick={(e) => {
+ e.stopPropagation()
+ handleRemoveNode(node.hostname)
+ }}
className="p-2 text-neutral-600 dark:text-neutral-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded transition-colors"
title="Remove from cluster"
data-testid={`remove-node-${node.hostname}`}
diff --git a/ushadow/frontend/src/pages/InstancesPage.tsx b/ushadow/frontend/src/pages/InstancesPage.tsx
deleted file mode 100644
index c52763f9..00000000
--- a/ushadow/frontend/src/pages/InstancesPage.tsx
+++ /dev/null
@@ -1,2134 +0,0 @@
-import { useState, useEffect, useCallback } from 'react'
-import {
- Layers,
- Plus,
- RefreshCw,
- ChevronDown,
- ChevronUp,
- AlertCircle,
- CheckCircle,
- X,
- Loader2,
- Cloud,
- HardDrive,
- Package,
- Pencil,
- Plug,
- Settings,
- Trash2,
- PlayCircle,
- ArrowRight,
- Activity,
- Database,
- Zap,
- Clock,
-} from 'lucide-react'
-import {
- instancesApi,
- integrationApi,
- settingsApi,
- servicesApi,
- Template,
- Instance,
- InstanceSummary,
- Wiring,
- InstanceCreateRequest,
-} from '../services/api'
-import ConfirmDialog from '../components/ConfirmDialog'
-import Modal from '../components/Modal'
-import { WiringBoard } from '../components/wiring'
-
-/**
- * Extract error message from FastAPI response.
- * Handles both string detail and validation error arrays.
- */
-function getErrorMessage(error: any, fallback: string): string {
- const detail = error.response?.data?.detail
-
- // Handle validation errors (array of error objects)
- if (Array.isArray(detail)) {
- return detail.map((err: any) => err.msg || String(err)).join(', ')
- }
-
- // Handle string detail
- if (typeof detail === 'string') {
- return detail
- }
-
- // Fallback
- return fallback
-}
-
-export default function InstancesPage() {
- // Templates state
- const [templates, setTemplates] = useState([])
- const [expandedTemplates, setExpandedTemplates] = useState>(new Set())
-
- // Instances state
- const [instances, setInstances] = useState([])
- const [expandedInstances, setExpandedInstances] = useState>(new Set())
- const [instanceDetails, setInstanceDetails] = useState>({})
-
- // Wiring state (per-service connections)
- const [wiring, setWiring] = useState([])
-
- // Service status state for consumers
- const [serviceStatuses, setServiceStatuses] = useState>({})
-
- // UI state
- const [loading, setLoading] = useState(true)
- const [creating, setCreating] = useState(null)
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
- const [confirmDialog, setConfirmDialog] = useState<{
- isOpen: boolean
- instanceId: string | null
- }>({ isOpen: false, instanceId: null })
- const [showAddProviderModal, setShowAddProviderModal] = useState(false)
- // Track providers user has added (even if not yet configured)
- const [addedProviderIds, setAddedProviderIds] = useState>(new Set())
-
- // Unified instance creation state (used by both + button and drag-drop)
- const [createInstanceState, setCreateInstanceState] = useState<{
- isOpen: boolean
- template: Template | null
- form: {
- id: string
- name: string
- deployment_target: string
- config: Record
- }
- wiring?: {
- capability: string
- consumerId: string
- consumerName: string
- }
- }>({
- isOpen: false,
- template: null,
- form: {
- id: '',
- name: '',
- deployment_target: 'local',
- config: {},
- },
- })
-
- // Edit modal state - for editing provider templates or instances from wiring board
- const [editingProvider, setEditingProvider] = useState<{
- id: string
- name: string
- isTemplate: boolean
- template: Template | null
- config: Record
- } | null>(null)
- const [editConfig, setEditConfig] = useState>({})
- const [isSavingEdit, setIsSavingEdit] = useState(false)
-
- // ESC key to close modals
- const closeAllModals = useCallback(() => {
- setCreateInstanceState({
- isOpen: false,
- template: null,
- form: { id: '', name: '', deployment_target: 'local', config: {} },
- })
- setShowAddProviderModal(false)
- setEditingProvider(null)
- }, [])
-
- useEffect(() => {
- const handleEsc = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- closeAllModals()
- }
- }
- window.addEventListener('keydown', handleEsc)
- return () => window.removeEventListener('keydown', handleEsc)
- }, [closeAllModals])
-
- // Load initial data
- useEffect(() => {
- loadData()
- }, [])
-
- const loadData = async () => {
- try {
- setLoading(true)
- const [templatesRes, instancesRes, wiringRes, statusesRes] = await Promise.all([
- instancesApi.getTemplates(),
- instancesApi.getInstances(),
- instancesApi.getWiring(),
- servicesApi.getAllStatuses().catch(() => ({ data: {} })),
- ])
-
- setTemplates(templatesRes.data)
- setInstances(instancesRes.data)
- setWiring(wiringRes.data)
- setServiceStatuses(statusesRes.data || {})
-
- // 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 providerInstances = instancesRes.data.filter((i) =>
- providerTemplates.some((t) => t.id === i.template_id)
- )
-
- if (providerInstances.length > 0) {
- const detailsPromises = providerInstances.map((i) =>
- instancesApi.getInstance(i.id).catch(() => null)
- )
- const detailsResults = await Promise.all(detailsPromises)
-
- const newDetails: Record = {}
- detailsResults.forEach((res, idx) => {
- if (res?.data) {
- newDetails[providerInstances[idx].id] = res.data
- }
- })
- setInstanceDetails((prev) => ({ ...prev, ...newDetails }))
- }
- } catch (error) {
- console.error('Error loading data:', error)
- setMessage({ type: 'error', text: 'Failed to load instances data' })
- } finally {
- setLoading(false)
- }
- }
-
- // 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 generateInstanceId = (templateId: string): string => {
- // Find all existing instances that start with this template ID
- const existingIds = instances
- .map((i) => i.id)
- .filter((id) => id.startsWith(`${templateId}-`))
-
- // Extract numbers from existing IDs
- const numbers = existingIds
- .map((id) => {
- const match = id.match(new RegExp(`^${templateId}-(\\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 `${templateId}-${nextNum}`
- }
-
- /**
- * Open create instance modal - 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 openCreateInstanceModal = (
- template: Template,
- wiring?: { capability: string; consumerId: string; consumerName: string }
- ) => {
- // Generate unique incremental ID
- const generatedId = generateInstanceId(template.id)
-
- // Start with EMPTY config - defaults are shown in UI but not stored
- // Only user-entered values should be in form state
- setCreateInstanceState({
- isOpen: true,
- template,
- form: {
- id: generatedId,
- name: generatedId, // Default name to the generated ID
- deployment_target: template.mode === 'cloud' ? 'cloud' : 'local',
- config: {}, // Empty - only store actual overrides
- },
- wiring, // Optional - only present for drag-drop path
- })
- }
-
- /**
- * Unified handler for creating instances (both + button and drag-drop)
- * If wiring info is present, also creates the wiring connection
- */
- const handleCreateInstance = async () => {
- if (!createInstanceState.template) return
-
- setCreating(createInstanceState.template.id)
- try {
- // Filter out empty values - only send actual overrides
- const filteredConfig = Object.fromEntries(
- Object.entries(createInstanceState.form.config).filter(([, v]) => v && v.trim() !== '')
- )
-
- const data: InstanceCreateRequest = {
- id: createInstanceState.form.id,
- template_id: createInstanceState.template.id,
- name: createInstanceState.form.name,
- deployment_target: createInstanceState.form.deployment_target,
- config: filteredConfig,
- }
-
- // Step 1: Create the instance
- await instancesApi.createInstance(data)
-
- // Step 2: If wiring info exists, create the wiring connection (drag-drop path)
- if (createInstanceState.wiring) {
- const newWiring = await instancesApi.createWiring({
- source_instance_id: createInstanceState.form.id,
- source_capability: createInstanceState.wiring.capability,
- target_instance_id: createInstanceState.wiring.consumerId,
- target_capability: createInstanceState.wiring.capability,
- })
-
- // Update wiring state
- setWiring((prev) => {
- const existing = prev.findIndex(
- (w) =>
- w.target_instance_id === createInstanceState.wiring!.consumerId &&
- w.target_capability === createInstanceState.wiring!.capability
- )
- if (existing >= 0) {
- const updated = [...prev]
- updated[existing] = newWiring.data
- return updated
- }
- return [...prev, newWiring.data]
- })
-
- setMessage({
- type: 'success',
- text: `Created ${createInstanceState.form.name} and connected to ${createInstanceState.wiring.consumerName}`,
- })
- } else {
- setMessage({ type: 'success', text: `Instance "${createInstanceState.form.name}" created` })
- }
-
- // Close modal and reload instances
- setCreateInstanceState({
- isOpen: false,
- template: null,
- form: { id: '', name: '', deployment_target: 'local', config: {} },
- })
-
- const instancesRes = await instancesApi.getInstances()
- setInstances(instancesRes.data)
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: getErrorMessage(error, 'Failed to create instance'),
- })
- } finally {
- setCreating(null)
- }
- }
-
- // Instance actions
- const toggleInstance = async (instanceId: string) => {
- if (expandedInstances.has(instanceId)) {
- setExpandedInstances((prev) => {
- const next = new Set(prev)
- next.delete(instanceId)
- return next
- })
- } else {
- // Load full instance details
- if (!instanceDetails[instanceId]) {
- try {
- const res = await instancesApi.getInstance(instanceId)
- setInstanceDetails((prev) => ({
- ...prev,
- [instanceId]: res.data,
- }))
- } catch (error) {
- console.error('Failed to load instance details:', error)
- }
- }
- setExpandedInstances((prev) => new Set(prev).add(instanceId))
- }
- }
-
- const handleDeleteInstance = (instanceId: string) => {
- setConfirmDialog({ isOpen: true, instanceId })
- }
-
- const confirmDeleteInstance = async () => {
- const { instanceId } = confirmDialog
- if (!instanceId) return
-
- setConfirmDialog({ isOpen: false, instanceId: null })
-
- try {
- await instancesApi.deleteInstance(instanceId)
- setMessage({ type: 'success', text: 'Instance deleted' })
-
- // Reload instances
- const instancesRes = await instancesApi.getInstances()
- setInstances(instancesRes.data)
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to delete instance',
- })
- }
- }
-
- // Deploy/undeploy actions
- const [deployingInstance, setDeployingInstance] = useState(null)
-
- // Integration sync state
- const [syncingInstance, setSyncingInstance] = useState(null)
- const [testingConnection, setTestingConnection] = useState(null)
- const [togglingAutoSync, setTogglingAutoSync] = useState(null)
-
- const handleDeployInstance = async (instanceId: string) => {
- setDeployingInstance(instanceId)
- try {
- await instancesApi.deployInstance(instanceId)
- setMessage({ type: 'success', text: 'Instance started' })
- // Reload instances
- const instancesRes = await instancesApi.getInstances()
- setInstances(instancesRes.data)
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to start instance',
- })
- } finally {
- setDeployingInstance(null)
- }
- }
-
- const handleUndeployInstance = async (instanceId: string) => {
- setDeployingInstance(instanceId)
- try {
- await instancesApi.undeployInstance(instanceId)
- setMessage({ type: 'success', text: 'Instance stopped' })
- // Reload instances
- const instancesRes = await instancesApi.getInstances()
- setInstances(instancesRes.data)
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to stop instance',
- })
- } finally {
- setDeployingInstance(null)
- }
- }
-
- // Integration actions
- const handleTestConnection = async (instanceId: string) => {
- setTestingConnection(instanceId)
- try {
- const response = await integrationApi.testConnection(instanceId)
- setMessage({
- type: response.data.success ? 'success' : 'error',
- text: response.data.message,
- })
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: getErrorMessage(error, 'Failed to test connection'),
- })
- } finally {
- setTestingConnection(null)
- }
- }
-
- const handleSyncNow = async (instanceId: string) => {
- setSyncingInstance(instanceId)
- try {
- const response = await integrationApi.syncNow(instanceId)
- if (response.data.success) {
- setMessage({
- type: 'success',
- text: `Synced ${response.data.items_synced} items`,
- })
- // Reload instance details to show updated sync status
- const res = await instancesApi.getInstance(instanceId)
- setInstanceDetails((prev) => ({ ...prev, [instanceId]: res.data }))
- } else {
- setMessage({
- type: 'error',
- text: response.data.error || 'Sync failed',
- })
- }
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: getErrorMessage(error, 'Failed to sync'),
- })
- } finally {
- setSyncingInstance(null)
- }
- }
-
- const handleToggleAutoSync = async (instanceId: string, enable: boolean) => {
- setTogglingAutoSync(instanceId)
- try {
- const response = enable
- ? await integrationApi.enableAutoSync(instanceId)
- : await integrationApi.disableAutoSync(instanceId)
-
- setMessage({
- type: response.data.success ? 'success' : 'error',
- text: response.data.message,
- })
-
- // Reload instance details to show updated auto-sync status
- const res = await instancesApi.getInstance(instanceId)
- setInstanceDetails((prev) => ({ ...prev, [instanceId]: res.data }))
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: getErrorMessage(error, 'Failed to toggle auto-sync'),
- })
- } finally {
- setTogglingAutoSync(null)
- }
- }
-
- // Consumer/Service handlers for WiringBoard
- const handleStartConsumer = async (consumerId: string) => {
- try {
- // Extract service name from template ID (format: "compose_file:service_name")
- const serviceName = consumerId.includes(':') ? consumerId.split(':').pop()! : consumerId
- await servicesApi.startService(serviceName)
- setMessage({ type: 'success', text: `${consumerId} started` })
- // Reload service statuses
- const statusesRes = await servicesApi.getAllStatuses()
- setServiceStatuses(statusesRes.data || {})
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || `Failed to start ${consumerId}`,
- })
- }
- }
-
- const handleStopConsumer = async (consumerId: string) => {
- try {
- // Extract service name from template ID (format: "compose_file:service_name")
- const serviceName = consumerId.includes(':') ? consumerId.split(':').pop()! : consumerId
- await servicesApi.stopService(serviceName)
- setMessage({ type: 'success', text: `${consumerId} stopped` })
- // Reload service statuses
- const statusesRes = await servicesApi.getAllStatuses()
- setServiceStatuses(statusesRes.data || {})
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || `Failed to stop ${consumerId}`,
- })
- }
- }
-
- const handleEditConsumer = (consumerId: string) => {
- // Navigate to services page with the service pre-selected for editing
- // For now, just expand the template to show settings
- const template = templates.find((t) => t.id === consumerId)
- if (template) {
- setSelectedTemplate(template)
- setShowCreateModal(true)
- }
- }
-
- // Transform data for WiringBoard
- // Providers: provider templates (both configured and unconfigured) + custom instances
- const providerTemplates = templates
- .filter((t) => t.source === 'provider' && t.provides)
-
- 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'
- }
-
- // 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}`
- }
- }
-
- 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,
- })
- })
-
- // 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
- }
-
- // 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}`
- }
- }
-
- return {
- id: i.id,
- name: displayName,
- capability: template.provides!,
- status: instanceStatus,
- mode: template.mode,
- isTemplate: false,
- templateId: i.template_id,
- configVars,
- configured: template.configured, // Instance inherits template's configured status
- }
- }),
- ]
-
- // Consumers: compose services that require capabilities
- const wiringConsumers = templates
- .filter((t) => t.source === 'compose' && t.requires && t.requires.length > 0)
- .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,
- }
- })
-
- // 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 instancesApi.createWiring({
- source_instance_id: dropInfo.provider.id,
- source_capability: dropInfo.capability,
- target_instance_id: dropInfo.consumerId,
- target_capability: dropInfo.capability,
- })
- setWiring((prev) => {
- const existing = prev.findIndex(
- (w) => w.target_instance_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
- }
-
- // For templates, open the create instance modal with wiring info
- const template = templates.find((t) => t.id === dropInfo.provider.id)
- if (template) {
- openCreateInstanceModal(template, {
- capability: dropInfo.capability,
- consumerId: dropInfo.consumerId,
- consumerName: consumer?.name || dropInfo.consumerId,
- })
- }
- }
-
- const handleDeleteWiringFromBoard = async (consumerId: string, capability: string) => {
- // Find the wiring to delete
- const wire = wiring.find(
- (w) => w.target_instance_id === consumerId && w.target_capability === capability
- )
- if (!wire) return
-
- try {
- await instancesApi.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
- }
- }
-
- // Handle edit provider/instance from wiring board
- const handleEditProviderFromBoard = async (providerId: string, isTemplate: boolean) => {
- if (isTemplate) {
- // Edit template - open modal with template config
- const template = templates.find((t) => t.id === providerId)
- if (template) {
- // Pre-populate config from template values
- const initialConfig: Record = {}
- template.config_schema?.forEach((field: any) => {
- if (field.value) {
- initialConfig[field.key] = field.value
- }
- })
-
- setEditingProvider({
- id: providerId,
- name: template.name,
- isTemplate: true,
- template,
- config: initialConfig,
- })
- setEditConfig(initialConfig)
- }
- } else {
- // Edit instance - fetch details and open modal
- try {
- let details = instanceDetails[providerId]
- if (!details) {
- const res = await instancesApi.getInstance(providerId)
- details = res.data
- setInstanceDetails((prev) => ({ ...prev, [providerId]: details }))
- }
-
- const instance = instances.find((i) => i.id === providerId)
- const template = templates.find((t) => t.id === instance?.template_id)
-
- if (details && template) {
- const initialConfig = details.config?.values || {}
- setEditingProvider({
- id: providerId,
- name: details.name,
- isTemplate: false,
- template,
- config: initialConfig as Record,
- })
- setEditConfig(initialConfig as Record)
- }
- } catch (err) {
- console.error('Failed to load instance details:', err)
- setMessage({ type: 'error', text: 'Failed to load instance details' })
- }
- }
- }
-
- // Handle save edit from modal
- const handleSaveEdit = async () => {
- if (!editingProvider) return
-
- setIsSavingEdit(true)
- try {
- if (editingProvider.isTemplate) {
- // Templates store values in settings store via settings_path
- // Build a nested update object from the config schema
- const updates: Record> = {}
- const schema = editingProvider.template?.config_schema || []
-
- for (const field of schema) {
- const newValue = editConfig[field.key]
- // Only update if user provided a new value (not empty for existing secrets)
- if (newValue && newValue.trim()) {
- if (field.settings_path) {
- // Parse path like "api_keys.openai_api_key" into nested object
- const parts = field.settings_path.split('.')
- if (parts.length === 2) {
- const [section, key] = parts
- if (!updates[section]) updates[section] = {}
- updates[section][key] = newValue
- }
- }
- }
- }
-
- if (Object.keys(updates).length > 0) {
- await settingsApi.update(updates)
- }
-
- // Refresh templates to get updated values
- const templatesRes = await instancesApi.getTemplates()
- setTemplates(templatesRes.data)
- setMessage({ type: 'success', text: `${editingProvider.name} settings updated` })
- } else {
- // Update instance config - filter out empty values before saving
- const filteredConfig = Object.fromEntries(
- Object.entries(editConfig).filter(([, v]) => v && v.trim() !== '')
- )
- await instancesApi.updateInstance(editingProvider.id, { config: filteredConfig })
- // Refresh instance details
- const res = await instancesApi.getInstance(editingProvider.id)
- setInstanceDetails((prev) => ({ ...prev, [editingProvider.id]: res.data }))
- setMessage({ type: 'success', text: `${editingProvider.name} updated` })
- }
- setEditingProvider(null)
- } catch (error: any) {
- setMessage({
- type: 'error',
- text: error.response?.data?.detail || 'Failed to save changes',
- })
- } finally {
- setIsSavingEdit(false)
- }
- }
-
- // Handle create instance from wiring board (via "+" button)
- const handleCreateInstanceFromBoard = (templateId: string) => {
- const template = templates.find((t) => t.id === templateId)
- if (template) {
- openCreateInstanceModal(template)
- }
- }
-
- // Group templates by source
- const composeTemplates = templates.filter((t) => t.source === 'compose')
- const allProviderTemplates = templates.filter((t) => t.source === 'provider')
-
- // 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)
- )
-
- const handleAddProvider = (templateId: string) => {
- setAddedProviderIds((prev) => new Set(prev).add(templateId))
- setShowAddProviderModal(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}
-
- )
- }
- }
-
- // Render
- if (loading) {
- return (
-
-
-
-
Loading instances...
-
-
- )
- }
-
- return (
-
- {/* Header */}
-
-
-
-
-
Instances
-
-
- Create and manage service instances from templates
-
-
-
- {availableToAdd.length > 0 && (
-
setShowAddProviderModal(true)}
- className="btn-primary flex items-center gap-2"
- data-testid="add-provider-button"
- >
-
- Add Provider
-
- )}
-
-
-
-
-
-
- {/* Stats */}
-
-
-
Templates
-
- {templates.length}
-
-
-
-
Instances
-
- {instances.length}
-
-
-
-
Running
-
- {instances.filter((i) => i.status === 'running').length}
-
-
-
-
Wiring
-
- {wiring.length}
-
-
-
-
- {/* Message */}
- {message && (
-
-
-
-
{message.text}
-
setMessage(null)} className="ml-auto">
-
-
-
-
- )}
-
- {/* Instances - Compact list style */}
- {instances.length > 0 && (
-
-
- Active Instances
-
-
- {instances.map((instance) => {
- const isExpanded = expandedInstances.has(instance.id)
- const details = instanceDetails[instance.id]
-
- return (
-
-
toggleInstance(instance.id)}
- >
-
- {getStatusBadge(instance.status)}
-
- {instance.name}
-
-
- {instance.template_id}
-
-
-
- {instance.provides && (
-
- {instance.provides}
-
- )}
- {/* Start/Stop/Setup buttons for deployable instances (not cloud) */}
- {instance.status !== 'n/a' && (() => {
- const instanceTemplate = templates.find((t) => t.id === instance.template_id)
- const needsSetup = instanceTemplate && !instanceTemplate.configured
- const canStart = instance.status === 'stopped' || instance.status === 'pending' || instance.status === 'error'
- const canStop = instance.status === 'running' || instance.status === 'deploying'
-
- return (
- <>
- {/* Setup button - when template is not configured */}
- {needsSetup && canStart && (
-
{
- e.stopPropagation()
- // Expand and open edit modal
- if (!expandedInstances.has(instance.id)) {
- toggleInstance(instance.id)
- }
- setEditingProvider({
- id: instance.id,
- name: instance.name,
- isTemplate: false,
- template: instanceTemplate,
- config: (instanceDetails[instance.id]?.config.values || {}) as Record,
- })
- setEditConfig((instanceDetails[instance.id]?.config.values || {}) as Record)
- }}
- className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-300 hover:bg-warning-200 dark:hover:bg-warning-900/50"
- title="Configure required settings"
- data-testid={`setup-instance-${instance.id}`}
- >
-
- Setup
-
- )}
- {/* Start button - when configured */}
- {!needsSetup && canStart && (
-
{
- e.stopPropagation()
- handleDeployInstance(instance.id)
- }}
- disabled={deployingInstance === instance.id}
- className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-success-100 dark:bg-success-900/30 text-success-700 dark:text-success-300 hover:bg-success-200 dark:hover:bg-success-900/50 disabled:opacity-50"
- title="Start"
- data-testid={`start-instance-${instance.id}`}
- >
- {deployingInstance === instance.id ? (
-
- ) : (
-
- )}
- Start
-
- )}
- {/* Stop button */}
- {canStop && (
-
{
- e.stopPropagation()
- handleUndeployInstance(instance.id)
- }}
- disabled={deployingInstance === instance.id}
- className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 disabled:opacity-50"
- title="Stop"
- data-testid={`stop-instance-${instance.id}`}
- >
- {deployingInstance === instance.id ? (
-
- ) : (
-
- )}
- Stop
-
- )}
- >
- )
- })()}
-
{
- e.stopPropagation()
- handleDeleteInstance(instance.id)
- }}
- className="p-1 text-neutral-400 hover:text-error-600 dark:hover:text-error-400 rounded"
- title="Delete"
- >
-
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
-
- {isExpanded && details && (() => {
- const template = templates.find((t) => t.id === instance.template_id)
- const configSchema = template?.config_schema || []
- const hasConfigSchema = configSchema.length > 0
- const configValues = details.config.values || {}
-
- return (
-
- {/* Config Schema with required indicators */}
- {hasConfigSchema && (
-
-
-
- Configuration
-
-
{
- e.stopPropagation()
- // Open edit modal for this instance
- setEditingProvider({
- id: instance.id,
- name: instance.name,
- isTemplate: false,
- template: template || null,
- config: configValues as Record,
- })
- setEditConfig(configValues as Record)
- }}
- className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
- data-testid={`edit-instance-config-${instance.id}`}
- >
-
- Edit
-
-
- {configSchema.map((field) => {
- const overrideValue = configValues[field.key]
- const displayValue = overrideValue || field.value || field.default || ''
- const isSecret = field.type === 'secret'
- const isRequired = field.required
- const hasValue = field.has_value || !!field.default || !!overrideValue
- const needsValue = isRequired && !hasValue
-
- return (
-
-
- {isRequired && * }
- {field.label || field.key}:
-
-
- {needsValue ? (
- Not set
- ) : isSecret ? (
- '••••••••'
- ) : (
- displayValue || —
- )}
-
- {overrideValue && (
-
- override
-
- )}
-
- )
- })}
-
- )}
-
- {/* Legacy: Show override values if no schema (shouldn't happen, but fallback) */}
- {!hasConfigSchema && Object.entries(configValues).length > 0 && (
-
- {Object.entries(configValues).map(([key, value]) => (
-
- {key}:
-
- {String(value).length > 30
- ? String(value).slice(0, 30) + '...'
- : String(value)}
-
-
- ))}
-
- )}
-
- {/* Integration Sync UI - only shown for integrations */}
- {details.integration_type && (
-
-
-
-
- Integration Sync
-
-
- {details.integration_type}
-
-
-
- {/* Sync Status */}
-
-
- Status:
-
-
- {details.last_sync_status === 'success' && (
-
-
- Success
-
- )}
- {details.last_sync_status === 'error' && (
-
-
- Error
-
- )}
- {details.last_sync_status === 'in_progress' && (
-
-
- Syncing
-
- )}
- {details.last_sync_status === 'never' && (
-
- Never synced
-
- )}
- {details.last_sync_at && (
-
- {new Date(details.last_sync_at).toLocaleString()}
-
- )}
-
-
-
- {/* Last Sync Items Count */}
- {details.last_sync_items_count !== null && details.last_sync_items_count !== undefined && (
-
-
- Items Synced:
-
-
- {details.last_sync_items_count}
-
-
- )}
-
- {/* Auto-Sync Status */}
-
-
- Auto-Sync:
-
-
- {details.sync_enabled ? (
- <>
-
-
- Enabled
-
- {details.next_sync_at && (
-
-
- Next: {new Date(details.next_sync_at).toLocaleString()}
-
- )}
- >
- ) : (
- Disabled
- )}
-
-
-
- {/* Sync Error */}
- {details.last_sync_error && (
-
-
-
-
{details.last_sync_error}
-
-
- )}
-
- {/* Action Buttons */}
-
-
{
- e.stopPropagation()
- handleTestConnection(instance.id)
- }}
- disabled={testingConnection === instance.id}
- className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 disabled:opacity-50"
- data-testid={`test-connection-${instance.id}`}
- >
- {testingConnection === instance.id ? (
-
- ) : (
-
- )}
- Test Connection
-
-
-
{
- e.stopPropagation()
- handleSyncNow(instance.id)
- }}
- disabled={syncingInstance === instance.id || details.last_sync_status === 'in_progress'}
- className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/50 disabled:opacity-50"
- data-testid={`sync-now-${instance.id}`}
- >
- {syncingInstance === instance.id || details.last_sync_status === 'in_progress' ? (
-
- ) : (
-
- )}
- Sync Now
-
-
-
{
- e.stopPropagation()
- handleToggleAutoSync(instance.id, !details.sync_enabled)
- }}
- disabled={togglingAutoSync === instance.id}
- className={`flex items-center gap-1 px-2 py-1 text-xs rounded ${
- details.sync_enabled
- ? 'bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-300 hover:bg-warning-200 dark:hover:bg-warning-900/50'
- : 'bg-success-100 dark:bg-success-900/30 text-success-700 dark:text-success-300 hover:bg-success-200 dark:hover:bg-success-900/50'
- } disabled:opacity-50`}
- data-testid={`toggle-auto-sync-${instance.id}`}
- >
- {togglingAutoSync === instance.id ? (
-
- ) : (
-
- )}
- {details.sync_enabled ? 'Disable' : 'Enable'} Auto-Sync
-
-
-
- )}
-
- {/* Access URL */}
- {details.outputs?.access_url && (
-
- {details.outputs.access_url}
-
- )}
-
- {/* Error */}
- {details.error && (
-
- {details.error}
-
- )}
-
- )
- })()}
-
- )
- })}
-
-
- )}
-
- {/* Templates - Compose */}
- {composeTemplates.length > 0 && (
-
-
-
- Compose Services
-
-
-
- {composeTemplates.map((template) => (
- toggleTemplate(template.id)}
- onCreate={() => openCreateInstanceModal(template)}
- />
- ))}
-
-
-
- )}
-
- {/* Templates - Providers */}
- {(visibleProviders.length > 0 || availableToAdd.length > 0) && (
-
-
-
- Providers
-
-
- {visibleProviders.length > 0 ? (
-
- {visibleProviders.map((template) => (
- toggleTemplate(template.id)}
- onCreate={() => openCreateInstanceModal(template)}
- />
- ))}
-
- ) : (
-
-
-
- No providers added yet
-
-
setShowAddProviderModal(true)}
- className="btn-primary flex items-center gap-2 mx-auto"
- >
-
- Add Provider
-
-
- )}
-
-
- )}
-
- {/* Wiring Board - Drag and Drop Interface */}
-
-
-
-
- Wiring
-
-
- Drag providers to connect them to service capability slots
-
-
-
-
- {
- 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 handleDeployInstance(providerId)
- }}
- onStopProvider={async (providerId, isTemplate) => {
- if (isTemplate) {
- return
- }
- await handleUndeployInstance(providerId)
- }}
- onEditConsumer={handleEditConsumer}
- onStartConsumer={handleStartConsumer}
- onStopConsumer={handleStopConsumer}
- />
-
-
-
- {/* Unified Create Instance Modal (used by both + button and drag-drop) */}
-
setCreateInstanceState({ ...createInstanceState, isOpen: false })}
- title={createInstanceState.wiring ? 'Connect Provider' : 'Create Instance'}
- titleIcon={createInstanceState.wiring ? : }
- maxWidth="lg"
- testId="create-instance-modal"
- >
- {createInstanceState.template && (
-
- {/* Wiring connection visual (only shown for drag-drop path) */}
- {createInstanceState.wiring && (
-
-
-
- {createInstanceState.template.name}
-
-
{createInstanceState.wiring.capability}
-
-
-
-
- {createInstanceState.wiring.consumerName}
-
-
{createInstanceState.wiring.capability} slot
-
-
- )}
-
- {/* Template info (only shown for + button path) */}
- {!createInstanceState.wiring && (
-
-
-
- {createInstanceState.template.source === 'compose' ? (
-
- ) : (
-
- )}
-
- {createInstanceState.template.name}
-
-
-
{createInstanceState.template.description}
-
-
- )}
-
- {/* Instance Name */}
-
-
- Instance Name
-
-
- setCreateInstanceState((prev) => ({
- ...prev,
- form: { ...prev.form, name: e.target.value },
- }))
- }
- className="input w-full text-sm"
- placeholder={createInstanceState.form.id}
- data-testid="create-instance-name"
- />
-
-
- {/* Config fields using ConfigFieldRow */}
- {createInstanceState.template.config_schema && createInstanceState.template.config_schema.length > 0 && (
-
-
- Provider Settings
-
-
- {createInstanceState.template.config_schema.map((field: any) => (
-
- setCreateInstanceState((prev) => ({
- ...prev,
- form: {
- ...prev.form,
- config: { ...prev.form.config, [field.key]: value },
- },
- }))
- }
- />
- ))}
-
-
- )}
-
- {/* Help text */}
-
- {createInstanceState.wiring
- ? 'Instance will be created and connected to the service slot.'
- : 'Leave fields blank to use default settings. Only modified values will be stored.'}
-
-
- {/* Modal Footer */}
-
-
setCreateInstanceState({ ...createInstanceState, isOpen: false })}
- className="btn-secondary"
- >
- Cancel
-
-
- {creating === createInstanceState.template.id ? (
-
- ) : (
-
- )}
- {createInstanceState.wiring ? 'Create & Connect' : 'Create Instance'}
-
-
-
- )}
-
-
- {/* Edit Provider/Instance Modal */}
-
setEditingProvider(null)}
- title={editingProvider?.isTemplate ? 'Edit Provider' : 'Edit Instance'}
- titleIcon={ }
- maxWidth="lg"
- testId="edit-provider-modal"
- >
- {editingProvider && editingProvider.template && (
-
- {/* Provider/Instance name */}
-
-
- {editingProvider.name}
-
-
- {editingProvider.isTemplate ? 'Default provider' : 'Custom instance'}
-
-
-
- {/* Config fields */}
- {editingProvider.template.config_schema && editingProvider.template.config_schema.length > 0 && (
-
- {editingProvider.template.config_schema.map((field: any) => (
-
- setEditConfig((prev) => ({
- ...prev,
- [field.key]: value,
- }))
- }
- />
- ))}
-
- )}
-
- {/* Footer */}
-
- setEditingProvider(null)} className="btn-secondary">
- Cancel
-
-
- {isSavingEdit ? (
-
- ) : (
-
- )}
- Save Changes
-
-
-
- )}
-
-
- {/* Add Provider Modal */}
-
setShowAddProviderModal(false)}
- title="Add Provider"
- maxWidth="md"
- testId="add-provider-modal"
- >
-
- {availableToAdd.map((template) => (
-
handleAddProvider(template.id)}
- className="w-full p-3 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-colors text-left flex items-center justify-between"
- data-testid={`add-provider-${template.id}`}
- >
-
-
- {template.mode === 'local' ? (
-
- ) : (
-
- )}
-
-
-
- {template.name}
-
-
- {template.provides}
-
-
-
-
-
- ))}
-
-
-
- setShowAddProviderModal(false)}
- className="btn-secondary"
- >
- Cancel
-
-
-
-
- {/* Confirmation Dialog */}
-
setConfirmDialog({ isOpen: false, instanceId: null })}
- />
-
- )
-}
-
-// =============================================================================
-// Config Field Row Component (matches ServicesPage EnvVarEditor style)
-// =============================================================================
-
-interface ConfigFieldRowProps {
- field: {
- key: string
- label?: string
- type?: string
- required?: boolean
- has_value?: boolean
- value?: string
- default?: string
- settings_path?: string
- }
- value: string
- onChange: (value: string) => void
- readOnly?: boolean
-}
-
-function ConfigFieldRow({ field, value, onChange, readOnly: _readOnly = false }: ConfigFieldRowProps) {
- const [editing, setEditing] = useState(false)
- const [showMapping, setShowMapping] = useState(false)
-
- const isSecret = field.type === 'secret'
- const isRequired = field.required
- const hasDefault = field.has_value || field.default
- const defaultValue = field.value || field.default || ''
- const hasOverride = value && value.trim() !== ''
- const isUsingDefault = hasDefault && !hasOverride
- const needsValue = isRequired && !hasDefault && !hasOverride
-
- return (
-
- {/* Label with required indicator */}
-
- {isRequired && * }
- {field.label || field.key}
- {needsValue && (
- (missing)
- )}
-
-
- {/* Map button */}
- {field.settings_path && (
-
setShowMapping(!showMapping)}
- className={`px-2 py-1 text-xs rounded transition-colors flex-shrink-0 ${
- showMapping
- ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-300'
- : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
- }`}
- title={showMapping ? 'Enter value' : 'Map to setting'}
- >
- Map
-
- )}
-
- {/* Input area */}
-
- {showMapping && field.settings_path ? (
- // Mapping mode - show setting path
-
- {field.settings_path}
-
- ) : isUsingDefault && !editing ? (
- // Default value display
- <>
-
setEditing(true)}
- className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 flex-shrink-0"
- title="Edit"
- >
-
-
-
- {isSecret ? '••••••••' : defaultValue}
-
-
- default
-
- >
- ) : (
- // Value input
- <>
-
onChange(e.target.value)}
- placeholder={isSecret ? '••••••••' : defaultValue || 'enter value'}
- className="flex-1 px-2 py-1.5 text-xs rounded border-0 bg-neutral-100 dark:bg-neutral-700/50 text-neutral-900 dark:text-neutral-200 focus:outline-none focus:ring-1 focus:ring-primary-500 placeholder:text-neutral-400 dark:placeholder:text-neutral-500"
- autoFocus={editing}
- onBlur={() => {
- if (!value && hasDefault) setEditing(false)
- }}
- data-testid={`config-field-${field.key}`}
- />
- {hasOverride && (
-
- override
-
- )}
- >
- )}
-
-
- )
-}
-
-// =============================================================================
-// 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 && (
-
{
- e.stopPropagation()
- onRemove()
- }}
- className="p-1.5 text-neutral-400 hover:text-error-600 dark:hover:text-error-400 rounded"
- title="Remove"
- data-testid={`remove-template-${template.id}`}
- >
-
-
- )}
-
-
- )}
-
- )
-}
diff --git a/ushadow/frontend/src/pages/InterfacesPage.tsx b/ushadow/frontend/src/pages/InterfacesPage.tsx
new file mode 100644
index 00000000..b6d21e7a
--- /dev/null
+++ b/ushadow/frontend/src/pages/InterfacesPage.tsx
@@ -0,0 +1,413 @@
+import { useState, useEffect } from 'react'
+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'
+
+export default function InterfacesPage() {
+ // Tab state
+ const [activeTab, setActiveTab] = useState('providers')
+
+ // 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 = [
+ { id: 'providers' as TabId, label: 'Providers', icon: Cloud },
+ { id: 'services' as TabId, label: 'Services', icon: Server },
+ { id: 'deployed' as TabId, label: 'Deployed', icon: Layers },
+ ]
+
+ // Load initial data
+ useEffect(() => {
+ loadData()
+ }, [])
+
+ // Deep linking - support hash-based tab navigation
+ useEffect(() => {
+ const hash = window.location.hash.slice(1)
+ if (['providers', 'services', 'deployed'].includes(hash)) {
+ setActiveTab(hash as TabId)
+ }
+ }, [])
+
+ // Update hash when tab changes
+ useEffect(() => {
+ window.location.hash = activeTab
+ }, [activeTab])
+
+ const loadData = async () => {
+ try {
+ setLoading(true)
+ const [capsResponse, servicesResponse, instancesResponse] = await Promise.all([
+ providersApi.getCapabilities(),
+ servicesApi.getInstalled(),
+ svcConfigsApi.getServiceConfigs(),
+ ])
+
+ 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' })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ 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 (
+
+
+
+
Loading interfaces...
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Interfaces
+
+
+ Manage providers, services, and deployments
+
+
+
+
+
+
+
+
+
+ {/* Message */}
+ {message && (
+
+
+
+
{message.text}
+
setMessage(null)} className="ml-auto">
+
+
+
+
+ )}
+
+ {/* Tabs */}
+
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.id)}
+ data-testid={`tab-${tab.id}`}
+ className={`
+ flex items-center space-x-2 px-4 py-3 font-medium transition-all
+ ${activeTab === tab.id
+ ? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-600 dark:border-primary-400'
+ : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100'
+ }
+ `}
+ >
+
+ {tab.label}
+
+ ))}
+
+
+
+ {/* Tab Content - Providers */}
+ {activeTab === 'providers' && (
+
+
+ Capability Providers
+
+
+ Providers tab content - showing {capabilities.length} capabilities
+
+
+ )}
+
+ {/* Tab Content - Services */}
+ {activeTab === 'services' && (
+
+
+ Compose Services
+
+
+ Services tab content - showing {services.length} services
+
+
+ )}
+
+ {/* Tab Content - Deployed */}
+ {activeTab === 'deployed' && (
+
+
+ Deployed Service Instances
+
+
+ Deployed tab content - showing {deployedInstances.length} instances
+
+
+ )}
+
+ )
+}
diff --git a/ushadow/frontend/src/pages/KubernetesClustersPage.tsx b/ushadow/frontend/src/pages/KubernetesClustersPage.tsx
index 1c20f1ea..5abdc6b9 100644
--- a/ushadow/frontend/src/pages/KubernetesClustersPage.tsx
+++ b/ushadow/frontend/src/pages/KubernetesClustersPage.tsx
@@ -1,7 +1,24 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
-import { Server, Plus, RefreshCw, Trash2, CheckCircle, XCircle, Clock, Upload, X } from 'lucide-react'
+import { Server, Plus, RefreshCw, Trash2, CheckCircle, XCircle, Clock, Upload, X, Search, Database, AlertCircle, Rocket } from 'lucide-react'
import { kubernetesApi, KubernetesCluster } from '../services/api'
+import Modal from '../components/Modal'
+import ConfirmDialog from '../components/ConfirmDialog'
+import DeployToK8sModal from '../components/DeployToK8sModal'
+
+interface InfraService {
+ found: boolean
+ endpoints: string[]
+ type: string
+ default_port: number
+ error?: string
+}
+
+interface InfraScanResults {
+ cluster_id: string
+ namespace: string
+ infra_services: Record
+}
export default function KubernetesClustersPage() {
const [clusters, setClusters] = useState([])
@@ -9,6 +26,17 @@ export default function KubernetesClustersPage() {
const [showAddModal, setShowAddModal] = useState(false)
const [adding, setAdding] = useState(false)
+ // Infrastructure scanning
+ const [scanningClusterId, setScanningClusterId] = useState(null)
+ const [scanResults, setScanResults] = useState>({})
+ const [showScanResults, setShowScanResults] = useState(null)
+ const [showNamespaceSelector, setShowNamespaceSelector] = useState(null)
+ const [scanNamespace, setScanNamespace] = useState('')
+
+ // Deployment
+ const [showDeployModal, setShowDeployModal] = useState(false)
+ const [selectedClusterForDeploy, setSelectedClusterForDeploy] = useState(null)
+
// Form state
const [clusterName, setClusterName] = useState('')
const [kubeconfig, setKubeconfig] = useState('')
@@ -24,6 +52,21 @@ export default function KubernetesClustersPage() {
setError(null)
const response = await kubernetesApi.listClusters()
setClusters(response.data)
+
+ // Load cached scan results from clusters
+ const cachedScans: Record = {}
+ response.data.forEach((cluster: KubernetesCluster) => {
+ if (cluster.infra_scans) {
+ Object.entries(cluster.infra_scans).forEach(([namespace, scanData]) => {
+ cachedScans[`${cluster.cluster_id}-${namespace}`] = {
+ cluster_id: cluster.cluster_id,
+ namespace: namespace,
+ infra_services: scanData as Record
+ }
+ })
+ }
+ })
+ setScanResults(cachedScans)
} catch (err: any) {
console.error('Error loading clusters:', err)
setError(err.response?.data?.detail || 'Failed to load clusters')
@@ -68,12 +111,55 @@ export default function KubernetesClustersPage() {
try {
await kubernetesApi.removeCluster(clusterId)
+ // Remove scan results for this cluster
+ const newScanResults = { ...scanResults }
+ delete newScanResults[clusterId]
+ setScanResults(newScanResults)
loadClusters()
} catch (err: any) {
alert(`Failed to remove cluster: ${err.response?.data?.detail || err.message}`)
}
}
+ const handleScanInfrastructure = async (clusterId: string, namespace?: string) => {
+ try {
+ setScanningClusterId(clusterId)
+ setError(null)
+
+ const cluster = clusters.find(c => c.cluster_id === clusterId)
+ const namespaceToScan = namespace || cluster?.namespace || 'default'
+
+ const response = await kubernetesApi.scanInfraServices(clusterId, namespaceToScan)
+
+ // Store scan results
+ setScanResults(prev => ({
+ ...prev,
+ [`${clusterId}-${namespaceToScan}`]: response.data
+ }))
+
+ // Show results modal
+ setShowScanResults(`${clusterId}-${namespaceToScan}`)
+ setShowNamespaceSelector(null)
+ setScanNamespace('')
+ } catch (err: any) {
+ console.error('Error scanning infrastructure:', err)
+ alert(`Failed to scan infrastructure: ${err.response?.data?.detail || err.message}`)
+ } finally {
+ setScanningClusterId(null)
+ }
+ }
+
+ const handleOpenNamespaceSelector = (clusterId: string) => {
+ const cluster = clusters.find(c => c.cluster_id === clusterId)
+ setScanNamespace(cluster?.namespace || 'ushadow')
+ setShowNamespaceSelector(clusterId)
+ }
+
+ const handleOpenDeployModal = (cluster: KubernetesCluster) => {
+ setSelectedClusterForDeploy(cluster)
+ setShowDeployModal(true)
+ }
+
const handleFileUpload = (e: React.ChangeEvent) => {
const file = e.target.files?.[0]
if (file) {
@@ -105,14 +191,122 @@ export default function KubernetesClustersPage() {
}
}
+ const renderInfraScanResults = (clusterId: string) => {
+ const results = scanResults[clusterId]
+ if (!results) return null
+
+ const foundServices = Object.entries(results.infra_services).filter(([_, service]) => service.found)
+ const notFoundServices = Object.entries(results.infra_services).filter(([_, service]) => !service.found)
+
+ return (
+ setShowScanResults(null)}
+ title="Infrastructure Scan Results"
+ maxWidth="lg"
+ testId="infra-scan-results-modal"
+ >
+
+
+
+ Scanned namespace: {results.namespace}
+
+
+
+ {/* Found Services */}
+ {foundServices.length > 0 && (
+
+
+
+ Found Infrastructure ({foundServices.length})
+
+
+ {foundServices.map(([name, service]) => (
+
+
+
+ {name}
+
+
+ Running
+
+
+ {service.endpoints.length > 0 && (
+
+
Connection endpoints:
+ {service.endpoints.map((endpoint, idx) => (
+
+ {endpoint}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Not Found Services */}
+ {notFoundServices.length > 0 && (
+
+
+
+ Not Found ({notFoundServices.length})
+
+
+ {notFoundServices.map(([name, service]) => (
+
+
{name}
+
+ Not running in {results.namespace}
+
+
+ ))}
+
+
+ )}
+
+ {/* Help Text */}
+
+
+ Next steps: You can use existing infrastructure services when deploying applications,
+ or deploy your own infrastructure using the unified deployment UI.
+
+
+
+ {/* Actions */}
+
+ setShowScanResults(null)}
+ className="btn-primary"
+ data-testid="close-scan-results"
+ >
+ Done
+
+
+
+
+ )
+ }
+
return (
-
+
{/* Header */}
Kubernetes Clusters
- Manage Kubernetes clusters for service deployment
+ Configure clusters and scan infrastructure for deployments
0 && (
- {clusters.map((cluster) => (
-
- {/* Header */}
-
-
-
-
-
-
-
- {cluster.name}
-
-
- {cluster.context}
-
+ {clusters.map((cluster) => {
+ // Get all scanned namespaces for this cluster
+ const scannedKeys = Object.keys(scanResults).filter(key => key.startsWith(cluster.cluster_id))
+ const hasScanned = scannedKeys.length > 0
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {cluster.name}
+
+
+ {cluster.context}
+
+
+ {getStatusIcon(cluster.status)}
- {getStatusIcon(cluster.status)}
-
- {/* Server */}
-
- Server:
-
- {cluster.server}
-
-
-
- {/* Info */}
- {cluster.version && (
-
- K8s {cluster.version} | {cluster.node_count} nodes | namespace: {cluster.namespace}
+ {/* Server */}
+
+ Server:
+
+ {cluster.server}
+
- )}
- {/* Labels */}
- {Object.keys(cluster.labels).length > 0 && (
-
- {Object.entries(cluster.labels).map(([key, value]) => (
-
+ K8s {cluster.version} | {cluster.node_count} nodes | namespace: {cluster.namespace}
+
+ )}
+
+ {/* Infrastructure Status */}
+ {hasScanned && (
+
+ {scannedKeys.map(key => {
+ const results = scanResults[key]
+ const namespace = results.namespace
+ const foundInfra = Object.values(results.infra_services).filter(s => s.found).length
+
+ return (
+
+
+
+
+
+ {foundInfra} in {namespace}
+
+
+
setShowScanResults(key)}
+ className="text-xs text-success-600 dark:text-success-400 hover:underline"
+ data-testid={`view-scan-results-${key}`}
+ >
+ View
+
+
+
+ )
+ })}
+
+ )}
+
+ {/* Labels */}
+ {Object.keys(cluster.labels).length > 0 && (
+
+ {Object.entries(cluster.labels).map(([key, value]) => (
+
+ {key}: {value}
+
+ ))}
+
+ )}
+
+ {/* Actions */}
+
+
+ handleOpenNamespaceSelector(cluster.cluster_id)}
+ disabled={scanningClusterId === cluster.cluster_id || cluster.status !== 'connected'}
+ className="btn-secondary flex items-center space-x-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
+ data-testid={`scan-infra-${cluster.cluster_id}`}
>
- {key}: {value}
-
- ))}
-
- )}
+ {scanningClusterId === cluster.cluster_id ? (
+ <>
+
+
Scanning...
+ >
+ ) : (
+ <>
+
+
Scan
+ >
+ )}
+
+
+
handleOpenDeployModal(cluster)}
+ disabled={cluster.status !== 'connected'}
+ className="btn-primary flex items-center space-x-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
+ data-testid={`deploy-services-${cluster.cluster_id}`}
+ >
+
+ Deploy
+
+
- {/* Actions */}
-
- handleRemoveCluster(cluster.cluster_id)}
- className="p-2 text-neutral-600 dark:text-neutral-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded transition-colors"
- title="Remove cluster"
- >
-
-
+ handleRemoveCluster(cluster.cluster_id)}
+ className="p-2 text-neutral-600 dark:text-neutral-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded transition-colors"
+ title="Remove cluster"
+ data-testid={`remove-cluster-${cluster.cluster_id}`}
+ >
+
+
+
-
- ))}
+ )
+ })}
)}
@@ -235,6 +498,78 @@ export default function KubernetesClustersPage() {
)}
+ {/* Namespace Selector Modal */}
+ {showNamespaceSelector && (
+
setShowNamespaceSelector(null)}
+ title="Select Namespace to Scan"
+ maxWidth="md"
+ testId="namespace-selector-modal"
+ >
+
+
+ Choose which namespace to scan for infrastructure services.
+
+
+
+
+ Namespace
+
+
setScanNamespace(e.target.value)}
+ placeholder="e.g., ushadow, default, kube-system"
+ className="w-full px-4 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100"
+ data-testid="scan-namespace-input"
+ />
+
+ Common namespaces: ushadow, default, kube-system
+
+
+
+
+ setShowNamespaceSelector(null)}
+ className="btn-secondary"
+ >
+ Cancel
+
+ handleScanInfrastructure(showNamespaceSelector, scanNamespace)}
+ disabled={!scanNamespace.trim()}
+ className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
+ data-testid="confirm-scan-btn"
+ >
+
+ Scan {scanNamespace || 'Namespace'}
+
+
+
+
+ )}
+
+ {/* Scan Results Modal */}
+ {showScanResults && renderInfraScanResults(showScanResults)}
+
+ {/* Deploy to K8s Modal */}
+ {showDeployModal && selectedClusterForDeploy && (
+
{
+ setShowDeployModal(false)
+ setSelectedClusterForDeploy(null)
+ }}
+ 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
+ : undefined
+ }
+ />
+ )}
+
{/* Add Cluster Modal */}
{showAddModal && createPortal(
@@ -291,10 +626,13 @@ export default function KubernetesClustersPage() {
type="text"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
- placeholder="default"
+ placeholder="ushadow"
className="w-full px-4 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100"
data-testid="namespace-input"
/>
+
+ Recommended: ushadow
+
{/* Kubeconfig Upload */}
diff --git a/ushadow/frontend/src/pages/N8NPage.tsx b/ushadow/frontend/src/pages/N8NPage.tsx
index bd735250..ed4f0147 100644
--- a/ushadow/frontend/src/pages/N8NPage.tsx
+++ b/ushadow/frontend/src/pages/N8NPage.tsx
@@ -57,7 +57,7 @@ export default function N8NPage() {
- n8n Instance
+ n8n ServiceConfig
err.msg || String(err)).join(', ')
+ }
+
+ // Handle string detail
+ if (typeof detail === 'string') {
+ return detail
+ }
+
+ // Fallback
+ return fallback
+}
+
+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)
+ const [wiring, setWiring] = useState([])
+
+ // Service status state for consumers
+ const [serviceStatuses, setServiceStatuses] = useState>({})
+
+ // UI state
+ const [loading, setLoading] = useState(true)
+ const [creating, setCreating] = useState(null)
+ const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
+ const [confirmDialog, setConfirmDialog] = useState<{
+ isOpen: boolean
+ instanceId: string | null
+ }>({ isOpen: false, instanceId: null })
+ const [showAddProviderModal, setShowAddProviderModal] = useState(false)
+ // Track providers user has added (even if not yet configured)
+ const [addedProviderIds, setAddedProviderIds] = useState>(new Set())
+
+ // Edit modal state - for editing provider templates or instances from wiring board
+ const [editingProvider, setEditingProvider] = useState<{
+ id: string
+ name: string
+ isTemplate: boolean
+ template: Template | null
+ config: Record
+ } | null>(null)
+ const [editConfig, setEditConfig] = useState>({})
+ const [isSavingEdit, setIsSavingEdit] = useState(false)
+
+ // Environment variable configuration state (for instance editing)
+ const [envVars, setEnvVars] = useState([])
+ const [envConfigs, setEnvConfigs] = useState>({})
+ const [loadingEnvConfig, setLoadingEnvConfig] = useState(false)
+
+ // Deploy modal state
+ const [deployModalState, setDeployModalState] = useState<{
+ isOpen: boolean
+ serviceId: string | null
+ 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 [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)
+ const [catalogServices, setCatalogServices] = useState([])
+ const [catalogLoading, setCatalogLoading] = useState(false)
+ const [installingService, setInstallingService] = useState(null)
+
+ // ESC key to close modals
+ const closeAllModals = useCallback(() => {
+ setShowAddProviderModal(false)
+ setEditingProvider(null)
+ setShowCatalog(false)
+ }, [])
+
+ useEffect(() => {
+ const handleEsc = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ closeAllModals()
+ }
+ }
+ window.addEventListener('keydown', handleEsc)
+ return () => window.removeEventListener('keydown', handleEsc)
+ }, [closeAllModals])
+
+ // Load initial data
+ useEffect(() => {
+ loadData()
+ }, [])
+
+ const loadData = async () => {
+ try {
+ setLoading(true)
+ const [templatesRes, instancesRes, wiringRes, statusesRes] = await Promise.all([
+ svcConfigsApi.getTemplates(),
+ svcConfigsApi.getServiceConfigs(),
+ svcConfigsApi.getWiring(),
+ servicesApi.getAllStatuses().catch(() => ({ data: {} })),
+ ])
+
+ console.log('Templates loaded:', templatesRes.data)
+ console.log('Compose templates (before filter):', templatesRes.data.filter((t: any) => t.source === 'compose'))
+ console.log('Compose templates (after installed filter):', templatesRes.data.filter((t: any) => t.source === 'compose' && t.installed))
+
+ setTemplates(templatesRes.data)
+ setServiceConfigs(instancesRes.data)
+ setWiring(wiringRes.data)
+ setServiceStatuses(statusesRes.data || {})
+
+ // 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' })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Service catalog functions
+ const openCatalog = async () => {
+ console.log('Opening catalog...')
+ setShowCatalog(true)
+ setCatalogLoading(true)
+ try {
+ const response = await servicesApi.getCatalog()
+ console.log('Catalog response:', response.data)
+ setCatalogServices(response.data)
+ } catch (error: any) {
+ console.error('Catalog error:', error)
+ setMessage({ type: 'error', text: 'Failed to load service catalog' })
+ } finally {
+ setCatalogLoading(false)
+ }
+ }
+
+ const handleInstallService = async (serviceId: string) => {
+ setInstallingService(serviceId)
+ try {
+ await servicesApi.install(serviceId)
+ // Reload templates and catalog
+ const [templatesRes, catalogRes] = await Promise.all([
+ svcConfigsApi.getTemplates(),
+ servicesApi.getCatalog()
+ ])
+ setTemplates(templatesRes.data)
+ setCatalogServices(catalogRes.data)
+ setMessage({ type: 'success', text: 'Service installed successfully' })
+ } catch (error: any) {
+ setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to install service' })
+ } finally {
+ setInstallingService(null)
+ }
+ }
+
+ const handleUninstallService = async (serviceId: string) => {
+ setInstallingService(serviceId)
+ try {
+ await servicesApi.uninstall(serviceId)
+ // Reload templates and catalog
+ const [templatesRes, catalogRes] = await Promise.all([
+ svcConfigsApi.getTemplates(),
+ servicesApi.getCatalog()
+ ])
+ setTemplates(templatesRes.data)
+ setCatalogServices(catalogRes.data)
+ setMessage({ type: 'success', text: 'Service uninstalled successfully' })
+ } catch (error: any) {
+ setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to uninstall service' })
+ } finally {
+ setInstallingService(null)
+ }
+ }
+
+ // 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
+
+ setConfirmDialog({ isOpen: false, instanceId: null })
+
+ try {
+ await svcConfigsApi.deleteServiceConfig(instanceId)
+ setMessage({ type: 'success', text: 'ServiceConfig deleted' })
+
+ // Reload instances
+ const instancesRes = await svcConfigsApi.getServiceConfigs()
+ setServiceConfigs(instancesRes.data)
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || 'Failed to delete instance',
+ })
+ }
+ }
+
+ // Deploy/undeploy actions
+ const [deployingServiceConfig, setDeployingServiceConfig] = useState(null)
+
+ // Integration sync state
+ const [syncingServiceConfig, setSyncingServiceConfig] = useState(null)
+ const [testingConnection, setTestingConnection] = useState(null)
+ const [togglingAutoSync, setTogglingAutoSync] = useState(null)
+
+ const handleDeployServiceConfig = async (instanceId: string) => {
+ setDeployingServiceConfig(instanceId)
+ try {
+ await svcConfigsApi.deployServiceConfig(instanceId)
+ setMessage({ type: 'success', text: 'ServiceConfig started' })
+ // Reload instances
+ const instancesRes = await svcConfigsApi.getServiceConfigs()
+ setServiceConfigs(instancesRes.data)
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || 'Failed to start instance',
+ })
+ } finally {
+ setDeployingServiceConfig(null)
+ }
+ }
+
+ const handleUndeployServiceConfig = async (instanceId: string) => {
+ setDeployingServiceConfig(instanceId)
+ try {
+ await svcConfigsApi.undeployServiceConfig(instanceId)
+ setMessage({ type: 'success', text: 'ServiceConfig stopped' })
+ // Reload instances
+ const instancesRes = await svcConfigsApi.getServiceConfigs()
+ setServiceConfigs(instancesRes.data)
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || 'Failed to stop instance',
+ })
+ } finally {
+ setDeployingServiceConfig(null)
+ }
+ }
+
+ // Integration actions
+ const handleTestConnection = async (instanceId: string) => {
+ setTestingConnection(instanceId)
+ try {
+ const response = await integrationApi.testConnection(instanceId)
+ setMessage({
+ type: response.data.success ? 'success' : 'error',
+ text: response.data.message,
+ })
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: getErrorMessage(error, 'Failed to test connection'),
+ })
+ } finally {
+ setTestingConnection(null)
+ }
+ }
+
+ const handleSyncNow = async (instanceId: string) => {
+ setSyncingServiceConfig(instanceId)
+ try {
+ const response = await integrationApi.syncNow(instanceId)
+ if (response.data.success) {
+ setMessage({
+ type: 'success',
+ text: `Synced ${response.data.items_synced} items`,
+ })
+ // Reload instance details to show updated sync status
+ const res = await svcConfigsApi.getServiceConfig(instanceId)
+ setServiceConfigDetails((prev) => ({ ...prev, [instanceId]: res.data }))
+ } else {
+ setMessage({
+ type: 'error',
+ text: response.data.error || 'Sync failed',
+ })
+ }
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: getErrorMessage(error, 'Failed to sync'),
+ })
+ } finally {
+ setSyncingServiceConfig(null)
+ }
+ }
+
+ const handleToggleAutoSync = async (instanceId: string, enable: boolean) => {
+ setTogglingAutoSync(instanceId)
+ try {
+ const response = enable
+ ? await integrationApi.enableAutoSync(instanceId)
+ : await integrationApi.disableAutoSync(instanceId)
+
+ setMessage({
+ type: response.data.success ? 'success' : 'error',
+ text: response.data.message,
+ })
+
+ // Reload instance details to show updated auto-sync status
+ const res = await svcConfigsApi.getServiceConfig(instanceId)
+ setServiceConfigDetails((prev) => ({ ...prev, [instanceId]: res.data }))
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: getErrorMessage(error, 'Failed to toggle auto-sync'),
+ })
+ } finally {
+ setTogglingAutoSync(null)
+ }
+ }
+
+ // Consumer/Service handlers for WiringBoard
+ const handleStartConsumer = async (consumerId: string) => {
+ try {
+ // Extract service name from template ID (format: "compose_file:service_name")
+ const serviceName = consumerId.includes(':') ? consumerId.split(':').pop()! : consumerId
+ await servicesApi.startService(serviceName)
+ setMessage({ type: 'success', text: `${consumerId} started` })
+ // Reload service statuses
+ const statusesRes = await servicesApi.getAllStatuses()
+ setServiceStatuses(statusesRes.data || {})
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || `Failed to start ${consumerId}`,
+ })
+ }
+ }
+
+ const handleStopConsumer = async (consumerId: string) => {
+ try {
+ // Extract service name from template ID (format: "compose_file:service_name")
+ const serviceName = consumerId.includes(':') ? consumerId.split(':').pop()! : consumerId
+ await servicesApi.stopService(serviceName)
+ setMessage({ type: 'success', text: `${consumerId} stopped` })
+ // Reload service statuses
+ const statusesRes = await servicesApi.getAllStatuses()
+ setServiceStatuses(statusesRes.data || {})
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || `Failed to stop ${consumerId}`,
+ })
+ }
+ }
+
+ const handleDeployConsumer = async (consumerId: string, target: { type: 'local' | 'remote' | 'kubernetes'; id?: string }) => {
+ // Get the consumer instance to find its template_id
+ const consumerInstance = instances.find(inst => inst.id === consumerId)
+ if (!consumerInstance) {
+ setMessage({ type: 'error', text: `Service instance ${consumerId} not found` })
+ return
+ }
+
+ // For Kubernetes, load available clusters first
+ if (target.type === 'kubernetes') {
+ setLoadingClusters(true)
+ try {
+ 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: consumerInstance.template_id,
+ targetType: target.type,
+ selectedClusterId: cluster.cluster_id,
+ infraServices: infraData,
+ })
+ } else {
+ // Multiple clusters - need to show cluster selection
+ // Infrastructure will be loaded when cluster is selected in modal
+ setDeployModalState({
+ isOpen: true,
+ serviceId: consumerInstance.template_id,
+ targetType: target.type,
+ })
+ }
+ } catch (err) {
+ console.error('Failed to load K8s clusters:', err)
+ setMessage({ type: 'error', text: 'Failed to load Kubernetes clusters' })
+ } finally {
+ setLoadingClusters(false)
+ }
+ } else if (target.type === 'local' || target.type === 'remote') {
+ // Show deploy confirmation modal with env vars
+ setSimpleDeployModal({
+ isOpen: true,
+ serviceId: consumerId,
+ targetType: target.type,
+ targetId: target.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
+ }
+ })
+ setDeployEnvConfigs(formData)
+ } catch (error) {
+ console.error('Failed to load env config:', error)
+ } finally {
+ 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
+ const template = templates.find((t) => t.id === consumerId)
+ 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]
+ setEnvVars(allEnvVars)
+
+ // Load wiring connections for this service to get provider-supplied values
+ const wiringConnections = wiring.filter((w) => w.target_config_id === consumerId)
+ const providerSuppliedValues: Record = {}
+
+ // For each wiring connection, determine which env vars it supplies
+ for (const conn of wiringConnections) {
+ const provider = [...templates, ...instances].find((p) => p.id === conn.source_config_id)
+ if (provider) {
+ // Get the capability mapping (e.g., "mongodb" -> MONGODB_URL, MONGODB_NAME)
+ const capability = conn.source_capability
+
+ // Map capability to env var names based on common patterns
+ if (capability === 'mongodb') {
+ providerSuppliedValues['MONGODB_URL'] = {
+ value: `Provider: ${provider.name}`,
+ provider: provider.name,
+ locked: true,
+ }
+ providerSuppliedValues['MONGODB_NAME'] = {
+ value: `Provider: ${provider.name}`,
+ provider: provider.name,
+ locked: true,
+ }
+ } else if (capability === 'memory') {
+ providerSuppliedValues['MEMORY_URL'] = {
+ value: `Provider: ${provider.name}`,
+ provider: provider.name,
+ locked: true,
+ }
+ } else if (capability === 'vector_db') {
+ providerSuppliedValues['QDRANT_URL'] = {
+ value: `Provider: ${provider.name}`,
+ provider: provider.name,
+ locked: true,
+ }
+ } else if (capability === 'redis') {
+ providerSuppliedValues['REDIS_URL'] = {
+ value: `Provider: ${provider.name}`,
+ provider: provider.name,
+ locked: true,
+ }
+ } else if (capability === 'llm') {
+ providerSuppliedValues['OPENAI_API_KEY'] = {
+ value: `Provider: ${provider.name}`,
+ provider: provider.name,
+ locked: true,
+ }
+ }
+ }
+ }
+
+ // Initialize env configs from API response, overriding with provider values
+ const initialEnvConfigs: Record = {}
+ allEnvVars.forEach((envVar) => {
+ const providerValue = providerSuppliedValues[envVar.name]
+
+ if (providerValue) {
+ // This value comes from a wired provider - mark as locked
+ initialEnvConfigs[envVar.name] = {
+ name: envVar.name,
+ source: 'literal',
+ value: providerValue.value,
+ setting_path: undefined,
+ new_setting_path: undefined,
+ locked: true,
+ provider_name: providerValue.provider,
+ }
+ } else {
+ // Use API response data (setting mapping or default)
+ initialEnvConfigs[envVar.name] = {
+ name: envVar.name,
+ source: (envVar.source as 'setting' | 'new_setting' | 'literal' | 'default') || 'default',
+ setting_path: envVar.setting_path,
+ value: envVar.value,
+ new_setting_path: undefined,
+ }
+ }
+ })
+
+ setEnvConfigs(initialEnvConfigs)
+
+ // Open edit modal with service template
+ setEditingProvider({
+ id: template.id,
+ name: template.name,
+ isTemplate: true,
+ template,
+ config: {},
+ })
+ setEditConfig({})
+ } catch (err) {
+ console.error('Failed to load service env config:', err)
+ setMessage({ type: 'error', text: 'Failed to load service configuration' })
+ } finally {
+ setLoadingEnvConfig(false)
+ }
+ }
+
+ // Transform data for WiringBoard
+ // Providers: provider templates (both configured and unconfigured) + custom instances
+ const providerTemplates = templates
+ .filter((t) => t.source === 'provider' && t.provides)
+
+ 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'
+ }
+
+ // 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}`
+ }
+ }
+
+ 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,
+ })
+ })
+
+ // 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
+ }
+
+ // 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}`
+ }
+ }
+
+ 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
+ }
+
+ // 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,
+ })
+ }
+ }
+
+ 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
+ }
+ }
+
+ // Handle edit provider/instance from wiring board
+ const handleEditProviderFromBoard = async (providerId: string, isTemplate: boolean) => {
+ if (isTemplate) {
+ // Edit template - open modal with template config
+ const template = templates.find((t) => t.id === providerId)
+ if (template) {
+ // Pre-populate config from template values
+ const initialConfig: Record = {}
+ template.config_schema?.forEach((field: any) => {
+ if (field.value) {
+ initialConfig[field.key] = field.value
+ }
+ })
+
+ setEditingProvider({
+ id: providerId,
+ name: template.name,
+ isTemplate: true,
+ template,
+ config: initialConfig,
+ })
+ setEditConfig(initialConfig)
+ setEnvVars([])
+ setEnvConfigs({})
+ }
+ } else {
+ // Edit instance - fetch details and load environment configuration
+ try {
+ let details = instanceDetails[providerId]
+ if (!details) {
+ const res = await svcConfigsApi.getServiceConfig(providerId)
+ details = res.data
+ setServiceConfigDetails((prev) => ({ ...prev, [providerId]: details }))
+ }
+
+ const instance = instances.find((i) => i.id === providerId)
+ const template = templates.find((t) => t.id === instance?.template_id)
+
+ if (details && template) {
+ const initialConfig = details.config?.values || {}
+ setEditingProvider({
+ id: providerId,
+ name: details.name,
+ isTemplate: false,
+ template,
+ config: initialConfig as Record,
+ })
+ setEditConfig(initialConfig as Record)
+
+ // Load environment variable configuration for compose services
+ if (template.source === 'compose') {
+ setLoadingEnvConfig(true)
+ try {
+ const envResponse = await servicesApi.getEnvConfig(template.id)
+ const envData = envResponse.data
+
+ const allEnvVars = [...envData.required_env_vars, ...envData.optional_env_vars]
+ setEnvVars(allEnvVars)
+
+ // Load wiring connections for this instance to get provider-supplied values
+ const wiringConnections = wiring.filter((w) => w.target_config_id === providerId)
+ const providerSuppliedValues: Record = {}
+
+ // For each wiring connection, determine which env vars it supplies
+ for (const conn of wiringConnections) {
+ const provider = [...templates, ...instances].find((p) => p.id === conn.source_config_id)
+ if (provider) {
+ const capability = conn.source_capability
+
+ // Map capability to env var names
+ if (capability === 'mongodb') {
+ providerSuppliedValues['MONGODB_URL'] = { value: `Provider: ${provider.name}`, provider: provider.name, locked: true }
+ providerSuppliedValues['MONGODB_NAME'] = { value: `Provider: ${provider.name}`, provider: provider.name, locked: true }
+ } else if (capability === 'memory') {
+ providerSuppliedValues['MEMORY_URL'] = { value: `Provider: ${provider.name}`, provider: provider.name, locked: true }
+ } else if (capability === 'vector_db') {
+ providerSuppliedValues['QDRANT_URL'] = { value: `Provider: ${provider.name}`, provider: provider.name, locked: true }
+ } else if (capability === 'redis') {
+ providerSuppliedValues['REDIS_URL'] = { value: `Provider: ${provider.name}`, provider: provider.name, locked: true }
+ } else if (capability === 'llm') {
+ providerSuppliedValues['OPENAI_API_KEY'] = { value: `Provider: ${provider.name}`, provider: provider.name, locked: true }
+ }
+ }
+ }
+
+ // Initialize env configs from API response, checking for provider overrides
+ const initialEnvConfigs: Record = {}
+ allEnvVars.forEach((envVar) => {
+ const providerValue = providerSuppliedValues[envVar.name]
+
+ if (providerValue) {
+ // This value comes from a wired provider - mark as locked
+ initialEnvConfigs[envVar.name] = {
+ name: envVar.name,
+ source: 'literal',
+ value: providerValue.value,
+ setting_path: undefined,
+ new_setting_path: undefined,
+ locked: true,
+ provider_name: providerValue.provider,
+ }
+ } else {
+ // Check if instance has an override for this var
+ const instanceValue = initialConfig[envVar.name]
+
+ if (instanceValue !== undefined) {
+ // ServiceConfig has an override
+ initialEnvConfigs[envVar.name] = {
+ name: envVar.name,
+ source: 'new_setting',
+ value: instanceValue,
+ setting_path: undefined,
+ new_setting_path: undefined,
+ }
+ } else {
+ // Use service default configuration
+ initialEnvConfigs[envVar.name] = {
+ name: envVar.name,
+ source: (envVar.source as 'setting' | 'new_setting' | 'literal' | 'default') || 'default',
+ setting_path: envVar.setting_path,
+ value: envVar.value,
+ new_setting_path: undefined,
+ }
+ }
+ }
+ })
+
+ setEnvConfigs(initialEnvConfigs)
+ } catch (err) {
+ console.error('Failed to load env config:', err)
+ setEnvVars([])
+ setEnvConfigs({})
+ } finally {
+ setLoadingEnvConfig(false)
+ }
+ } else {
+ // Provider templates use config_schema, not env config
+ setEnvVars([])
+ setEnvConfigs({})
+ }
+ }
+ } catch (err) {
+ console.error('Failed to load instance details:', err)
+ setMessage({ type: 'error', text: 'Failed to load instance details' })
+ }
+ }
+ }
+
+ // Handle save edit from modal
+ const handleSaveEdit = async () => {
+ if (!editingProvider) return
+
+ setIsSavingEdit(true)
+ try {
+ if (editingProvider.isTemplate) {
+ // For templates, check if we have env config (compose services) or config schema (providers)
+ if (envVars.length > 0) {
+ // Compose service template - save env configs to settings store
+ const envVarConfigs = Object.values(envConfigs).filter(
+ (config) => config.source === 'new_setting' && config.value && config.new_setting_path
+ )
+
+ if (envVarConfigs.length > 0) {
+ // Call the service API to update env config
+ await servicesApi.updateEnvConfig(editingProvider.template!.id, envVarConfigs)
+ setMessage({ type: 'success', text: `${editingProvider.name} configuration updated` })
+ }
+ } else {
+ // Provider template - store values in settings store via settings_path
+ // Build a nested update object from the config schema
+ const updates: Record> = {}
+ const schema = editingProvider.template?.config_schema || []
+
+ for (const field of schema) {
+ const newValue = editConfig[field.key]
+ // Only update if user provided a new value (not empty for existing secrets)
+ if (newValue && newValue.trim()) {
+ if (field.settings_path) {
+ // Parse path like "api_keys.openai_api_key" into nested object
+ const parts = field.settings_path.split('.')
+ if (parts.length === 2) {
+ const [section, key] = parts
+ if (!updates[section]) updates[section] = {}
+ updates[section][key] = newValue
+ }
+ }
+ }
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await settingsApi.update(updates)
+ }
+
+ setMessage({ type: 'success', text: `${editingProvider.name} settings updated` })
+ }
+
+ // Refresh templates to get updated values
+ const templatesRes = await svcConfigsApi.getTemplates()
+ setTemplates(templatesRes.data)
+ } else {
+ // Update instance config
+ let configToSave: Record = {}
+
+ // For compose services with env config, convert envConfigs to instance config format
+ if (envVars.length > 0) {
+ Object.entries(envConfigs).forEach(([name, config]) => {
+ if (config.source === 'setting' && config.setting_path) {
+ configToSave[name] = { _from_setting: config.setting_path }
+ } else if (config.source === 'new_setting' && config.value) {
+ configToSave[name] = config.value
+ if (config.new_setting_path) {
+ configToSave[`_save_${name}`] = config.new_setting_path
+ }
+ } else if (config.value) {
+ configToSave[name] = config.value
+ }
+ })
+ } else {
+ // For provider templates, use the simple config format
+ configToSave = Object.fromEntries(
+ Object.entries(editConfig).filter(([, v]) => v && v.trim() !== '')
+ )
+ }
+
+ await svcConfigsApi.updateServiceConfig(editingProvider.id, { config: configToSave })
+ // Refresh instance details
+ const res = await svcConfigsApi.getServiceConfig(editingProvider.id)
+ setServiceConfigDetails((prev) => ({ ...prev, [editingProvider.id]: res.data }))
+ setMessage({ type: 'success', text: `${editingProvider.name} updated` })
+ }
+ setEditingProvider(null)
+ setEnvVars([])
+ setEnvConfigs({})
+ } catch (error: any) {
+ setMessage({
+ type: 'error',
+ text: error.response?.data?.detail || 'Failed to save changes',
+ })
+ } finally {
+ setIsSavingEdit(false)
+ }
+ }
+
+ // 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')
+
+ // 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)
+ )
+
+ const handleAddProvider = (templateId: string) => {
+ setAddedProviderIds((prev) => new Set(prev).add(templateId))
+ setShowAddProviderModal(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}
+
+ )
+ }
+ }
+
+ // Render
+ if (loading) {
+ return (
+
+
+
+
Loading instances...
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
ServiceConfigs
+
+
+ Create and manage service instances from templates
+
+
+
+
+
+ Browse Services
+
+ {availableToAdd.length > 0 && (
+
setShowAddProviderModal(true)}
+ className="btn-secondary flex items-center gap-2"
+ data-testid="add-provider-button"
+ >
+
+ Add Provider
+
+ )}
+
+
+
+
+
+
+ {/* Stats */}
+
+
+
Templates
+
+ {templates.length}
+
+
+
+
ServiceConfigs
+
+ {instances.length}
+
+
+
+
Running
+
+ {instances.filter((i) => i.status === 'running').length}
+
+
+
+
Wiring
+
+ {wiring.length}
+
+
+
+
+ {/* Message */}
+ {message && (
+
+
+
+
{message.text}
+
setMessage(null)} className="ml-auto">
+
+
+
+
+ )}
+
+ {/* Wiring Board - Drag and Drop Interface */}
+
+
+
+
+ Wiring
+
+
+ Drag providers to connect them to service capability slots
+
+
+
+
+ {
+ 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}
+ />
+
+
+ {/* Edit Provider/ServiceConfig Modal */}
+
setEditingProvider(null)}
+ title={editingProvider?.isTemplate ? 'Edit Provider' : 'Edit ServiceConfig'}
+ titleIcon={ }
+ maxWidth="lg"
+ testId="edit-provider-modal"
+ >
+ {editingProvider && editingProvider.template && (
+
+ {/* Provider/ServiceConfig name */}
+
+
+ {editingProvider.name}
+
+
+ {editingProvider.isTemplate ? 'Default provider' : 'Custom instance'}
+
+
+
+ {/* Config fields - providers use config_schema */}
+ {editingProvider.template.source === 'provider' &&
+ editingProvider.template.config_schema &&
+ editingProvider.template.config_schema.length > 0 && (
+
+ {editingProvider.template.config_schema.map((field: any) => (
+
+ setEditConfig((prev) => ({
+ ...prev,
+ [field.key]: value,
+ }))
+ }
+ />
+ ))}
+
+ )}
+
+ {/* Environment variables - compose services (both templates and instances) */}
+ {editingProvider.template.source === 'compose' &&
+ (loadingEnvConfig ? (
+
+
+ Loading configuration...
+
+ ) : envVars.length > 0 ? (
+
+
+ Environment Variables
+
+
+ {envVars.map((envVar) => {
+ const config = envConfigs[envVar.name] || {
+ name: envVar.name,
+ source: 'default',
+ value: undefined,
+ setting_path: undefined,
+ new_setting_path: undefined,
+ }
+
+ return (
+ {
+ setEnvConfigs((prev) => ({
+ ...prev,
+ [envVar.name]: { ...prev[envVar.name], ...updates } as EnvVarConfig,
+ }))
+ }}
+ />
+ )
+ })}
+
+
+ ) : null)}
+
+ {/* Footer */}
+
+ setEditingProvider(null)} className="btn-secondary">
+ Cancel
+
+
+ {isSavingEdit ? (
+
+ ) : (
+
+ )}
+ Save Changes
+
+
+
+ )}
+
+
+ {/* Add Provider Modal */}
+
setShowAddProviderModal(false)}
+ title="Add Provider"
+ maxWidth="md"
+ testId="add-provider-modal"
+ >
+
+ {availableToAdd.map((template) => (
+
handleAddProvider(template.id)}
+ className="w-full p-3 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-colors text-left flex items-center justify-between"
+ data-testid={`add-provider-${template.id}`}
+ >
+
+
+ {template.mode === 'local' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {template.name}
+
+
+ {template.provides}
+
+
+
+
+
+ ))}
+
+
+
+ setShowAddProviderModal(false)}
+ className="btn-secondary"
+ >
+ Cancel
+
+
+
+
+ {/* Confirmation Dialog */}
+
setConfirmDialog({ isOpen: false, instanceId: null })}
+ />
+
+ {/* Deploy to Kubernetes Modal */}
+ {deployModalState.isOpen && deployModalState.targetType === 'kubernetes' && (
+ 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}
+ />
+ )}
+
+ {/* Service Catalog Modal */}
+ setShowCatalog(false)}
+ title="Service Catalog"
+ maxWidth="2xl"
+ testId="catalog-modal"
+ >
+ {catalogLoading ? (
+
+
+
+ ) : (
+
+ {catalogServices.map(service => (
+
+
+
+
+
+
+
+
+ {service.service_name.split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
+
+
+ {service.description || service.image || 'Docker service'}
+
+
+
+
+
+ {/* Capabilities */}
+ {service.requires && service.requires.length > 0 && (
+
+ {service.requires.map((cap: string) => (
+
+ {cap}
+
+ ))}
+
+ )}
+
+ {/* Install/Uninstall Button */}
+ {service.installed ? (
+
handleUninstallService(service.service_id)}
+ disabled={installingService === service.service_id}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-danger-300 text-danger-600 hover:bg-danger-50 dark:border-danger-700 dark:text-danger-400 dark:hover:bg-danger-900/20 disabled:opacity-50"
+ >
+ {installingService === service.service_id ? (
+
+ ) : (
+
+ )}
+ Uninstall
+
+ ) : (
+
handleInstallService(service.service_id)}
+ disabled={installingService === service.service_id}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50"
+ >
+ {installingService === service.service_id ? (
+
+ ) : (
+
+ )}
+ Install
+
+ )}
+
+
+
+ ))}
+
+ {catalogServices.length === 0 && (
+
+
+
No services found in the catalog
+
+ )}
+
+ )}
+
+
+ {/* 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.
+
+ )}
+
+
+ setSimpleDeployModal({ isOpen: false, serviceId: null, targetType: null })}
+ className="btn-secondary"
+ >
+ Cancel
+
+
+ {creating !== null ? (
+
+ ) : (
+
+ )}
+ Deploy
+
+
+
+
+
+ )
+}
+
+// =============================================================================
+// Config Field Row Component (matches ServicesPage EnvVarEditor style)
+// =============================================================================
+
+interface ConfigFieldRowProps {
+ field: {
+ key: string
+ label?: string
+ type?: string
+ required?: boolean
+ has_value?: boolean
+ value?: string
+ default?: string
+ settings_path?: string
+ }
+ value: string
+ onChange: (value: string) => void
+ readOnly?: boolean
+}
+
+function ConfigFieldRow({ field, value, onChange, readOnly: _readOnly = false }: ConfigFieldRowProps) {
+ const [editing, setEditing] = useState(false)
+ const [showMapping, setShowMapping] = useState(false)
+
+ const isSecret = field.type === 'secret'
+ const isRequired = field.required
+ const hasDefault = field.has_value || field.default
+ const defaultValue = field.value || field.default || ''
+ const hasOverride = value && value.trim() !== ''
+ const isUsingDefault = hasDefault && !hasOverride
+ const needsValue = isRequired && !hasDefault && !hasOverride
+
+ return (
+
+ {/* Label with required indicator */}
+
+ {isRequired && * }
+ {field.label || field.key}
+ {needsValue && (
+ (missing)
+ )}
+
+
+ {/* Map button */}
+ {field.settings_path && (
+
setShowMapping(!showMapping)}
+ className={`px-2 py-1 text-xs rounded transition-colors flex-shrink-0 ${
+ showMapping
+ ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-300'
+ : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
+ }`}
+ title={showMapping ? 'Enter value' : 'Map to setting'}
+ >
+ Map
+
+ )}
+
+ {/* Input area */}
+
+ {showMapping && field.settings_path ? (
+ // Mapping mode - show setting path
+
+ {field.settings_path}
+
+ ) : isUsingDefault && !editing ? (
+ // Default value display
+ <>
+
setEditing(true)}
+ className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 flex-shrink-0"
+ title="Edit"
+ >
+
+
+
+ {isSecret ? '••••••••' : defaultValue}
+
+
+ default
+
+ >
+ ) : (
+ // Value input
+ <>
+
onChange(e.target.value)}
+ placeholder={isSecret ? '••••••••' : defaultValue || 'enter value'}
+ className="flex-1 px-2 py-1.5 text-xs rounded border-0 bg-neutral-100 dark:bg-neutral-700/50 text-neutral-900 dark:text-neutral-200 focus:outline-none focus:ring-1 focus:ring-primary-500 placeholder:text-neutral-400 dark:placeholder:text-neutral-500"
+ autoFocus={editing}
+ onBlur={() => {
+ if (!value && hasDefault) setEditing(false)
+ }}
+ data-testid={`config-field-${field.key}`}
+ />
+ {hasOverride && (
+
+ override
+
+ )}
+ >
+ )}
+
+
+ )
+}
+
+// =============================================================================
+// 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 && (
+
{
+ e.stopPropagation()
+ onRemove()
+ }}
+ className="p-1.5 text-neutral-400 hover:text-error-600 dark:hover:text-error-400 rounded"
+ title="Remove"
+ data-testid={`remove-template-${template.id}`}
+ >
+
+
+ )}
+
+
+ )}
+
+ )
+}
+
+// =============================================================================
+// 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 */}
+
setShowMapping(!showMapping)}
+ className={`px-2 py-1 text-xs rounded transition-colors flex-shrink-0 ${
+ showMapping
+ ? 'bg-primary-900/30 text-primary-300'
+ : 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-700'
+ }`}
+ title={showMapping ? 'Enter value' : 'Map to setting'}
+ data-testid={`map-button-${envVar.name}`}
+ >
+ Map
+
+
+ {/* Input area */}
+
+ {showMapping ? (
+ // Mapping mode - styled dropdown
+
{
+ if (e.target.value) {
+ onChange({
+ source: 'setting',
+ setting_path: e.target.value,
+ value: undefined,
+ new_setting_path: undefined,
+ })
+ }
+ }}
+ className="flex-1 min-w-0 px-2 py-1.5 text-xs font-mono rounded border-0 bg-neutral-700/50 text-neutral-200 focus:outline-none focus:ring-1 focus:ring-primary-500 cursor-pointer overflow-hidden text-ellipsis"
+ data-testid={`map-select-${envVar.name}`}
+ >
+ select...
+ {envVar.suggestions.map((s) => {
+ const displayValue = s.value && s.value.length > 30 ? s.value.substring(0, 30) + '...' : s.value
+ return (
+
+ {s.path}
+ {displayValue ? ` → ${displayValue}` : ''}
+
+ )
+ })}
+
+ ) : hasDefault && isUsingDefault && !editing ? (
+ // Default value display
+ <>
+
setEditing(true)}
+ className="text-neutral-500 hover:text-neutral-300 flex-shrink-0"
+ title="Edit"
+ >
+
+
+
{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 8382766e..ebb1923b 100644
--- a/ushadow/frontend/src/services/api.ts
+++ b/ushadow/frontend/src/services/api.ts
@@ -38,6 +38,11 @@ const getBackendUrl = () => {
// Fallback - calculate backend port from frontend port
// Frontend runs on 3000 + offset, backend on 8000 + offset
const frontendPort = parseInt(port)
+ if (isNaN(frontendPort)) {
+ // Invalid or empty port - use relative URLs as safest default
+ console.log('Unknown port, using relative URLs via proxy')
+ return ''
+ }
const backendPort = frontendPort - 3000 + 8000
console.log('Calculated backend port:', backendPort)
return `${protocol}//${hostname}:${backendPort}`
@@ -317,6 +322,8 @@ export interface EnvVarConfig {
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'
+ locked?: boolean // For provider-supplied values that cannot be edited
+ provider_name?: string // Name of the provider supplying this value
}
export interface EnvVarSuggestion {
@@ -541,6 +548,7 @@ export interface KubernetesCluster {
node_count?: number
namespace: string
labels: Record
+ infra_scans?: Record
}
export const kubernetesApi = {
@@ -552,6 +560,43 @@ export const kubernetesApi = {
api.get(`/api/kubernetes/${clusterId}`),
removeCluster: (clusterId: string) =>
api.delete(`/api/kubernetes/${clusterId}`),
+
+ // Service management
+ getAvailableServices: () =>
+ api.get<{ services: any[] }>('/api/kubernetes/services/available'),
+ getInfraServices: () =>
+ api.get<{ services: any[] }>('/api/kubernetes/services/infra'),
+
+ // Cluster operations
+ scanInfraServices: (clusterId: string, namespace: string = 'default') =>
+ api.post<{ cluster_id: string; namespace: string; infra_services: Record }>(
+ `/api/kubernetes/${clusterId}/scan-infra`,
+ { namespace }
+ ),
+ createEnvmap: (clusterId: string, data: { service_name: string; namespace?: string; env_vars: Record }) =>
+ api.post<{ success: boolean; configmap: string | null; secret: string | null; namespace: string }>(
+ `/api/kubernetes/${clusterId}/envmap`,
+ { namespace: 'default', ...data }
+ ),
+ deployService: (clusterId: string, data: { service_id: string; namespace?: string; k8s_spec?: any; config_id?: string }) =>
+ api.post<{ success: boolean; message: string; service_id: string; namespace: string }>(
+ `/api/kubernetes/${clusterId}/deploy`,
+ { namespace: 'default', ...data }
+ ),
+
+ // Pod operations
+ listPods: (clusterId: string, namespace: string = 'ushadow') =>
+ api.get<{ pods: Array<{ name: string; namespace: string; status: string; restarts: number; age: string; labels: Record; node: string }>; namespace: string }>(
+ `/api/kubernetes/${clusterId}/pods?namespace=${namespace}`
+ ),
+ getPodLogs: (clusterId: string, podName: string, namespace: string = 'ushadow', previous: boolean = false, tailLines: number = 100) =>
+ api.get<{ pod_name: string; namespace: string; previous: boolean; logs: string }>(
+ `/api/kubernetes/${clusterId}/pods/${podName}/logs?namespace=${namespace}&previous=${previous}&tail_lines=${tailLines}`
+ ),
+ getPodEvents: (clusterId: string, podName: string, namespace: string = 'ushadow') =>
+ api.get<{ pod_name: string; namespace: string; events: Array<{ type: string; reason: string; message: string; count: number; first_timestamp: string | null; last_timestamp: string | null }> }>(
+ `/api/kubernetes/${clusterId}/pods/${podName}/events?namespace=${namespace}`
+ ),
}
// Service Definition and Deployment types
@@ -628,7 +673,7 @@ export interface TailscaleConfig {
https_enabled: boolean
use_caddy_proxy: boolean
backend_port: number
- frontend_port: number
+ frontend_port: number | null // null = auto-detect (5173 dev, 80 prod)
environments: string[]
}
@@ -1024,14 +1069,14 @@ export const memoriesApi = {
}
// =============================================================================
-// Instances API (templates, instances, wiring)
+// ServiceConfigs API (templates, instances, wiring)
// =============================================================================
/** Template source - where the template was discovered from */
export type TemplateSource = 'compose' | 'provider'
-/** Instance status */
-export type InstanceStatus = 'pending' | 'deploying' | 'running' | 'stopped' | 'error' | 'n/a'
+/** ServiceConfig status */
+export type ServiceConfigStatus = 'pending' | 'deploying' | 'running' | 'stopped' | 'error' | 'n/a'
/** Template - discovered from compose or provider files */
export interface Template {
@@ -1061,30 +1106,31 @@ export interface Template {
tags: string[]
configured: boolean // Whether required config fields are set (for providers)
available: boolean // Whether local service is running (for local providers)
+ installed: boolean // Whether service is installed (for compose services)
}
-/** Instance config values */
-export interface InstanceConfig {
+/** ServiceConfig config values */
+export interface ConfigValues {
values: Record
}
-/** Instance outputs after deployment */
-export interface InstanceOutputs {
+/** ServiceConfig outputs after deployment */
+export interface ServiceOutputs {
access_url?: string
env_vars: Record
capability_values: Record
}
-/** Instance - configured deployment of a template */
-export interface Instance {
+/** ServiceConfig - configured deployment of a template */
+export interface ServiceConfig {
id: string
template_id: string
name: string
description?: string
- config: InstanceConfig
+ config: ConfigValues
deployment_target?: string
- status: InstanceStatus
- outputs: InstanceOutputs
+ status: ServiceConfigStatus
+ outputs: ServiceOutputs
container_id?: string
container_name?: string
deployment_id?: string
@@ -1103,12 +1149,12 @@ export interface Instance {
next_sync_at?: string
}
-/** Instance summary for list views */
-export interface InstanceSummary {
+/** ServiceConfig summary for list views */
+export interface ServiceConfigSummary {
id: string
template_id: string
name: string
- status: InstanceStatus
+ status: ServiceConfigStatus
provides?: string
deployment_target?: string
access_url?: string
@@ -1117,15 +1163,15 @@ export interface InstanceSummary {
/** Wiring connection between instances */
export interface Wiring {
id: string
- source_instance_id: string
+ source_config_id: string
source_capability: string
- target_instance_id: string
+ target_config_id: string
target_capability: string
created_at?: string
}
/** Request to create an instance */
-export interface InstanceCreateRequest {
+export interface ServiceConfigCreateRequest {
id: string
template_id: string
name: string
@@ -1135,7 +1181,7 @@ export interface InstanceCreateRequest {
}
/** Request to update an instance */
-export interface InstanceUpdateRequest {
+export interface ServiceConfigUpdateRequest {
name?: string
description?: string
config?: Record
@@ -1144,75 +1190,75 @@ export interface InstanceUpdateRequest {
/** Request to create wiring */
export interface WiringCreateRequest {
- source_instance_id: string
+ source_config_id: string
source_capability: string
- target_instance_id: string
+ target_config_id: string
target_capability: string
}
-export const instancesApi = {
+export const svcConfigsApi = {
// Templates
/** List all templates (compose services + providers) */
getTemplates: (source?: TemplateSource) =>
- api.get('/api/instances/templates', { params: source ? { source } : {} }),
+ api.get('/api/svc-configs/templates', { params: source ? { source } : {} }),
/** Get a template by ID */
getTemplate: (templateId: string) =>
- api.get(`/api/instances/templates/${templateId}`),
+ api.get(`/api/svc-configs/templates/${templateId}`),
- // Instances
+ // ServiceConfigs
/** List all instances */
- getInstances: () =>
- api.get('/api/instances'),
+ getServiceConfigs: () =>
+ api.get('/api/svc-configs'),
/** Get an instance by ID */
- getInstance: (instanceId: string) =>
- api.get(`/api/instances/${instanceId}`),
+ getServiceConfig: (instanceId: string) =>
+ api.get(`/api/svc-configs/${instanceId}`),
/** Create a new instance */
- createInstance: (data: InstanceCreateRequest) =>
- api.post('/api/instances', data),
+ createServiceConfig: (data: ServiceConfigCreateRequest) =>
+ api.post('/api/svc-configs', data),
/** Update an instance */
- updateInstance: (instanceId: string, data: InstanceUpdateRequest) =>
- api.put(`/api/instances/${instanceId}`, data),
+ updateServiceConfig: (instanceId: string, data: ServiceConfigUpdateRequest) =>
+ api.put(`/api/svc-configs/${instanceId}`, data),
/** Delete an instance */
- deleteInstance: (instanceId: string) =>
- api.delete(`/api/instances/${instanceId}`),
+ deleteServiceConfig: (instanceId: string) =>
+ api.delete(`/api/svc-configs/${instanceId}`),
/** Deploy/start an instance */
- deployInstance: (instanceId: string) =>
- api.post<{ success: boolean; message: string }>(`/api/instances/${instanceId}/deploy`),
+ deployServiceConfig: (instanceId: string) =>
+ api.post<{ success: boolean; message: string }>(`/api/svc-configs/${instanceId}/deploy`),
/** Undeploy/stop an instance */
- undeployInstance: (instanceId: string) =>
- api.post<{ success: boolean; message: string }>(`/api/instances/${instanceId}/undeploy`),
+ undeployServiceConfig: (instanceId: string) =>
+ api.post<{ success: boolean; message: string }>(`/api/svc-configs/${instanceId}/undeploy`),
// Wiring
/** List all wiring connections */
getWiring: () =>
- api.get('/api/instances/wiring/all'),
+ api.get('/api/svc-configs/wiring/all'),
/** Get default capability mappings */
getDefaults: () =>
- api.get>('/api/instances/wiring/defaults'),
+ api.get>('/api/svc-configs/wiring/defaults'),
/** Set default instance for a capability */
setDefault: (capability: string, instanceId: string) =>
- api.put(`/api/instances/wiring/defaults/${capability}`, null, { params: { instance_id: instanceId } }),
+ api.put(`/api/svc-configs/wiring/defaults/${capability}`, null, { params: { config_id: instanceId } }),
/** Create a wiring connection */
createWiring: (data: WiringCreateRequest) =>
- api.post('/api/instances/wiring', data),
+ api.post('/api/svc-configs/wiring', data),
/** Delete a wiring connection */
deleteWiring: (wiringId: string) =>
- api.delete(`/api/instances/wiring/${wiringId}`),
+ api.delete(`/api/svc-configs/wiring/${wiringId}`),
/** Get wiring for a specific instance */
- getInstanceWiring: (instanceId: string) =>
- api.get(`/api/instances/${instanceId}/wiring`),
+ getServiceConfigWiring: (instanceId: string) =>
+ api.get(`/api/svc-configs/${instanceId}/wiring`),
}
export const graphApi = {
@@ -1301,6 +1347,8 @@ export const tailscaleApi = {
api.post('/api/tailscale/container/provision-cert', null, { params: { hostname } }),
configureServe: (config: TailscaleConfig) =>
api.post<{ status: string; message: string; routes?: string; hostname?: string }>('/api/tailscale/configure-serve', config),
+ getServeStatus: () =>
+ api.get<{ status: string; routes: string | null; error?: string }>('/api/tailscale/serve-status'),
updateCorsOrigins: (hostname: string) =>
api.post<{
status: string
@@ -1422,23 +1470,23 @@ export interface IntegrationConnectionResult {
export const integrationApi = {
/** Test connection to an integration */
testConnection: (instanceId: string) =>
- api.post(`/api/instances/${instanceId}/test-connection`),
+ api.post(`/api/svc-configs/${instanceId}/test-connection`),
/** Manually trigger sync for an integration */
syncNow: (instanceId: string) =>
- api.post(`/api/instances/${instanceId}/sync`),
+ api.post(`/api/svc-configs/${instanceId}/sync`),
/** Get current sync status for an integration */
getSyncStatus: (instanceId: string) =>
- api.get(`/api/instances/${instanceId}/sync-status`),
+ api.get