From b6b6236a9444dc0975f29f791ef5bf01c2a32241 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 7 Dec 2025 20:09:51 +0000 Subject: [PATCH 1/2] feat: add operations and configurations panels with entity tree restructure - Add ConfigurationPanel for viewing/editing ROS 2 parameters - Add OperationsPanel for invoking services and actions - Add ActionStatusPanel with auto-refresh for monitoring action goals - Restructure entity tree with virtual folders (data/operations/configurations) - Add lazy loading for virtual folder contents - Add empty state indicator for folders without content - Add detail views for service, action, and parameter entities - Extend sovd-api.ts with configurations and operations endpoints - Add new types for Parameter, Operation, ActionGoalStatus --- src/components/ActionStatusPanel.tsx | 231 +++++++++ src/components/ConfigurationPanel.tsx | 382 +++++++++++++++ src/components/DataFolderPanel.tsx | 147 ++++++ src/components/EntityDetailPanel.tsx | 508 +++++++++++++++----- src/components/EntityTreeNode.tsx | 63 ++- src/components/OperationResponse.tsx | 149 ++++++ src/components/OperationsPanel.tsx | 515 ++++++++++++++++++++ src/index.css | 53 ++- src/lib/sovd-api.ts | 359 +++++++++++++- src/lib/store.ts | 648 ++++++++++++++++++++------ src/lib/types.ts | 253 ++++++++++ 11 files changed, 3009 insertions(+), 299 deletions(-) create mode 100644 src/components/ActionStatusPanel.tsx create mode 100644 src/components/ConfigurationPanel.tsx create mode 100644 src/components/DataFolderPanel.tsx create mode 100644 src/components/OperationResponse.tsx create mode 100644 src/components/OperationsPanel.tsx diff --git a/src/components/ActionStatusPanel.tsx b/src/components/ActionStatusPanel.tsx new file mode 100644 index 0000000..074c72a --- /dev/null +++ b/src/components/ActionStatusPanel.tsx @@ -0,0 +1,231 @@ +import { useEffect, useCallback } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Activity, RefreshCw, XCircle, CheckCircle, AlertCircle, Clock, Loader2, Navigation } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useAppStore, type AppState } from '@/lib/store'; +import type { ActionGoalStatusValue } from '@/lib/types'; + +interface ActionStatusPanelProps { + componentId: string; + operationName: string; + goalId: string; +} + +/** + * Get status badge variant and icon + */ +function getStatusStyle(status: ActionGoalStatusValue): { + variant: 'default' | 'secondary' | 'destructive' | 'outline'; + icon: typeof CheckCircle; + color: string; + bgColor: string; +} { + switch (status) { + case 'accepted': + return { variant: 'outline', icon: Clock, color: 'text-blue-500', bgColor: 'bg-blue-500/10' }; + case 'executing': + return { variant: 'default', icon: Activity, color: 'text-blue-500', bgColor: 'bg-blue-500/10' }; + case 'canceling': + return { variant: 'secondary', icon: XCircle, color: 'text-yellow-500', bgColor: 'bg-yellow-500/10' }; + case 'succeeded': + return { variant: 'default', icon: CheckCircle, color: 'text-green-500', bgColor: 'bg-green-500/10' }; + case 'canceled': + return { variant: 'secondary', icon: XCircle, color: 'text-gray-500', bgColor: 'bg-gray-500/10' }; + case 'aborted': + return { variant: 'destructive', icon: AlertCircle, color: 'text-red-500', bgColor: 'bg-red-500/10' }; + default: + return { variant: 'outline', icon: Clock, color: 'text-muted-foreground', bgColor: 'bg-muted' }; + } +} + +/** + * Check if status is terminal (no more updates expected) + */ +function isTerminalStatus(status: ActionGoalStatusValue): boolean { + return ['succeeded', 'canceled', 'aborted'].includes(status); +} + +/** + * Check if status is active (action is in progress) + */ +function isActiveStatus(status: ActionGoalStatusValue): boolean { + return ['accepted', 'executing', 'canceling'].includes(status); +} + +export function ActionStatusPanel({ componentId, operationName, goalId }: ActionStatusPanelProps) { + const { + activeGoals, + autoRefreshGoals, + refreshActionStatus, + cancelActionGoal, + setAutoRefreshGoals, + } = useAppStore( + useShallow((state: AppState) => ({ + activeGoals: state.activeGoals, + autoRefreshGoals: state.autoRefreshGoals, + refreshActionStatus: state.refreshActionStatus, + cancelActionGoal: state.cancelActionGoal, + setAutoRefreshGoals: state.setAutoRefreshGoals, + })) + ); + + const goalStatus = activeGoals.get(goalId); + const statusStyle = goalStatus ? getStatusStyle(goalStatus.status) : null; + const StatusIcon = statusStyle?.icon || Clock; + const isTerminal = goalStatus ? isTerminalStatus(goalStatus.status) : false; + const isActive = goalStatus ? isActiveStatus(goalStatus.status) : false; + const canCancel = goalStatus && ['accepted', 'executing'].includes(goalStatus.status); + + // Manual refresh + const handleRefresh = useCallback(() => { + refreshActionStatus(componentId, operationName, goalId); + }, [componentId, operationName, goalId, refreshActionStatus]); + + // Cancel action + const handleCancel = useCallback(async () => { + await cancelActionGoal(componentId, operationName, goalId); + }, [componentId, operationName, goalId, cancelActionGoal]); + + // Auto-refresh effect + useEffect(() => { + if (!autoRefreshGoals || isTerminal) return; + + const interval = setInterval(() => { + refreshActionStatus(componentId, operationName, goalId); + }, 1000); // Refresh every second + + return () => clearInterval(interval); + }, [autoRefreshGoals, isTerminal, componentId, operationName, goalId, refreshActionStatus]); + + // Initial fetch + useEffect(() => { + if (!goalStatus) { + refreshActionStatus(componentId, operationName, goalId); + } + }, [goalId, goalStatus, componentId, operationName, refreshActionStatus]); + + if (!goalStatus) { + return ( +
+ +
+ ); + } + + return ( + + +
+
+ {isActive ? ( +
+ + {goalStatus.status === 'executing' && ( + + )} +
+ ) : ( + + )} + Action Status + + {goalStatus.status} + +
+ +
+ {/* Auto-refresh checkbox */} + + + {/* Manual refresh */} + + + {/* Cancel button */} + {canCancel && ( + + )} +
+
+
+ + + {/* Progress bar for active actions */} + {isActive && ( +
+
+ + + {goalStatus.status === 'accepted' && 'Waiting to start...'} + {goalStatus.status === 'executing' && 'Action in progress...'} + {goalStatus.status === 'canceling' && 'Canceling...'} + +
+ {/* Animated progress bar */} +
+
+
+
+ )} + + {/* Goal ID */} +
+ Goal ID: + + {goalId.slice(0, 8)}...{goalId.slice(-8)} + +
+ + {/* Feedback */} + {goalStatus.last_feedback !== undefined && goalStatus.last_feedback !== null && ( +
+ + {isTerminal ? 'Result:' : 'Last Feedback:'} + +
+                            {JSON.stringify(goalStatus.last_feedback, null, 2)}
+                        
+
+ )} + + {/* Terminal state message */} + {isTerminal && ( +
+ + + {goalStatus.status === 'succeeded' && 'Action completed successfully'} + {goalStatus.status === 'canceled' && 'Action was canceled'} + {goalStatus.status === 'aborted' && 'Action was aborted due to an error'} + +
+ )} + + + ); +} diff --git a/src/components/ConfigurationPanel.tsx b/src/components/ConfigurationPanel.tsx new file mode 100644 index 0000000..18b0449 --- /dev/null +++ b/src/components/ConfigurationPanel.tsx @@ -0,0 +1,382 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Settings, Loader2, RefreshCw, Lock, Save, X, RotateCcw } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { useAppStore, type AppState } from '@/lib/store'; +import type { Parameter, ParameterType } from '@/lib/types'; + +interface ConfigurationPanelProps { + componentId: string; + /** Optional parameter name to highlight */ + highlightParam?: string; +} + +/** + * Get badge color for parameter type + */ +function getTypeBadgeVariant(type: ParameterType): 'default' | 'secondary' | 'outline' { + switch (type) { + case 'bool': + return 'default'; + case 'int': + case 'double': + return 'secondary'; + default: + return 'outline'; + } +} + +/** + * Parameter row component with inline editing + */ +function ParameterRow({ + param, + onSetParameter, + onResetParameter, + isHighlighted, +}: { + param: Parameter; + onSetParameter: (name: string, value: unknown) => Promise; + onResetParameter: (name: string) => Promise; + isHighlighted?: boolean; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isResetting, setIsResetting] = useState(false); + + const startEditing = useCallback(() => { + if (param.read_only) return; + setEditValue(formatValue(param.value, param.type)); + setIsEditing(true); + }, [param]); + + const cancelEditing = useCallback(() => { + setIsEditing(false); + setEditValue(''); + }, []); + + const saveValue = useCallback(async () => { + setIsSaving(true); + try { + const parsedValue = parseValue(editValue, param.type); + const success = await onSetParameter(param.name, parsedValue); + if (success) { + setIsEditing(false); + } + } finally { + setIsSaving(false); + } + }, [editValue, param, onSetParameter]); + + const resetValue = useCallback(async () => { + if (param.read_only) return; + setIsResetting(true); + try { + await onResetParameter(param.name); + } finally { + setIsResetting(false); + } + }, [param, onResetParameter]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + saveValue(); + } else if (e.key === 'Escape') { + cancelEditing(); + } + }, [saveValue, cancelEditing]); + + // Toggle for boolean parameters + const toggleBool = useCallback(async () => { + if (param.read_only || param.type !== 'bool') return; + setIsSaving(true); + try { + await onSetParameter(param.name, !param.value); + } finally { + setIsSaving(false); + } + }, [param, onSetParameter]); + + return ( +
+ {/* Parameter name */} +
+
+ {param.name} + {param.read_only && ( + + + + )} +
+ {param.description && ( +

+ {param.description} +

+ )} +
+ + {/* Type badge */} + + {param.type} + + + {/* Value display/edit */} +
+ {param.type === 'bool' ? ( + // Boolean toggle button + + ) : isEditing ? ( + // Editing mode +
+ setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + className="h-8 text-sm font-mono" + autoFocus + disabled={isSaving} + /> + + +
+ ) : ( + // Display mode - click to edit +
+ {formatValue(param.value, param.type)} +
+ )} +
+ + {/* Reset to default button */} + {!param.read_only && ( + + )} +
+ ); +} + +/** + * Format parameter value for display + */ +function formatValue(value: unknown, type: ParameterType): string { + if (value === null || value === undefined) return ''; + + if (type.endsWith('_array') || Array.isArray(value)) { + return JSON.stringify(value); + } + + return String(value); +} + +/** + * Parse string input to appropriate type + */ +function parseValue(input: string, type: ParameterType): unknown { + switch (type) { + case 'bool': + return input.toLowerCase() === 'true'; + case 'int': + return parseInt(input, 10); + case 'double': + return parseFloat(input); + case 'string': + return input; + case 'byte_array': + case 'bool_array': + case 'int_array': + case 'double_array': + case 'string_array': + try { + return JSON.parse(input); + } catch { + return input; + } + default: + return input; + } +} + +export function ConfigurationPanel({ componentId, highlightParam }: ConfigurationPanelProps) { + const { + configurations, + isLoadingConfigurations, + fetchConfigurations, + setParameter, + resetParameter, + resetAllConfigurations, + } = useAppStore( + useShallow((state: AppState) => ({ + configurations: state.configurations, + isLoadingConfigurations: state.isLoadingConfigurations, + fetchConfigurations: state.fetchConfigurations, + setParameter: state.setParameter, + resetParameter: state.resetParameter, + resetAllConfigurations: state.resetAllConfigurations, + })) + ); + + const [isResettingAll, setIsResettingAll] = useState(false); + const parameters = configurations.get(componentId) || []; + + // Fetch configurations on mount (lazy loading) + useEffect(() => { + if (!configurations.has(componentId)) { + fetchConfigurations(componentId); + } + }, [componentId, configurations, fetchConfigurations]); + + const handleRefresh = useCallback(() => { + fetchConfigurations(componentId); + }, [componentId, fetchConfigurations]); + + const handleSetParameter = useCallback( + async (name: string, value: unknown) => { + return setParameter(componentId, name, value); + }, + [componentId, setParameter] + ); + + const handleResetParameter = useCallback( + async (name: string) => { + return resetParameter(componentId, name); + }, + [componentId, resetParameter] + ); + + const handleResetAll = useCallback(async () => { + setIsResettingAll(true); + try { + await resetAllConfigurations(componentId); + } finally { + setIsResettingAll(false); + } + }, [componentId, resetAllConfigurations]); + + if (isLoadingConfigurations && parameters.length === 0) { + return ( + + + + + + ); + } + + return ( + + +
+
+ + Configurations + + ({parameters.length} parameters) + +
+
+ + +
+
+
+ + {parameters.length === 0 ? ( +
+ No parameters available for this component. +
+ ) : ( +
+ {parameters.map((param) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/DataFolderPanel.tsx b/src/components/DataFolderPanel.tsx new file mode 100644 index 0000000..cd38a66 --- /dev/null +++ b/src/components/DataFolderPanel.tsx @@ -0,0 +1,147 @@ +import { useEffect, useCallback } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Database, Loader2, RefreshCw, Radio, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useAppStore, type AppState } from '@/lib/store'; + +interface DataFolderPanelProps { + /** Base path for navigation (e.g., /root/route_server) */ + basePath: string; +} + +export function DataFolderPanel({ basePath }: DataFolderPanelProps) { + const { + rootEntities, + selectEntity, + loadChildren, + expandedPaths, + toggleExpanded, + } = useAppStore( + useShallow((state: AppState) => ({ + client: state.client, + rootEntities: state.rootEntities, + selectEntity: state.selectEntity, + loadChildren: state.loadChildren, + expandedPaths: state.expandedPaths, + toggleExpanded: state.toggleExpanded, + })) + ); + + // Find the data folder node in the tree + const dataFolderPath = `${basePath}/data`; + const findNode = useCallback((nodes: typeof rootEntities, path: string): typeof rootEntities[0] | null => { + for (const node of nodes) { + if (node.path === path) return node; + if (node.children) { + const found = findNode(node.children, path); + if (found) return found; + } + } + return null; + }, []); + + const dataFolder = findNode(rootEntities, dataFolderPath); + const topics = dataFolder?.children || []; + const isLoading = !dataFolder?.children && dataFolder !== null; + + // Load children if not loaded yet + useEffect(() => { + if (dataFolder && !dataFolder.children) { + loadChildren(dataFolderPath); + } + }, [dataFolder, dataFolderPath, loadChildren]); + + const handleRefresh = useCallback(() => { + loadChildren(dataFolderPath); + }, [dataFolderPath, loadChildren]); + + const handleTopicClick = useCallback((topicPath: string) => { + // Expand the data folder if not expanded + if (!expandedPaths.includes(dataFolderPath)) { + toggleExpanded(dataFolderPath); + } + // Navigate to topic + selectEntity(topicPath); + }, [dataFolderPath, expandedPaths, toggleExpanded, selectEntity]); + + if (isLoading) { + return ( + + + + + + ); + } + + return ( + + +
+
+ + Topics + + ({topics.length} topics) + +
+ +
+
+ + {topics.length === 0 ? ( +
+ No topics available for this component. +
+ ) : ( +
+ {topics.map((topic) => { + // Extract direction info from topic data + const topicData = topic.data as { isPublisher?: boolean; isSubscriber?: boolean; type?: string } | undefined; + const isPublisher = topicData?.isPublisher ?? false; + const isSubscriber = topicData?.isSubscriber ?? false; + const topicType = topicData?.type || 'Unknown'; + + return ( +
handleTopicClick(topic.path)} + > + +
+
{topic.name}
+
+ {topicType} +
+
+ {/* Direction indicators */} +
+ {isPublisher && ( + + + + )} + {isSubscriber && ( + + + + )} +
+ +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 31dbb76..186d653 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -1,17 +1,310 @@ +import { useState } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Copy, Loader2, Radio, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react'; +import { Copy, Loader2, Radio, ChevronRight, ArrowUp, ArrowDown, Database, Zap, Settings, RefreshCw, Box } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { EmptyState } from '@/components/EmptyState'; import { TopicDiagnosticsPanel } from '@/components/TopicDiagnosticsPanel'; +import { ConfigurationPanel } from '@/components/ConfigurationPanel'; +import { OperationsPanel } from '@/components/OperationsPanel'; +import { DataFolderPanel } from '@/components/DataFolderPanel'; import { useAppStore, type AppState } from '@/lib/store'; -import type { ComponentTopic } from '@/lib/types'; +import type { ComponentTopic, Parameter } from '@/lib/types'; + +type ComponentTab = 'data' | 'operations' | 'configurations'; + +interface TabConfig { + id: ComponentTab; + label: string; + icon: typeof Database; + description: string; +} + +const COMPONENT_TABS: TabConfig[] = [ + { id: 'data', label: 'Data', icon: Database, description: 'Topics & messages' }, + { id: 'operations', label: 'Operations', icon: Zap, description: 'Services & actions' }, + { id: 'configurations', label: 'Configurations', icon: Settings, description: 'Parameters' }, +]; + +/** + * Component tab content - renders based on active tab + */ +interface ComponentTabContentProps { + activeTab: ComponentTab; + componentId: string; + selectedPath: string; + selectedEntity: NonNullable; + hasTopicsArray: boolean; + hasTopicsInfo: boolean; + selectEntity: (path: string) => void; +} + +function ComponentTabContent({ + activeTab, + componentId, + selectedPath, + selectedEntity, + hasTopicsArray, + hasTopicsInfo, + selectEntity, +}: ComponentTabContentProps) { + switch (activeTab) { + case 'data': + return ( + + ); + case 'operations': + return ; + case 'configurations': + return ; + default: + return null; + } +} + +/** + * Data tab content - shows topics + */ +interface DataTabContentProps { + selectedPath: string; + selectedEntity: NonNullable; + hasTopicsArray: boolean; + hasTopicsInfo: boolean; + selectEntity: (path: string) => void; +} + +function DataTabContent({ + selectedPath, + selectedEntity, + hasTopicsArray, + hasTopicsInfo, + selectEntity, +}: DataTabContentProps) { + if (hasTopicsArray) { + return ( + + +
+ + Topics + + ({(selectedEntity.topics as ComponentTopic[]).length} topics) + +
+
+ +
+ {(selectedEntity.topics as ComponentTopic[]).map((topic) => { + const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; + const encodedName = encodeURIComponent(cleanName); + const topicPath = `${selectedPath}/data/${encodedName}`; + + return ( +
selectEntity(topicPath)} + > + +
+
{topic.topic}
+
+ {topic.type || 'Unknown Type'} +
+
+ +
+ ); + })} +
+
+
+ ); + } + + if (hasTopicsInfo) { + const topicsInfo = selectedEntity.topicsInfo as NonNullable; + return ( +
+ {/* Publishes Section */} + {topicsInfo.publishes.length > 0 && ( + + +
+ + Publishes + {topicsInfo.publishes.length} +
+
+ +
+ {topicsInfo.publishes.map((topic: string) => { + const cleanName = topic.startsWith('/') ? topic.slice(1) : topic; + const encodedName = encodeURIComponent(cleanName); + const topicPath = `${selectedPath}/data/${encodedName}`; + + return ( +
selectEntity(topicPath)} + > + + {topic} + +
+ ); + })} +
+
+
+ )} + + {/* Subscribes Section */} + {topicsInfo.subscribes.length > 0 && ( + + +
+ + Subscribes + {topicsInfo.subscribes.length} +
+
+ +
+ {topicsInfo.subscribes.map((topic: string) => { + const cleanName = topic.startsWith('/') ? topic.slice(1) : topic; + const encodedName = encodeURIComponent(cleanName); + const topicPath = `${selectedPath}/data/${encodedName}`; + + return ( +
selectEntity(topicPath)} + > + + {topic} + +
+ ); + })} +
+
+
+ )} +
+ ); + } + + return ( + + +
+ No topics available for this component. +
+
+
+ ); +} + +/** + * Operation (Service/Action) detail card + * Shows the full OperationsPanel with the selected operation highlighted + */ +interface OperationDetailCardProps { + entity: NonNullable; + componentId: string; +} + +function OperationDetailCard({ entity, componentId }: OperationDetailCardProps) { + // Render full OperationsPanel with the specific operation highlighted + return ; +} + +/** + * Parameter detail card + */ +interface ParameterDetailCardProps { + entity: NonNullable; + componentId: string; +} + +function ParameterDetailCard({ entity, componentId }: ParameterDetailCardProps) { + const parameterData = entity.data as Parameter | undefined; + + if (!parameterData) { + return ( + + +

+ Parameter data not available. Select from the Configurations tab. +

+
+
+ ); + } + + return ( + + +
+
+ +
+
+ {entity.name} + + {parameterData.type} + {parameterData.read_only && Read-only} + +
+
+
+ + + +
+ ); +} + +/** + * Virtual folder content - redirect to appropriate panel + */ +interface VirtualFolderContentProps { + folderType: 'data' | 'operations' | 'configurations'; + componentId: string; + basePath: string; +} + +function VirtualFolderContent({ folderType, componentId, basePath }: VirtualFolderContentProps) { + switch (folderType) { + case 'data': + return ; + case 'operations': + return ; + case 'configurations': + return ; + default: + return null; + } +} + interface EntityDetailPanelProps { onConnectClick: () => void; } export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { + const [activeTab, setActiveTab] = useState('data'); + const { selectedPath, selectedEntity, @@ -79,28 +372,75 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { (selectedEntity.topicsInfo.subscribes?.length ?? 0) > 0); const hasError = !!selectedEntity.error; + // Extract component ID from path for component views + const pathParts = selectedPath.split('/').filter(Boolean); + const componentId = pathParts.length >= 2 ? pathParts[1] : pathParts[0]; + return (
- {/* Component Header */} + {/* Component Header with Dashboard style */}
-
- {selectedEntity.name} - - {selectedEntity.type} • {selectedPath} - +
+
+ +
+
+ {selectedEntity.name} + + {selectedEntity.type} + + {selectedPath} + +
+
+
+ +
-
+ + {/* Tab Navigation for Components */} + {isComponent && ( +
+
+ {COMPONENT_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+
+ )} - {/* Content */} + {/* Content based on entity type and active tab */} {hasError ? ( @@ -114,9 +454,6 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { // Single Topic View - use TopicDiagnosticsPanel (() => { const topic = selectedEntity.topicData!; - // Extract component ID from path /area/component/topic - const componentId = selectedPath.split('/')[2]; - return ( ); })() - ) : hasTopicsArray ? ( - // Component view with full topics array (type, QoS, publishers info) -
- {/* Topics List - Rich View with Type and QoS info */} -
- {(selectedEntity.topics as ComponentTopic[]).map((topic) => { - const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; - const encodedName = encodeURIComponent(cleanName); - const topicPath = `${selectedPath}/${encodedName}`; - - return ( - selectEntity(topicPath)} - > - -
- -
-
{topic.topic}
-
- {topic.type || 'Unknown Type'} -
-
- -
-
-
- ); - })} -
-
- ) : hasTopicsInfo ? ( - // Component view with publishes/subscribes arrays + ) : isComponent ? ( + // Component Dashboard with Tabs + + ) : selectedEntity.type === 'service' || selectedEntity.type === 'action' ? ( + // Service/Action detail view + + ) : selectedEntity.type === 'parameter' ? ( + // Parameter detail view + + ) : selectedEntity.folderType ? ( + // Virtual folder selected - show appropriate panel (() => { - // Safe to access - hasTopicsInfo already verified this exists - const topicsInfo = selectedEntity.topicsInfo as NonNullable; + // Extract base path (component path) from folder path + // e.g., /root/route_server/data -> /root/route_server + const folderPathParts = selectedPath.split('/'); + folderPathParts.pop(); // Remove folder name (data/operations/configurations) + const basePath = folderPathParts.join('/'); return ( -
- {/* Publishes Section */} - {topicsInfo.publishes.length > 0 && ( - - -
- - Publishes - - ({topicsInfo.publishes.length} topics) - -
-
- -
- {topicsInfo.publishes.map((topic: string) => { - const cleanName = topic.startsWith('/') ? topic.slice(1) : topic; - const encodedName = encodeURIComponent(cleanName); - const topicPath = `${selectedPath}/${encodedName}`; - - return ( -
selectEntity(topicPath)} - > - - {topic} - -
- ); - })} -
-
-
- )} - - {/* Subscribes Section */} - {topicsInfo.subscribes.length > 0 && ( - - -
- - Subscribes - - ({topicsInfo.subscribes.length} topics) - -
-
- -
- {topicsInfo.subscribes.map((topic: string) => { - const cleanName = topic.startsWith('/') ? topic.slice(1) : topic; - const encodedName = encodeURIComponent(cleanName); - const topicPath = `${selectedPath}/${encodedName}`; - - return ( -
selectEntity(topicPath)} - > - - {topic} - -
- ); - })} -
-
-
- )} -
+ ); })() ) : ( diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index 6bdcc06..ec7c24b 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -1,10 +1,11 @@ import { useEffect } from 'react'; import { useShallow } from 'zustand/shallow'; -import { ChevronRight, Loader2, Server, Folder, FileJson, Box, MessageSquare, ArrowUp, ArrowDown } from 'lucide-react'; +import { ChevronRight, Loader2, Server, Folder, FileJson, Box, MessageSquare, ArrowUp, ArrowDown, Database, Zap, Clock, Settings, Sliders } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useAppStore } from '@/lib/store'; -import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData } from '@/lib/types'; +import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData, VirtualFolderData, Parameter } from '@/lib/types'; +import { isVirtualFolderData } from '@/lib/types'; interface EntityTreeNodeProps { node: EntityTreeNodeType; @@ -14,7 +15,20 @@ interface EntityTreeNodeProps { /** * Get icon for entity type */ -function getEntityIcon(type: string) { +function getEntityIcon(type: string, data?: unknown) { + // Check for virtual folder types + if (isVirtualFolderData(data)) { + const folderData = data as VirtualFolderData; + switch (folderData.folderType) { + case 'data': + return Database; + case 'operations': + return Zap; + case 'configurations': + return Settings; + } + } + switch (type.toLowerCase()) { case 'device': case 'server': @@ -27,6 +41,12 @@ function getEntityIcon(type: string) { return Folder; case 'topic': return MessageSquare; + case 'service': + return Zap; + case 'action': + return Clock; + case 'parameter': + return Sliders; default: return FileJson; } @@ -39,6 +59,13 @@ function isTopicNodeData(data: unknown): data is TopicNodeData { return !!data && typeof data === 'object' && 'isPublisher' in data && 'isSubscriber' in data; } +/** + * Check if node data is Parameter + */ +function isParameterData(data: unknown): data is Parameter { + return !!data && typeof data === 'object' && 'type' in data && 'value' in data && !('kind' in data); +} + export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { const { expandedPaths, @@ -62,10 +89,11 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { const isLoading = loadingPaths.includes(node.path); const isSelected = selectedPath === node.path; const hasChildren = node.hasChildren !== false; // Default to true if not specified - const Icon = getEntityIcon(node.type); + const Icon = getEntityIcon(node.type, node.data); // Get topic direction info if available const topicData = isTopicNodeData(node.data) ? node.data : null; + const parameterData = isParameterData(node.data) ? node.data : null; // Load children when expanded and no children loaded yet useEffect(() => { @@ -135,6 +163,14 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) {
)} + {/* Parameter value indicator */} + {parameterData && ( + + {String(parameterData.value)} + + )} + + {/* Type label */} - {node.children?.map((child) => ( - - ))} + {node.children && node.children.length > 0 ? ( + node.children.map((child) => ( + + )) + ) : ( + // Empty state for folders with no children (after loading) + !isLoading && node.children !== undefined && ( +
+ + Empty +
+ ) + )} )} diff --git a/src/components/OperationResponse.tsx b/src/components/OperationResponse.tsx new file mode 100644 index 0000000..bc197e6 --- /dev/null +++ b/src/components/OperationResponse.tsx @@ -0,0 +1,149 @@ +import { CheckCircle, XCircle, Clock, Zap, Hash } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import type { OperationResponse } from '@/lib/types'; + +interface OperationResponseProps { + response: OperationResponse; +} + +/** + * Renders a value with appropriate styling based on type + */ +function ValueDisplay({ value, depth = 0 }: { value: unknown; depth?: number }) { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === 'boolean') { + return ( + + {value ? 'true' : 'false'} + + ); + } + + if (typeof value === 'number') { + return {value}; + } + + if (typeof value === 'string') { + if (value === '') { + return (empty); + } + // Check if it's a UUID-like string + if (/^[a-f0-9]{32}$/i.test(value)) { + return ( + + {value.slice(0, 8)}...{value.slice(-8)} + + ); + } + return "{value}"; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return []; + } + return ( +
+ {value.map((item, idx) => ( +
+ [{idx}] + +
+ ))} +
+ ); + } + + if (typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return {'{}'}; + } + return ( +
0 ? 'pl-3 border-l-2 border-muted' : ''}> + {entries.map(([key, val]) => ( +
+ + {key}: + + +
+ ))} +
+ ); + } + + return {String(value)}; +} + +export function OperationResponseDisplay({ response }: OperationResponseProps) { + const isSuccess = response.status === 'success'; + const isAction = response.kind === 'action'; + const StatusIcon = isSuccess ? CheckCircle : XCircle; + const KindIcon = isAction ? Clock : Zap; + + return ( +
+ {/* Header */} +
+ +
+ + {response.status} + + + + {response.kind} + +
+ + {response.operation} + +
+ + {/* Body */} +
+ {/* Action-specific: Goal ID */} + {isAction && 'goal_id' in response && response.goal_id && ( +
+ + Goal ID: + + {response.goal_id} + +
+ )} + + {/* Action-specific: Initial status */} + {isAction && 'goal_status' in response && response.goal_status && ( +
+ + Initial Status: + {response.goal_status} +
+ )} + + {/* Service response data */} + {'response' in response && response.response !== undefined && ( +
+
Response Data:
+
+ +
+
+ )} + + {/* Error message */} + {'error' in response && response.error && ( +
+ + {response.error} +
+ )} +
+
+ ); +} diff --git a/src/components/OperationsPanel.tsx b/src/components/OperationsPanel.tsx new file mode 100644 index 0000000..bc88f04 --- /dev/null +++ b/src/components/OperationsPanel.tsx @@ -0,0 +1,515 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Play, Loader2, RefreshCw, Zap, Clock, ChevronDown, ChevronUp, FileJson, FormInput, AlertCircle, History, Trash2 } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Textarea } from '@/components/ui/textarea'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { useAppStore, type AppState } from '@/lib/store'; +import type { Operation, OperationKind, OperationResponse, TopicSchema, ServiceSchema, ActionSchema } from '@/lib/types'; +import { ActionStatusPanel } from './ActionStatusPanel'; +import { SchemaForm } from './SchemaFormField'; +import { getSchemaDefaults } from '@/lib/schema-utils'; +import { OperationResponseDisplay } from './OperationResponse'; + +/** History entry for an operation invocation */ +interface OperationHistoryEntry { + id: string; + timestamp: Date; + response: OperationResponse; + goalId?: string; +} + +interface OperationsPanelProps { + componentId: string; + /** Optional: highlight and auto-expand a specific operation */ + highlightOperation?: string; +} + +/** + * Get badge color for operation kind + */ +function getKindBadgeVariant(kind: OperationKind): 'default' | 'secondary' { + return kind === 'service' ? 'default' : 'secondary'; +} + +/** + * Get icon for operation kind + */ +function getKindIcon(kind: OperationKind) { + return kind === 'service' ? Zap : Clock; +} + +/** + * Check if schema is a ServiceSchema (has request/response) + */ +function isServiceSchema(schema: ServiceSchema | ActionSchema): schema is ServiceSchema { + return 'request' in schema && 'response' in schema; +} + +/** + * Check if schema is an ActionSchema (has goal/result/feedback) + */ +function isActionSchema(schema: ServiceSchema | ActionSchema): schema is ActionSchema { + return 'goal' in schema; +} + +/** + * Get the request/goal schema based on operation kind + */ +function getInputSchema(operation: Operation): TopicSchema | null { + if (!operation.type_info?.schema) return null; + + const schema = operation.type_info.schema; + if (operation.kind === 'service' && isServiceSchema(schema)) { + return schema.request; + } + if (operation.kind === 'action' && isActionSchema(schema)) { + return schema.goal; + } + return null; +} + +/** + * Check if a schema is empty (no fields) + */ +function isEmptySchema(schema: TopicSchema | null): boolean { + if (!schema) return true; + return Object.keys(schema).length === 0; +} + +/** + * Single operation row with invoke capability + */ +function OperationRow({ + operation, + componentId, + onInvoke, + defaultExpanded = false, +}: { + operation: Operation; + componentId: string; + onInvoke: (opName: string, payload: unknown) => Promise; + defaultExpanded?: boolean; +}) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [useFormView, setUseFormView] = useState(true); + const [requestBody, setRequestBody] = useState('{}'); + const [formData, setFormData] = useState>({}); + const [isInvoking, setIsInvoking] = useState(false); + const [history, setHistory] = useState([]); + const [showHistory, setShowHistory] = useState(false); + + const KindIcon = getKindIcon(operation.kind); + const inputSchema = getInputSchema(operation); + const hasInputFields = !isEmptySchema(inputSchema); + const hasSchema = !!inputSchema; + + // Get latest entry for action status monitoring + const latestEntry = history[0]; + const latestGoalId = latestEntry?.goalId; + + // Initialize form data with schema defaults + useEffect(() => { + if (inputSchema && Object.keys(inputSchema).length > 0) { + const defaults = getSchemaDefaults(inputSchema); + setFormData(defaults); + setRequestBody(JSON.stringify(defaults, null, 2)); + } + }, [inputSchema]); + + // Sync form data to JSON when form changes + const handleFormChange = useCallback((newData: Record) => { + setFormData(newData); + setRequestBody(JSON.stringify(newData, null, 2)); + }, []); + + // Sync JSON to form data when JSON changes + const handleJsonChange = useCallback((json: string) => { + setRequestBody(json); + try { + const parsed = JSON.parse(json); + if (typeof parsed === 'object' && parsed !== null) { + setFormData(parsed); + } + } catch { + // Invalid JSON, don't update form + } + }, []); + + const handleInvoke = useCallback(async () => { + setIsInvoking(true); + + try { + let payload: unknown; + try { + payload = JSON.parse(requestBody); + } catch { + payload = {}; + } + + // Build request based on operation kind + const request = operation.kind === 'service' + ? { type: operation.type, request: payload } + : { type: operation.type, goal: payload }; + + const response = await onInvoke(operation.name, request); + + if (response) { + // Add to history (newest first, max 10 entries) + const entry: OperationHistoryEntry = { + id: crypto.randomUUID(), + timestamp: new Date(), + response, + goalId: response.kind === 'action' && response.status === 'success' ? response.goal_id : undefined, + }; + setHistory(prev => [entry, ...prev.slice(0, 9)]); + } + } finally { + setIsInvoking(false); + } + }, [operation, requestBody, onInvoke]); + + const clearHistory = useCallback(() => { + setHistory([]); + }, []); + + return ( + +
+ {/* Operation header - simplified, no button */} + +
+ + +
+
+ {operation.name} + + {operation.kind} + + {!hasInputFields && ( + + no params + + )} + {history.length > 0 && ( + + {history.length} call{history.length > 1 ? 's' : ''} + + )} +
+

+ {operation.type} +

+
+ + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Expanded content */} + +
+ {/* Input section - Form or JSON */} + {hasInputFields ? ( +
+
+ + {hasSchema && ( +
+ + +
+ )} +
+ + {useFormView && hasSchema && inputSchema ? ( +
+ + {/* Invoke button inside form */} + +
+ ) : ( +
+