diff --git a/ushadow/frontend/src/components/wiring/OutputWiring.tsx b/ushadow/frontend/src/components/wiring/OutputWiring.tsx new file mode 100644 index 00000000..23847fe2 --- /dev/null +++ b/ushadow/frontend/src/components/wiring/OutputWiring.tsx @@ -0,0 +1,570 @@ +/** + * OutputWiring - Components for wiring service outputs to env vars + * + * This module provides: + * - OutputPort: Draggable port on service outputs + * - EnvVarDropTarget: Droppable target for env vars + * - WireOverlay: SVG overlay for drawing connection wires + */ + +import { useState, useRef, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { Circle, Link2, X, ExternalLink } from 'lucide-react' + +// ============================================================================= +// Types +// ============================================================================= + +export interface OutputInfo { + instanceId: string + instanceName: string + outputKey: string // "access_url" | "env_vars.XXX" | "capability_values.XXX" + outputLabel: string + value?: string +} + +export interface EnvVarInfo { + instanceId: string + instanceName: string + envVarKey: string + envVarLabel: string + currentValue?: string +} + +export interface OutputWiringConnection { + id: string + source: OutputInfo + target: EnvVarInfo +} + +export interface WirePosition { + sourceId: string + targetId: string + sourcePos: { x: number; y: number } + targetPos: { x: number; y: number } +} + +// ============================================================================= +// OutputPort - Draggable port on service outputs +// ============================================================================= + +interface OutputPortProps { + output: OutputInfo + isConnected: boolean + connectionCount: number + onStartDrag: (output: OutputInfo, startPos: { x: number; y: number }) => void + onEndDrag: () => void + portRef?: (el: HTMLDivElement | null) => void +} + +export function OutputPort({ + output, + isConnected, + connectionCount, + onStartDrag, + onEndDrag, + portRef, +}: OutputPortProps) { + const [isDragging, setIsDragging] = useState(false) + const localRef = useRef(null) + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + const rect = localRef.current?.getBoundingClientRect() + if (rect) { + onStartDrag(output, { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }) + } + } + + useEffect(() => { + if (!isDragging) return + + const handleMouseUp = () => { + setIsDragging(false) + onEndDrag() + } + + window.addEventListener('mouseup', handleMouseUp) + return () => window.removeEventListener('mouseup', handleMouseUp) + }, [isDragging, onEndDrag]) + + const setRefs = (el: HTMLDivElement | null) => { + localRef.current = el + portRef?.(el) + } + + return ( +
+ {/* Output label */} + + {output.outputLabel} + + + {/* Port circle */} +
+ + {/* Connection count badge */} + {connectionCount > 0 && ( + + {connectionCount} + + )} +
+ ) +} + +// ============================================================================= +// EnvVarDropTarget - Droppable target for env vars +// ============================================================================= + +interface EnvVarDropTargetProps { + envVar: EnvVarInfo + isConnected: boolean + connectedFrom?: OutputInfo + isDropTarget: boolean + onDisconnect: () => void + targetRef?: (el: HTMLDivElement | null) => void +} + +export function EnvVarDropTarget({ + envVar, + isConnected, + connectedFrom, + isDropTarget, + onDisconnect, + targetRef, +}: EnvVarDropTargetProps) { + const localRef = useRef(null) + + const setRefs = (el: HTMLDivElement | null) => { + localRef.current = el + targetRef?.(el) + } + + return ( +
+ {/* Target circle */} +
+ + {/* Env var label */} +
+ + {envVar.envVarLabel} + + {isConnected && connectedFrom && ( + + + {connectedFrom.instanceName}.{connectedFrom.outputLabel} + + )} + {!isConnected && isDropTarget && ( + + Drop to connect + + )} +
+ + {/* Disconnect button */} + {isConnected && ( + + )} +
+ ) +} + +// ============================================================================= +// WireOverlay - SVG overlay for drawing connection wires +// ============================================================================= + +interface WireOverlayProps { + wires: WirePosition[] + draggingWire?: { + sourcePos: { x: number; y: number } + currentPos: { x: number; y: number } + } + containerRef: React.RefObject +} + +export function WireOverlay({ wires, draggingWire, containerRef }: WireOverlayProps) { + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + + // Update dimensions on resize + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setDimensions({ width: rect.width, height: rect.height }) + setOffset({ x: rect.left, y: rect.top }) + } + } + + updateDimensions() + window.addEventListener('resize', updateDimensions) + window.addEventListener('scroll', updateDimensions) + + // Also update on container size changes + const resizeObserver = new ResizeObserver(updateDimensions) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => { + window.removeEventListener('resize', updateDimensions) + window.removeEventListener('scroll', updateDimensions) + resizeObserver.disconnect() + } + }, [containerRef]) + + // Convert absolute position to relative position within container + const toRelative = useCallback((pos: { x: number; y: number }) => ({ + x: pos.x - offset.x, + y: pos.y - offset.y, + }), [offset]) + + // Generate a curved path between two points + const generatePath = (start: { x: number; y: number }, end: { x: number; y: number }) => { + const dx = end.x - start.x + const controlPointOffset = Math.min(Math.abs(dx) * 0.5, 100) + + return `M ${start.x} ${start.y} + C ${start.x + controlPointOffset} ${start.y}, + ${end.x - controlPointOffset} ${end.y}, + ${end.x} ${end.y}` + } + + if (dimensions.width === 0 || dimensions.height === 0) return null + + return createPortal( + + + {/* Gradient for connected wires */} + + + + + {/* Gradient for dragging wire */} + + + + + {/* Arrow marker */} + + + + + + {/* Existing connections */} + {wires.map((wire) => { + const startRel = toRelative(wire.sourcePos) + const endRel = toRelative(wire.targetPos) + + return ( + + {/* Shadow/glow effect */} + + {/* Main wire */} + + + ) + })} + + {/* Currently dragging wire */} + {draggingWire && ( + + + {/* Dragging endpoint circle */} + + + )} + , + document.body + ) +} + +// ============================================================================= +// OutputSection - Section showing all outputs for a service +// ============================================================================= + +interface OutputSectionProps { + instanceId: string + instanceName: string + outputs: { + access_url?: string + env_vars?: Record + capability_values?: Record + } + connections: OutputWiringConnection[] + onStartDrag: (output: OutputInfo, startPos: { x: number; y: number }) => void + onEndDrag: () => void + portRefs: Map +} + +export function OutputSection({ + instanceId, + instanceName, + outputs, + connections, + onStartDrag, + onEndDrag, + portRefs, +}: OutputSectionProps) { + // Build list of available outputs + const outputList: OutputInfo[] = [] + + // Add access_url if present + if (outputs.access_url) { + outputList.push({ + instanceId, + instanceName, + outputKey: 'access_url', + outputLabel: 'URL', + value: outputs.access_url, + }) + } + + // Add env_vars if present + if (outputs.env_vars) { + Object.entries(outputs.env_vars).forEach(([key, value]) => { + outputList.push({ + instanceId, + instanceName, + outputKey: `env_vars.${key}`, + outputLabel: key, + value, + }) + }) + } + + // Add capability_values if present + if (outputs.capability_values) { + Object.entries(outputs.capability_values).forEach(([key, value]) => { + outputList.push({ + instanceId, + instanceName, + outputKey: `capability_values.${key}`, + outputLabel: key, + value: typeof value === 'string' ? value : JSON.stringify(value), + }) + }) + } + + if (outputList.length === 0) { + return null + } + + return ( +
+
+ + Outputs +
+
+ {outputList.map((output) => { + const connectionCount = connections.filter( + (c) => c.source.instanceId === instanceId && c.source.outputKey === output.outputKey + ).length + + return ( + 0} + connectionCount={connectionCount} + onStartDrag={onStartDrag} + onEndDrag={onEndDrag} + portRef={(el) => { + if (el) { + portRefs.set(`output::${instanceId}::${output.outputKey}`, el) + } + }} + /> + ) + })} +
+
+ ) +} + +// ============================================================================= +// EnvVarSection - Section showing env vars that can receive wire connections +// ============================================================================= + +interface EnvVarSectionProps { + instanceId: string + instanceName: string + envVars: Array<{ + key: string + label: string + value?: string + required?: boolean + }> + connections: OutputWiringConnection[] + draggingOutput: OutputInfo | null + onDisconnect: (connectionId: string) => void + targetRefs: Map + isDropTarget: (envVarKey: string) => boolean +} + +export function EnvVarSection({ + instanceId, + instanceName, + envVars, + connections, + draggingOutput, + onDisconnect, + targetRefs, + isDropTarget, +}: EnvVarSectionProps) { + if (envVars.length === 0) { + return null + } + + return ( +
+
+ + Env Vars +
+
+ {envVars.map((envVar) => { + const connection = connections.find( + (c) => c.target.instanceId === instanceId && c.target.envVarKey === envVar.key + ) + + return ( + { + if (connection) { + onDisconnect(connection.id) + } + }} + targetRef={(el) => { + if (el) { + targetRefs.set(`envvar::${instanceId}::${envVar.key}`, el) + } + }} + /> + ) + })} +
+
+ ) +} diff --git a/ushadow/frontend/src/components/wiring/WiringBoard.tsx b/ushadow/frontend/src/components/wiring/WiringBoard.tsx index 83b7b185..61f82832 100644 --- a/ushadow/frontend/src/components/wiring/WiringBoard.tsx +++ b/ushadow/frontend/src/components/wiring/WiringBoard.tsx @@ -6,9 +6,10 @@ * - Right: Consumer instances with capability slots (targets) * * Drag from a provider to a consumer's capability slot to create a connection. + * Also supports output-to-env-var wiring with visual wire connections. */ -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { DndContext, @@ -36,6 +37,8 @@ import { StopCircle, Settings, Loader2, + ExternalLink, + Link2, } from 'lucide-react' // Per-service wiring model - each consumer has its own connections @@ -57,6 +60,12 @@ interface ProviderInfo { templateId: string // For templates: own ID; for instances: parent template ID configVars: ConfigVar[] configured?: boolean // Whether all required config fields have values + // Output wiring: available outputs for this provider + outputs?: { + access_url?: string + env_vars?: Record + capability_values?: Record + } } interface ConsumerInfo { @@ -68,6 +77,13 @@ interface ConsumerInfo { configVars?: ConfigVar[] configured?: boolean description?: string // Service description + // Output wiring: env vars that can receive wired values + wirableEnvVars?: Array<{ + key: string + label: string + value?: string + required?: boolean + }> } interface WiringInfo { @@ -78,12 +94,35 @@ interface WiringInfo { target_capability: string } +// Output wiring types +export interface OutputWiringInfo { + id: string + source_instance_id: string + source_output_key: string + target_instance_id: string + target_env_var: string +} + +export interface OutputInfo { + instanceId: string + instanceName: string + outputKey: string + outputLabel: string + value?: string +} + interface DropInfo { provider: ProviderInfo consumerId: string capability: string } +interface OutputDropInfo { + source: OutputInfo + targetInstanceId: string + targetEnvVar: string +} + interface WiringBoardProps { providers: ProviderInfo[] consumers: ConsumerInfo[] @@ -99,6 +138,10 @@ interface WiringBoardProps { onEditConsumer?: (consumerId: string) => void onStartConsumer?: (consumerId: string) => Promise onStopConsumer?: (consumerId: string) => Promise + // Output wiring props + outputWiring?: OutputWiringInfo[] + onOutputWiringCreate?: (dropInfo: OutputDropInfo) => Promise + onOutputWiringDelete?: (wiringId: string) => Promise } export default function WiringBoard({ @@ -115,10 +158,21 @@ export default function WiringBoard({ onEditConsumer, onStartConsumer, onStopConsumer, + outputWiring = [], + onOutputWiringCreate, + onOutputWiringDelete, }: WiringBoardProps) { const [activeProvider, setActiveProvider] = useState(null) const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) + // Output wiring state + const [draggingOutput, setDraggingOutput] = useState(null) + const [wireStartPos, setWireStartPos] = useState<{ x: number; y: number } | null>(null) + const [hoveredTarget, setHoveredTarget] = useState(null) + const boardRef = useRef(null) + const outputPortRefs = useRef>(new Map()) + const envVarTargetRefs = useRef>(new Map()) + // Configure sensors for proper drag handling const mouseSensor = useSensor(MouseSensor, { activationConstraint: { @@ -144,6 +198,82 @@ export default function WiringBoard({ return () => window.removeEventListener('mousemove', handleMouseMove) }, [activeProvider]) + // Output wire dragging handlers + const handleOutputDragStart = useCallback((output: OutputInfo, startPos: { x: number; y: number }) => { + setDraggingOutput(output) + setWireStartPos(startPos) + }, []) + + const handleOutputDragEnd = useCallback(async () => { + if (draggingOutput && hoveredTarget && onOutputWiringCreate) { + // Parse target: "envvar::instanceId::envVarKey" + const parts = hoveredTarget.split('::') + if (parts.length === 3 && parts[0] === 'envvar') { + await onOutputWiringCreate({ + source: draggingOutput, + targetInstanceId: parts[1], + targetEnvVar: parts[2], + }) + } + } + setDraggingOutput(null) + setWireStartPos(null) + setHoveredTarget(null) + }, [draggingOutput, hoveredTarget, onOutputWiringCreate]) + + // Track mouse position and detect drop targets during output wire drag + useEffect(() => { + if (!draggingOutput) return + + const handleMouseMove = (e: MouseEvent) => { + setMousePos({ x: e.clientX, y: e.clientY }) + + // Check if hovering over any env var target + const elements = document.elementsFromPoint(e.clientX, e.clientY) + const targetEl = elements.find(el => el.getAttribute('data-target-id')?.startsWith('envvar::')) + if (targetEl) { + setHoveredTarget(targetEl.getAttribute('data-target-id')) + } else { + setHoveredTarget(null) + } + } + + window.addEventListener('mousemove', handleMouseMove) + return () => window.removeEventListener('mousemove', handleMouseMove) + }, [draggingOutput]) + + // Calculate wire positions for existing output wiring connections + const getWirePositions = useCallback(() => { + const positions: Array<{ + sourceId: string + targetId: string + sourcePos: { x: number; y: number } + targetPos: { x: number; y: number } + }> = [] + + outputWiring.forEach((wire) => { + const sourceKey = `output::${wire.source_instance_id}::${wire.source_output_key}` + const targetKey = `envvar::${wire.target_instance_id}::${wire.target_env_var}` + + const sourceEl = outputPortRefs.current.get(sourceKey) + const targetEl = envVarTargetRefs.current.get(targetKey) + + if (sourceEl && targetEl) { + const sourceRect = sourceEl.getBoundingClientRect() + const targetRect = targetEl.getBoundingClientRect() + + positions.push({ + sourceId: sourceKey, + targetId: targetKey, + sourcePos: { x: sourceRect.right, y: sourceRect.top + sourceRect.height / 2 }, + targetPos: { x: targetRect.left, y: targetRect.top + targetRect.height / 2 }, + }) + } + }) + + return positions + }, [outputWiring]) + // Group providers by capability, then by template (templates with their instances nested) const providersByCapability = providers.reduce( (acc, provider) => { @@ -240,7 +370,8 @@ export default function WiringBoard({ collisionDetection={pointerWithin} >
{/* Left Column: Providers */} @@ -271,6 +402,10 @@ export default function WiringBoard({ onCreateInstance={() => onCreateInstance(template.id)} onStart={onStartProvider ? () => onStartProvider(template.id, true) : undefined} onStop={onStopProvider ? () => onStopProvider(template.id, true) : undefined} + outputWiring={outputWiring} + onOutputDragStart={handleOutputDragStart} + onOutputDragEnd={handleOutputDragEnd} + outputPortRefs={outputPortRefs.current} /> {/* Nested instances */} {instances.length > 0 && ( @@ -289,6 +424,10 @@ export default function WiringBoard({ onStart={onStartProvider ? () => onStartProvider(instance.id, false) : undefined} onStop={onStopProvider ? () => onStopProvider(instance.id, false) : undefined} templateProvider={template} + outputWiring={outputWiring} + onOutputDragStart={handleOutputDragStart} + onOutputDragEnd={handleOutputDragEnd} + outputPortRefs={outputPortRefs.current} /> ) })} @@ -341,6 +480,12 @@ export default function WiringBoard({ onEdit={onEditConsumer} onStart={onStartConsumer} onStop={onStopConsumer} + outputWiring={outputWiring} + draggingOutput={draggingOutput} + hoveredTarget={hoveredTarget} + onOutputWiringDelete={onOutputWiringDelete} + envVarTargetRefs={envVarTargetRefs.current} + providers={providers} /> ) })} @@ -376,6 +521,90 @@ export default function WiringBoard({ document.body )} + {/* Output wire overlay - SVG lines connecting outputs to env vars */} + {boardRef.current && (outputWiring.length > 0 || draggingOutput) && createPortal( + + + + + + + + + + + + + + + + {/* Existing output wiring connections */} + {getWirePositions().map((wire) => { + const boardRect = boardRef.current!.getBoundingClientRect() + const startX = wire.sourcePos.x - boardRect.left + const startY = wire.sourcePos.y - boardRect.top + const endX = wire.targetPos.x - boardRect.left + const endY = wire.targetPos.y - boardRect.top + const dx = endX - startX + const cp = Math.min(Math.abs(dx) * 0.5, 80) + + return ( + + + + + ) + })} + + {/* Wire being dragged */} + {draggingOutput && wireStartPos && (() => { + const boardRect = boardRef.current!.getBoundingClientRect() + const startX = wireStartPos.x - boardRect.left + const startY = wireStartPos.y - boardRect.top + const endX = mousePos.x - boardRect.left + const endY = mousePos.y - boardRect.top + const dx = endX - startX + const cp = Math.min(Math.abs(dx) * 0.5, 80) + + return ( + + + + + ) + })()} + , + document.body + )} + ) } @@ -393,9 +622,27 @@ interface DraggableProviderProps { onStart?: () => Promise onStop?: () => Promise templateProvider?: ProviderInfo // Parent template for instances + // Output wiring props + outputWiring?: OutputWiringInfo[] + onOutputDragStart?: (output: OutputInfo, startPos: { x: number; y: number }) => void + onOutputDragEnd?: () => void + outputPortRefs?: Map } -function DraggableProvider({ provider, connectionCount, onEdit, onCreateInstance, onDelete, onStart, onStop, templateProvider }: DraggableProviderProps) { +function DraggableProvider({ + provider, + connectionCount, + onEdit, + onCreateInstance, + onDelete, + onStart, + onStop, + templateProvider, + outputWiring = [], + onOutputDragStart, + onOutputDragEnd, + outputPortRefs, +}: DraggableProviderProps) { const [isStarting, setIsStarting] = useState(false) const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: provider.id, @@ -445,163 +692,427 @@ function DraggableProvider({ provider, connectionCount, onEdit, onCreateInstance
- {/* Draggable header */} -
- -
-
- - {provider.name} - -
- {/* Capability tag */} - - {provider.capability} + {/* Left side: Main content */} +
+ {/* Draggable header */} +
+ +
+
+ + {provider.name} - {isConnected && ( - - {connectionCount} +
+ {/* Capability tag */} + + {provider.capability} - )} + {isConnected && ( + + {connectionCount} + + )} +
-
- {/* Action buttons */} -
- {/* Start/Stop/Setup buttons for local providers */} - {!isCloud && onStart && onStop && ( - <> - {isStarting ? ( - - - - ) : needsSetup && canStart ? ( - - ) : canStart ? ( - - ) : canStop ? ( - - ) : null} - - )} - - {provider.isTemplate && onCreateInstance && ( - - )} - {!provider.isTemplate && onDelete && ( + {/* Action buttons */} +
+ {/* Start/Stop/Setup buttons for local providers */} + {!isCloud && onStart && onStop && ( + <> + {isStarting ? ( + + + + ) : needsSetup && canStart ? ( + + ) : canStart ? ( + + ) : canStop ? ( + + ) : null} + + )} - )} -
- - -
- - {/* Config vars display - show missing required first, then configured */} - {(missingRequiredVars.length > 0 || configuredVars.length > 0) && ( -
-
- {/* Missing required fields - shown first with warning */} - {missingRequiredVars.slice(0, 2).map((v) => ( - handleButtonClick(e, onCreateInstance)} + 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}`} > - * - {v.label}:{' '} - Not set - - ))} - {missingRequiredVars.length > 2 && ( - - +{missingRequiredVars.length - 2} required - + + + )} + {!provider.isTemplate && onDelete && ( + )} - {/* Configured fields - color code overrides */} - {configuredVars.slice(0, 3 - Math.min(missingRequiredVars.length, 2)).map((v) => { - // Check if this value is overridden from template - const isOverridden = templateProvider && - templateProvider.configVars.find(tv => tv.key === v.key)?.value !== v.value +
+ + +
- return ( + {/* Config vars display - show missing required first, then configured */} + {(missingRequiredVars.length > 0 || configuredVars.length > 0) && ( +
+
+ {/* Missing required fields - shown first with warning */} + {missingRequiredVars.slice(0, 2).map((v) => ( - {v.required && *} - {v.label}:{' '} - {v.value} + * + {v.label}:{' '} + Not set - ) - })} - {configuredVars.length > (3 - Math.min(missingRequiredVars.length, 2)) && ( - - +{configuredVars.length - (3 - Math.min(missingRequiredVars.length, 2))} more - - )} + ))} + {missingRequiredVars.length > 2 && ( + + +{missingRequiredVars.length - 2} required + + )} + {/* Configured fields - color code overrides */} + {configuredVars.slice(0, 3 - Math.min(missingRequiredVars.length, 2)).map((v) => { + // Check if this value is overridden from template + const isOverridden = templateProvider && + templateProvider.configVars.find(tv => tv.key === v.key)?.value !== v.value + + return ( + + {v.required && *} + {v.label}:{' '} + {v.value} + + ) + })} + {configuredVars.length > (3 - Math.min(missingRequiredVars.length, 2)) && ( + + +{configuredVars.length - (3 - Math.min(missingRequiredVars.length, 2))} more + + )} +
-
+ )} +
+ + {/* Right side: Output ports for wiring */} + {provider.outputs && onOutputDragStart && ( + {})} + outputPortRefs={outputPortRefs} + /> )}
) } +// ============================================================================= +// Output Ports Section - Shows draggable output ports on providers +// ============================================================================= + +interface OutputPortsSectionProps { + provider: ProviderInfo + outputWiring: OutputWiringInfo[] + onOutputDragStart: (output: OutputInfo, startPos: { x: number; y: number }) => void + onOutputDragEnd: () => void + outputPortRefs?: Map +} + +function OutputPortsSection({ + provider, + outputWiring, + onOutputDragStart, + onOutputDragEnd, + outputPortRefs, +}: OutputPortsSectionProps) { + const outputs = provider.outputs + if (!outputs) return null + + // Build list of available outputs + const outputList: Array<{ key: string; label: string; value?: string }> = [] + + if (outputs.access_url) { + outputList.push({ key: 'access_url', label: 'URL', value: outputs.access_url }) + } + + if (outputs.env_vars) { + Object.entries(outputs.env_vars).forEach(([key, value]) => { + outputList.push({ key: `env_vars.${key}`, label: key, value }) + }) + } + + if (outputs.capability_values) { + Object.entries(outputs.capability_values).forEach(([key, value]) => { + outputList.push({ + key: `capability_values.${key}`, + label: key, + value: typeof value === 'string' ? value : JSON.stringify(value), + }) + }) + } + + if (outputList.length === 0) return null + + return ( +
+
+ + Outputs +
+
+ {outputList.map((output) => { + const connectionCount = outputWiring.filter( + (w) => w.source_instance_id === provider.id && w.source_output_key === output.key + ).length + + return ( + { + if (el && outputPortRefs) { + outputPortRefs.set(`output::${provider.id}::${output.key}`, el) + } + }} + /> + ) + })} +
+
+ ) +} + +// ============================================================================= +// Output Ports Side Panel - Shows outputs on the right side of provider cards +// ============================================================================= + +function OutputPortsSidePanel({ + provider, + outputWiring, + onOutputDragStart, + onOutputDragEnd, + outputPortRefs, +}: OutputPortsSectionProps) { + const outputs = provider.outputs + if (!outputs) return null + + // Build list of available outputs + const outputList: Array<{ key: string; label: string; value?: string }> = [] + + if (outputs.access_url) { + outputList.push({ key: 'access_url', label: 'URL', value: outputs.access_url }) + } + + if (outputs.env_vars) { + Object.entries(outputs.env_vars).forEach(([key, value]) => { + outputList.push({ key: `env_vars.${key}`, label: key, value }) + }) + } + + if (outputs.capability_values) { + Object.entries(outputs.capability_values).forEach(([key, value]) => { + outputList.push({ + key: `capability_values.${key}`, + label: key, + value: typeof value === 'string' ? value : JSON.stringify(value), + }) + }) + } + + if (outputList.length === 0) return null + + return ( +
+ {outputList.map((output) => { + const connectionCount = outputWiring.filter( + (w) => w.source_instance_id === provider.id && w.source_output_key === output.key + ).length + + return ( + { + if (el && outputPortRefs) { + outputPortRefs.set(`output::${provider.id}::${output.key}`, el) + } + }} + /> + ) + })} +
+ ) +} + +// ============================================================================= +// Output Port Pill - Individual draggable output port +// ============================================================================= + +interface OutputPortPillProps { + instanceId: string + instanceName: string + outputKey: string + outputLabel: string + value?: string + connectionCount: number + onDragStart: (output: OutputInfo, startPos: { x: number; y: number }) => void + onDragEnd: () => void + portRef?: (el: HTMLDivElement | null) => void +} + +function OutputPortPill({ + instanceId, + instanceName, + outputKey, + outputLabel, + value, + connectionCount, + onDragStart, + onDragEnd, + portRef, +}: OutputPortPillProps) { + const [isDragging, setIsDragging] = useState(false) + const localRef = useRef(null) + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + const rect = localRef.current?.getBoundingClientRect() + if (rect) { + onDragStart( + { instanceId, instanceName, outputKey, outputLabel, value }, + { x: rect.right, y: rect.top + rect.height / 2 } + ) + } + } + + useEffect(() => { + if (!isDragging) return + + const handleMouseUp = () => { + setIsDragging(false) + onDragEnd() + } + + window.addEventListener('mouseup', handleMouseUp) + return () => window.removeEventListener('mouseup', handleMouseUp) + }, [isDragging, onDragEnd]) + + const setRefs = (el: HTMLDivElement | null) => { + localRef.current = el + portRef?.(el) + } + + const isConnected = connectionCount > 0 + + return ( +
+ {outputLabel} +
+ {connectionCount > 1 && ( + + {connectionCount} + + )} +
+ ) +} + // ============================================================================= // Service Card Component (vertical slot layout per wireframe) // ============================================================================= @@ -619,6 +1130,13 @@ interface ServiceCardProps { onEdit?: (consumerId: string) => void onStart?: (consumerId: string) => Promise onStop?: (consumerId: string) => Promise + // Output wiring props + outputWiring?: OutputWiringInfo[] + draggingOutput?: OutputInfo | null + hoveredTarget?: string | null + onOutputWiringDelete?: (wiringId: string) => Promise + envVarTargetRefs?: Map + providers?: ProviderInfo[] // To get source instance names } function ConsumerCard({ @@ -634,6 +1152,12 @@ function ConsumerCard({ onEdit, onStart, onStop, + outputWiring = [], + draggingOutput, + hoveredTarget, + onOutputWiringDelete, + envVarTargetRefs, + providers = [], }: ServiceCardProps) { const [isStarting, setIsStarting] = useState(false) @@ -774,6 +1298,19 @@ function ConsumerCard({ })}
+ {/* Wirable Env Vars - drop targets for output wiring */} + {consumer.wirableEnvVars && consumer.wirableEnvVars.length > 0 && ( + + )} + {/* Service config vars at bottom */} {(missingRequiredVars.length > 0 || configuredVars.length > 0) && (
@@ -927,6 +1464,206 @@ function CapabilitySlot({ ) } +// ============================================================================= +// Env Var Targets Section - Shows drop targets for output wiring +// ============================================================================= + +interface EnvVarTargetsSectionProps { + consumer: ConsumerInfo + outputWiring: OutputWiringInfo[] + draggingOutput?: OutputInfo | null + hoveredTarget?: string | null + onOutputWiringDelete?: (wiringId: string) => Promise + envVarTargetRefs?: Map + providers: ProviderInfo[] +} + +function EnvVarTargetsSection({ + consumer, + outputWiring, + draggingOutput, + hoveredTarget, + onOutputWiringDelete, + envVarTargetRefs, + providers, +}: EnvVarTargetsSectionProps) { + const envVars = consumer.wirableEnvVars || [] + if (envVars.length === 0) return null + + return ( +
+
+ + Wirable Env Vars +
+
+ {envVars.map((envVar) => { + // Find if this env var has a wired connection + const connection = outputWiring.find( + (w) => w.target_instance_id === consumer.id && w.target_env_var === envVar.key + ) + + // Get source info if connected + let sourceInfo: { instanceName: string; outputLabel: string } | null = null + if (connection) { + const sourceProvider = providers.find((p) => p.id === connection.source_instance_id) + const outputKey = connection.source_output_key + let outputLabel = outputKey + if (outputKey === 'access_url') { + outputLabel = 'URL' + } else if (outputKey.startsWith('env_vars.')) { + outputLabel = outputKey.replace('env_vars.', '') + } else if (outputKey.startsWith('capability_values.')) { + outputLabel = outputKey.replace('capability_values.', '') + } + sourceInfo = { + instanceName: sourceProvider?.name || connection.source_instance_id, + outputLabel, + } + } + + const targetId = `envvar::${consumer.id}::${envVar.key}` + const isDropTarget = draggingOutput !== null + const isHovered = hoveredTarget === targetId + + return ( + onOutputWiringDelete(connection.id) + : undefined + } + targetRef={(el) => { + if (el && envVarTargetRefs) { + envVarTargetRefs.set(targetId, el) + } + }} + targetId={targetId} + /> + ) + })} +
+
+ ) +} + +// ============================================================================= +// Env Var Drop Target Pill - Individual drop target for env vars +// ============================================================================= + +interface EnvVarDropTargetPillProps { + envVarKey: string + envVarLabel: string + value?: string + required?: boolean + isConnected: boolean + sourceInfo: { instanceName: string; outputLabel: string } | null + isDropTarget: boolean + isHovered: boolean + onDisconnect?: () => void + targetRef?: (el: HTMLDivElement | null) => void + targetId: string +} + +function EnvVarDropTargetPill({ + envVarKey, + envVarLabel, + value, + required, + isConnected, + sourceInfo, + isDropTarget, + isHovered, + onDisconnect, + targetRef, + targetId, +}: EnvVarDropTargetPillProps) { + return ( +
+ {/* Target indicator circle */} +
+ + {/* Content */} +
+
+ {required && *} + + {envVarLabel} + + ({envVarKey}) +
+ {isConnected && sourceInfo ? ( + + + + {sourceInfo.instanceName}.{sourceInfo.outputLabel} + + + ) : value ? ( + + Current: {value} + + ) : isDropTarget && !isConnected ? ( + + {isHovered ? 'Release to connect' : 'Drop output here'} + + ) : null} +
+ + {/* Disconnect button */} + {isConnected && onDisconnect && ( + + )} +
+ ) +} + // ============================================================================= // Status Indicator // ============================================================================= diff --git a/ushadow/frontend/src/components/wiring/index.ts b/ushadow/frontend/src/components/wiring/index.ts index a166debe..4f341ecf 100644 --- a/ushadow/frontend/src/components/wiring/index.ts +++ b/ushadow/frontend/src/components/wiring/index.ts @@ -1 +1,12 @@ export { default as WiringBoard } from './WiringBoard' +export { + OutputPort, + EnvVarDropTarget, + WireOverlay, + OutputSection, + EnvVarSection, + type OutputInfo, + type EnvVarInfo, + type OutputWiringConnection, + type WirePosition, +} from './OutputWiring' diff --git a/ushadow/frontend/src/pages/InstancesPage.tsx b/ushadow/frontend/src/pages/InstancesPage.tsx index c52763f9..a231eec4 100644 --- a/ushadow/frontend/src/pages/InstancesPage.tsx +++ b/ushadow/frontend/src/pages/InstancesPage.tsx @@ -33,6 +33,7 @@ import { InstanceSummary, Wiring, InstanceCreateRequest, + OutputWiring, } from '../services/api' import ConfirmDialog from '../components/ConfirmDialog' import Modal from '../components/Modal' @@ -72,6 +73,9 @@ export default function InstancesPage() { // Wiring state (per-service connections) const [wiring, setWiring] = useState([]) + // Output wiring state (service outputs to env vars) + const [outputWiring, setOutputWiring] = useState([]) + // Service status state for consumers const [serviceStatuses, setServiceStatuses] = useState>({}) @@ -153,16 +157,18 @@ export default function InstancesPage() { const loadData = async () => { try { setLoading(true) - const [templatesRes, instancesRes, wiringRes, statusesRes] = await Promise.all([ + const [templatesRes, instancesRes, wiringRes, outputWiringRes, statusesRes] = await Promise.all([ instancesApi.getTemplates(), instancesApi.getInstances(), instancesApi.getWiring(), + instancesApi.getOutputWiring().catch(() => ({ data: [] })), servicesApi.getAllStatuses().catch(() => ({ data: {} })), ]) setTemplates(templatesRes.data) setInstances(instancesRes.data) setWiring(wiringRes.data) + setOutputWiring(outputWiringRes.data || []) setServiceStatuses(statusesRes.data || {}) // Load details for provider instances (instances that provide capabilities) @@ -607,6 +613,10 @@ export default function InstancesPage() { templateId: t.id, configVars, configured: t.configured, + // For templates, outputs are shown as placeholders + outputs: t.mode === 'local' ? { + access_url: `http://localhost:${t.service_name ? '(port)' : '(configured port)'}`, + } : undefined, } }), // Custom instances from provider templates @@ -681,6 +691,12 @@ export default function InstancesPage() { templateId: i.template_id, configVars, configured: template.configured, // Instance inherits template's configured status + // Instance outputs from deployment + outputs: details?.outputs ? { + access_url: details.outputs.access_url, + env_vars: details.outputs.env_vars, + capability_values: details.outputs.capability_values, + } : undefined, } }), ] @@ -713,6 +729,29 @@ export default function InstancesPage() { } }) + // Extract wirable env vars - env vars that can receive wired values + // These are typically URL-type or connection-type env vars + const wirableEnvVars = (t.config_schema || []) + .filter((field: any) => { + // Include fields that look like they accept URLs or connection strings + const key = field.key.toLowerCase() + return ( + key.includes('url') || + key.includes('endpoint') || + key.includes('host') || + key.includes('server') || + key.includes('api') || + field.env_var?.toLowerCase().includes('url') || + field.env_var?.toLowerCase().includes('endpoint') + ) + }) + .map((field: any) => ({ + key: field.env_var || field.key, + label: field.label || field.key, + value: field.value, + required: field.required, + })) + return { id: t.id, name: t.name, @@ -722,6 +761,7 @@ export default function InstancesPage() { configVars, configured: t.configured, description: t.description, + wirableEnvVars, } }) @@ -791,6 +831,47 @@ export default function InstancesPage() { } } + // Handle output wiring create (from dragging output to env var) + const handleOutputWiringCreate = async (dropInfo: { + source: { instanceId: string; instanceName: string; outputKey: string; outputLabel: string } + targetInstanceId: string + targetEnvVar: string + }) => { + try { + const newWiring = await instancesApi.createOutputWiring({ + source_instance_id: dropInfo.source.instanceId, + source_output_key: dropInfo.source.outputKey, + target_instance_id: dropInfo.targetInstanceId, + target_env_var: dropInfo.targetEnvVar, + }) + + setOutputWiring((prev) => [...prev, newWiring.data]) + setMessage({ + type: 'success', + text: `Connected ${dropInfo.source.outputLabel} to ${dropInfo.targetEnvVar}`, + }) + } catch (error: any) { + setMessage({ + type: 'error', + text: getErrorMessage(error, 'Failed to create output wiring'), + }) + } + } + + // Handle output wiring delete + const handleOutputWiringDelete = async (wiringId: string) => { + try { + await instancesApi.deleteOutputWiring(wiringId) + setOutputWiring((prev) => prev.filter((w) => w.id !== wiringId)) + setMessage({ type: 'success', text: 'Output wire disconnected' }) + } catch (error: any) { + setMessage({ + type: 'error', + text: getErrorMessage(error, 'Failed to delete output wiring'), + }) + } + } + // Handle edit provider/instance from wiring board const handleEditProviderFromBoard = async (providerId: string, isTemplate: boolean) => { if (isTemplate) { @@ -1561,7 +1642,7 @@ export default function InstancesPage() { Wiring

- Drag providers to connect them to service capability slots + Drag providers to capability slots, or drag outputs to env vars

@@ -1592,6 +1673,9 @@ export default function InstancesPage() { onEditConsumer={handleEditConsumer} onStartConsumer={handleStartConsumer} onStopConsumer={handleStopConsumer} + outputWiring={outputWiring} + onOutputWiringCreate={handleOutputWiringCreate} + onOutputWiringDelete={handleOutputWiringDelete} />
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts index 8382766e..9c0f213c 100644 --- a/ushadow/frontend/src/services/api.ts +++ b/ushadow/frontend/src/services/api.ts @@ -1124,6 +1124,24 @@ export interface Wiring { created_at?: string } +/** Output wiring - connects service outputs to env vars of other services */ +export interface OutputWiring { + id: string + source_instance_id: string + source_output_key: string // "access_url" | "env_vars.XXX" | "capability_values.XXX" + target_instance_id: string + target_env_var: string // The env var key on the target service + created_at?: string +} + +/** Request to create output wiring */ +export interface OutputWiringCreateRequest { + source_instance_id: string + source_output_key: string + target_instance_id: string + target_env_var: string +} + /** Request to create an instance */ export interface InstanceCreateRequest { id: string @@ -1213,6 +1231,19 @@ export const instancesApi = { /** Get wiring for a specific instance */ getInstanceWiring: (instanceId: string) => api.get(`/api/instances/${instanceId}/wiring`), + + // Output Wiring - connects service outputs to env vars + /** List all output wiring connections */ + getOutputWiring: () => + api.get('/api/instances/output-wiring/all'), + + /** Create an output wiring connection */ + createOutputWiring: (data: OutputWiringCreateRequest) => + api.post('/api/instances/output-wiring', data), + + /** Delete an output wiring connection */ + deleteOutputWiring: (wiringId: string) => + api.delete(`/api/instances/output-wiring/${wiringId}`), } export const graphApi = {