diff --git a/src/components/ActionStatusPanel.tsx b/src/components/ActionStatusPanel.tsx
new file mode 100644
index 0000000..cd95fdc
--- /dev/null
+++ b/src/components/ActionStatusPanel.tsx
@@ -0,0 +1,236 @@
+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..1fc0b6b
--- /dev/null
+++ b/src/components/ConfigurationPanel.tsx
@@ -0,0 +1,392 @@
+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
+
{
+ if (!param.read_only && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ startEditing();
+ }
+ }}
+ role={param.read_only ? undefined : 'button'}
+ tabIndex={param.read_only ? undefined : 0}
+ aria-label={param.read_only ? `${param.name}: ${formatValue(param.value, param.type)} (read-only)` : `Edit ${param.name}`}
+ title={param.read_only ? 'Read-only parameter' : '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 empty array instead of invalid string to prevent type mismatch
+ return [];
+ }
+ 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..80b6b65
--- /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 (standard format with hyphens)
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value)) {
+ return (
+
+ {value.slice(0, 8)}...{value.slice(-12)}
+
+ );
+ }
+ 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 && (
+
+ )}
+
+ {/* 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..ba5b785
--- /dev/null
+++ b/src/components/OperationsPanel.tsx
@@ -0,0 +1,533 @@
+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
+ }
+ }, []);
+
+ // Track JSON validation error
+ const [jsonError, setJsonError] = useState(null);
+
+ const handleInvoke = useCallback(async () => {
+ // Validate JSON before invoking
+ let payload: unknown;
+ try {
+ payload = JSON.parse(requestBody);
+ setJsonError(null);
+ } catch (e) {
+ const errorMsg = e instanceof Error ? e.message : 'Invalid JSON';
+ setJsonError(errorMsg);
+ return; // Don't invoke with invalid JSON
+ }
+
+ setIsInvoking(true);
+
+ try {
+ // 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 */}
+
+
+ ) : (
+
+ )}
+
+ ) : (
+ /* No params - just show button */
+
+
+
+ This {operation.kind} takes no parameters
+
+
+
+ )}
+
+ {/* Action status monitoring for latest action */}
+ {latestGoalId && operation.kind === 'action' && (
+
+ )}
+
+ {/* History section */}
+ {history.length > 0 && (
+
+
+
+ {showHistory && (
+
+ )}
+
+
+ {showHistory && (
+
+ {history.map((entry, idx) => (
+
+ {idx === 0 && (
+
+ latest
+
+ )}
+
+ {entry.timestamp.toLocaleTimeString()}
+
+
+
+ ))}
+
+ )}
+
+ {/* Show only latest response when history is collapsed */}
+ {!showHistory && latestEntry && (
+
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+export function OperationsPanel({ componentId, highlightOperation }: OperationsPanelProps) {
+ const {
+ operations,
+ isLoadingOperations,
+ fetchOperations,
+ invokeOperation,
+ } = useAppStore(
+ useShallow((state: AppState) => ({
+ operations: state.operations,
+ isLoadingOperations: state.isLoadingOperations,
+ fetchOperations: state.fetchOperations,
+ invokeOperation: state.invokeOperation,
+ }))
+ );
+
+ const componentOperations = operations.get(componentId) || [];
+ const services = componentOperations.filter(op => op.kind === 'service');
+ const actions = componentOperations.filter(op => op.kind === 'action');
+
+ // Fetch operations on mount (lazy loading)
+ useEffect(() => {
+ if (!operations.has(componentId)) {
+ fetchOperations(componentId);
+ }
+ }, [componentId, operations, fetchOperations]);
+
+ const handleRefresh = useCallback(() => {
+ fetchOperations(componentId);
+ }, [componentId, fetchOperations]);
+
+ const handleInvoke = useCallback(
+ async (opName: string, payload: unknown) => {
+ return invokeOperation(componentId, opName, payload as Parameters[2]);
+ },
+ [componentId, invokeOperation]
+ );
+
+ if (isLoadingOperations && componentOperations.length === 0) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Operations
+
+ ({services.length} services, {actions.length} actions)
+
+
+
+
+
+
+ {componentOperations.length === 0 ? (
+
+
+
No operations available
+
This component has no services or actions
+
+ ) : (
+
+ {/* Services section */}
+ {services.length > 0 && (
+
+
+
+ Services
+
+
+ {services.map((op) => (
+
+ ))}
+
+
+ )}
+
+ {/* Actions section */}
+ {actions.length > 0 && (
+
+
+
+ Actions
+
+
+ {actions.map((op) => (
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/index.css b/src/index.css
index ac8339c..68c2f93 100644
--- a/src/index.css
+++ b/src/index.css
@@ -29,32 +29,32 @@
:root {
--radius: 0.5rem;
-
+
/* Green Theme Light */
--background: oklch(0.985 0.005 145); /* Very light green tint */
--foreground: oklch(0.145 0.02 145);
-
+
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.02 145);
-
+
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.02 145);
-
+
--primary: oklch(0.55 0.15 145); /* Vibrant Green */
--primary-foreground: oklch(0.985 0 0);
-
+
--secondary: oklch(0.95 0.03 145); /* Light Green */
--secondary-foreground: oklch(0.35 0.1 145);
-
+
--muted: oklch(0.95 0.02 145);
--muted-foreground: oklch(0.50 0.05 145);
-
+
--accent: oklch(0.93 0.04 145);
--accent-foreground: oklch(0.35 0.1 145);
-
+
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
-
+
--border: oklch(0.90 0.03 145);
--input: oklch(0.90 0.03 145);
--ring: oklch(0.55 0.15 145);
@@ -64,28 +64,28 @@
/* Green Theme Dark */
--background: oklch(0.12 0.02 145); /* Dark Green-Black */
--foreground: oklch(0.985 0.005 145);
-
+
--card: oklch(0.15 0.02 145);
--card-foreground: oklch(0.985 0.005 145);
-
+
--popover: oklch(0.15 0.02 145);
--popover-foreground: oklch(0.985 0.005 145);
-
+
--primary: oklch(0.65 0.15 145); /* Brighter Green for dark mode */
--primary-foreground: oklch(0.12 0.02 145);
-
+
--secondary: oklch(0.25 0.04 145);
--secondary-foreground: oklch(0.985 0.005 145);
-
+
--muted: oklch(0.25 0.04 145);
--muted-foreground: oklch(0.70 0.05 145);
-
+
--accent: oklch(0.25 0.04 145);
--accent-foreground: oklch(0.985 0.005 145);
-
+
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
-
+
--border: oklch(0.25 0.04 145);
--input: oklch(0.25 0.04 145);
--ring: oklch(0.65 0.15 145);
@@ -99,3 +99,22 @@
@apply bg-background text-foreground;
}
}
+
+/* Custom animations */
+@keyframes progress-indeterminate {
+ 0% {
+ transform: translateX(-100%);
+ width: 40%;
+ }
+ 50% {
+ width: 60%;
+ }
+ 100% {
+ transform: translateX(250%);
+ width: 40%;
+ }
+}
+
+.animate-progress-indeterminate {
+ animation: progress-indeterminate 1.5s ease-in-out infinite;
+}
diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts
index c100351..8fd00b3 100644
--- a/src/lib/sovd-api.ts
+++ b/src/lib/sovd-api.ts
@@ -1,4 +1,23 @@
-import type { SovdEntity, SovdEntityDetails, ComponentTopic, ComponentTopicPublishRequest, ComponentTopicsInfo } from './types';
+import type {
+ SovdEntity,
+ SovdEntityDetails,
+ ComponentTopic,
+ ComponentTopicPublishRequest,
+ ComponentTopicsInfo,
+ ComponentConfigurations,
+ ConfigurationDetail,
+ SetConfigurationRequest,
+ SetConfigurationResponse,
+ ResetConfigurationResponse,
+ ResetAllConfigurationsResponse,
+ Operation,
+ InvokeOperationRequest,
+ OperationResponse,
+ ActionGoalStatus,
+ AllActionGoalsStatus,
+ ActionGoalResult,
+ ActionCancelResponse,
+} from './types';
/**
* Timeout wrapper for fetch requests.
@@ -199,7 +218,43 @@ export class SovdApiClient {
// Path comes from the tree, e.g. "/area_id/component_id"
const parts = path.split('/').filter(p => p);
- // Level 3: /area/component/topic -> fetch topic details
+ // Handle virtual folder paths: /area/component/data/topic
+ // Transform to: /area/component/topic for API call
+ if (parts.length === 4 && parts[2] === 'data') {
+ const componentId = parts[1];
+ const encodedTopicName = parts[3];
+
+ // Decode topic name using standard percent-decoding
+ const decodedTopicName = decodeURIComponent(encodedTopicName);
+
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/data/${encodedTopicName}`),
+ {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' },
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error(`Topic ${decodedTopicName} not found for component ${componentId}`);
+ }
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const topic = await response.json() as ComponentTopic;
+
+ return {
+ id: encodedTopicName,
+ name: topic.topic || `/${decodedTopicName}`,
+ href: path,
+ topicData: topic,
+ rosType: topic.type,
+ type: 'topic',
+ };
+ }
+
+ // Level 3: /area/component/topic -> fetch topic details (legacy path format)
if (parts.length === 3) {
const componentId = parts[1];
const encodedTopicName = parts[2];
@@ -330,6 +385,306 @@ export class SovdApiClient {
return await response.json();
}
+ // ===========================================================================
+ // CONFIGURATIONS API (ROS 2 Parameters)
+ // ===========================================================================
+
+ /**
+ * List all configurations (parameters) for a component
+ * @param componentId Component ID
+ */
+ async listConfigurations(componentId: string): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/configurations`),
+ {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Get a specific configuration (parameter) value and metadata
+ * @param componentId Component ID
+ * @param paramName Parameter name
+ */
+ async getConfiguration(componentId: string, paramName: string): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/configurations/${encodeURIComponent(paramName)}`),
+ {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Set a configuration (parameter) value
+ * @param componentId Component ID
+ * @param paramName Parameter name
+ * @param request Request with new value
+ */
+ async setConfiguration(
+ componentId: string,
+ paramName: string,
+ request: SetConfigurationRequest
+ ): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/configurations/${encodeURIComponent(paramName)}`),
+ {
+ method: 'PUT',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(request),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Reset a configuration (parameter) to its default value
+ * @param componentId Component ID
+ * @param paramName Parameter name
+ */
+ async resetConfiguration(
+ componentId: string,
+ paramName: string
+ ): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/configurations/${encodeURIComponent(paramName)}`),
+ {
+ method: 'DELETE',
+ headers: {
+ 'Accept': 'application/json',
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Reset all configurations for a component to their default values
+ * @param componentId Component ID
+ */
+ async resetAllConfigurations(
+ componentId: string
+ ): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/configurations`),
+ {
+ method: 'DELETE',
+ headers: {
+ 'Accept': 'application/json',
+ },
+ }
+ );
+
+ // Accept both 200 (full success) and 207 (partial success)
+ if (!response.ok && response.status !== 207) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ // ===========================================================================
+ // OPERATIONS API (ROS 2 Services & Actions)
+ // ===========================================================================
+
+ /**
+ * List all operations (services + actions) for a component
+ * This data comes from the component operations endpoint
+ * @param componentId Component ID
+ */
+ async listOperations(componentId: string): Promise {
+ // Fetch from dedicated operations endpoint which includes type_info with schema
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/operations`),
+ {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' },
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ // Component not found or has no operations
+ return [];
+ }
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Invoke an operation (service call or action goal)
+ * @param componentId Component ID
+ * @param operationName Operation name
+ * @param request Request data (request for services, goal for actions)
+ */
+ async invokeOperation(
+ componentId: string,
+ operationName: string,
+ request: InvokeOperationRequest
+ ): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/operations/${encodeURIComponent(operationName)}`),
+ {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(request),
+ },
+ 30000 // 30 second timeout for operations
+ );
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Get action goal status
+ * @param componentId Component ID
+ * @param operationName Action name
+ * @param goalId Optional specific goal ID
+ */
+ async getActionStatus(
+ componentId: string,
+ operationName: string,
+ goalId?: string
+ ): Promise {
+ const url = goalId
+ ? this.getUrl(`components/${componentId}/operations/${encodeURIComponent(operationName)}/status?goal_id=${encodeURIComponent(goalId)}`)
+ : this.getUrl(`components/${componentId}/operations/${encodeURIComponent(operationName)}/status`);
+
+ const response = await fetchWithTimeout(url, {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' },
+ });
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Get all action goals status for an operation
+ * @param componentId Component ID
+ * @param operationName Action name
+ */
+ async getAllActionGoalsStatus(
+ componentId: string,
+ operationName: string
+ ): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/operations/${encodeURIComponent(operationName)}/status?all=true`),
+ {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Get action goal result (for completed goals)
+ * @param componentId Component ID
+ * @param operationName Action name
+ * @param goalId Goal UUID
+ */
+ async getActionResult(
+ componentId: string,
+ operationName: string,
+ goalId: string
+ ): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/operations/${encodeURIComponent(operationName)}/result?goal_id=${encodeURIComponent(goalId)}`),
+ {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
+ /**
+ * Cancel an action goal
+ * @param componentId Component ID
+ * @param operationName Action name
+ * @param goalId Goal UUID to cancel
+ */
+ async cancelAction(
+ componentId: string,
+ operationName: string,
+ goalId: string
+ ): Promise {
+ const response = await fetchWithTimeout(
+ this.getUrl(`components/${componentId}/operations/${encodeURIComponent(operationName)}?goal_id=${encodeURIComponent(goalId)}`),
+ {
+ method: 'DELETE',
+ headers: { 'Accept': 'application/json' },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string; details?: string };
+ throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ }
+
}
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 1f729f3..9f29992 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -1,7 +1,20 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { toast } from 'react-toastify';
-import type { SovdEntity, SovdEntityDetails, EntityTreeNode, ComponentTopic, TopicNodeData } from './types';
+import type {
+ SovdEntity,
+ SovdEntityDetails,
+ EntityTreeNode,
+ ComponentTopic,
+ TopicNodeData,
+ Parameter,
+ Operation,
+ ActionGoalStatus,
+ InvokeOperationRequest,
+ OperationResponse,
+ VirtualFolderData,
+} from './types';
+import { isVirtualFolderData } from './types';
import { createSovdClient, type SovdApiClient } from './sovd-api';
const STORAGE_KEY = 'sovd_web_ui_server_url';
@@ -26,6 +39,18 @@ export interface AppState {
isLoadingDetails: boolean;
isRefreshing: boolean;
+ // Configurations state (ROS 2 Parameters)
+ configurations: Map; // componentId -> parameters
+ isLoadingConfigurations: boolean;
+
+ // Operations state (ROS 2 Services & Actions)
+ operations: Map; // componentId -> operations
+ isLoadingOperations: boolean;
+
+ // Active action goals (for monitoring async actions)
+ activeGoals: Map; // goalId -> status
+ autoRefreshGoals: boolean; // checkbox state for auto-refresh
+
// Actions
connect: (url: string, baseEndpoint?: string) => Promise;
disconnect: () => void;
@@ -35,6 +60,19 @@ export interface AppState {
selectEntity: (path: string) => Promise;
refreshSelectedEntity: () => Promise;
clearSelection: () => void;
+
+ // Configurations actions
+ fetchConfigurations: (componentId: string) => Promise;
+ setParameter: (componentId: string, paramName: string, value: unknown) => Promise;
+ resetParameter: (componentId: string, paramName: string) => Promise;
+ resetAllConfigurations: (componentId: string) => Promise<{ reset_count: number; failed_count: number }>;
+
+ // Operations actions
+ fetchOperations: (componentId: string) => Promise;
+ invokeOperation: (componentId: string, operationName: string, request: InvokeOperationRequest) => Promise;
+ refreshActionStatus: (componentId: string, operationName: string, goalId: string) => Promise;
+ cancelActionGoal: (componentId: string, operationName: string, goalId: string) => Promise;
+ setAutoRefreshGoals: (enabled: boolean) => void;
}
/**
@@ -43,42 +81,45 @@ export interface AppState {
function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode {
const path = parentPath ? `${parentPath}/${entity.id}` : `/${entity.id}`;
- // If this is a component with topicsInfo, pre-populate children
+ // If this is a component, create virtual subfolders: data/, operations/, configurations/
let children: EntityTreeNode[] | undefined;
- if (entity.type === 'component' && entity.topicsInfo) {
- const allTopics = new Set();
-
- // Collect unique topics from publishes and subscribes
- entity.topicsInfo.publishes?.forEach(t => allTopics.add(t));
- entity.topicsInfo.subscribes?.forEach(t => allTopics.add(t));
-
- if (allTopics.size > 0) {
- children = Array.from(allTopics).sort().map(topicName => {
- const cleanName = topicName.startsWith('/') ? topicName.slice(1) : topicName;
- // Use percent-encoding for topic names in URLs
- // e.g., 'powertrain/engine/temp' -> 'powertrain%2Fengine%2Ftemp'
- const encodedName = encodeURIComponent(cleanName);
- const isPublisher = entity.topicsInfo?.publishes?.includes(topicName) ?? false;
- const isSubscriber = entity.topicsInfo?.subscribes?.includes(topicName) ?? false;
-
- return {
- id: encodedName,
- name: topicName,
- type: 'topic',
- href: `${path}/${encodedName}`,
- path: `${path}/${encodedName}`,
- hasChildren: false,
- isLoading: false,
- isExpanded: false,
- // Store topic direction info
- data: {
- topic: topicName,
- isPublisher,
- isSubscriber
- }
- };
- });
- }
+ if (entity.type === 'component') {
+ // Create virtual subfolder nodes for component
+ children = [
+ {
+ id: 'data',
+ name: 'data',
+ type: 'folder',
+ href: `${path}/data`,
+ path: `${path}/data`,
+ hasChildren: true, // Topics will be loaded here
+ isLoading: false,
+ isExpanded: false,
+ data: { folderType: 'data', componentId: entity.id, topicsInfo: entity.topicsInfo }
+ },
+ {
+ id: 'operations',
+ name: 'operations',
+ type: 'folder',
+ href: `${path}/operations`,
+ path: `${path}/operations`,
+ hasChildren: true, // Services/actions loaded on demand
+ isLoading: false,
+ isExpanded: false,
+ data: { folderType: 'operations', componentId: entity.id }
+ },
+ {
+ id: 'configurations',
+ name: 'configurations',
+ type: 'folder',
+ href: `${path}/configurations`,
+ path: `${path}/configurations`,
+ hasChildren: true, // Parameters loaded on demand
+ isLoading: false,
+ isExpanded: false,
+ data: { folderType: 'configurations', componentId: entity.id }
+ }
+ ];
}
return {
@@ -149,6 +190,18 @@ export const useAppStore = create()(
isLoadingDetails: false,
isRefreshing: false,
+ // Configurations state
+ configurations: new Map(),
+ isLoadingConfigurations: false,
+
+ // Operations state
+ operations: new Map(),
+ isLoadingOperations: false,
+
+ // Active goals state
+ activeGoals: new Map(),
+ autoRefreshGoals: false,
+
// Connect to SOVD server
connect: async (url: string, baseEndpoint: string = '') => {
set({ isConnecting: true, connectionError: null });
@@ -231,6 +284,106 @@ export const useAppStore = create()(
// Check if we already have this data in the tree
const node = findNode(rootEntities, path);
+
+ // Handle virtual folders (data/, operations/, configurations/)
+ if (node && isVirtualFolderData(node.data)) {
+ const folderData = node.data as VirtualFolderData;
+
+ // Skip if already has loaded children
+ if (node.children && node.children.length > 0) {
+ return;
+ }
+
+ set({ loadingPaths: [...loadingPaths, path] });
+
+ try {
+ let children: EntityTreeNode[] = [];
+
+ if (folderData.folderType === 'data') {
+ // Load topics for data folder
+ const topics = await client.getEntities(path.replace('/data', ''));
+ children = topics.map((topic: SovdEntity & { data?: ComponentTopic }) => {
+ const cleanName = topic.name.startsWith('/') ? topic.name.slice(1) : topic.name;
+ const encodedName = encodeURIComponent(cleanName);
+ return {
+ id: encodedName,
+ name: topic.name,
+ type: 'topic',
+ href: `${path}/${encodedName}`,
+ path: `${path}/${encodedName}`,
+ hasChildren: false,
+ isLoading: false,
+ isExpanded: false,
+ data: topic.data || {
+ topic: topic.name,
+ isPublisher: folderData.topicsInfo?.publishes?.includes(topic.name) ?? false,
+ isSubscriber: folderData.topicsInfo?.subscribes?.includes(topic.name) ?? false,
+ }
+ };
+ });
+ } else if (folderData.folderType === 'operations') {
+ // Load operations for operations folder
+ const ops = await client.listOperations(folderData.componentId);
+ children = ops.map(op => ({
+ id: op.name,
+ name: op.name,
+ type: op.kind === 'service' ? 'service' : 'action',
+ href: `${path}/${op.name}`,
+ path: `${path}/${op.name}`,
+ hasChildren: false,
+ isLoading: false,
+ isExpanded: false,
+ data: op
+ }));
+ } else if (folderData.folderType === 'configurations') {
+ // Load parameters for configurations folder
+ const config = await client.listConfigurations(folderData.componentId);
+ children = config.parameters.map(param => ({
+ id: param.name,
+ name: param.name,
+ type: 'parameter',
+ href: `${path}/${param.name}`,
+ path: `${path}/${param.name}`,
+ hasChildren: false,
+ isLoading: false,
+ isExpanded: false,
+ data: param
+ }));
+ }
+
+ const updatedTree = updateNodeInTree(rootEntities, path, n => ({
+ ...n,
+ children,
+ hasChildren: children.length > 0,
+ isLoading: false,
+ }));
+
+ set({
+ rootEntities: updatedTree,
+ loadingPaths: get().loadingPaths.filter(p => p !== path),
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ // Don't show error for empty results - some components may not have operations/configs
+ if (!message.includes('not found')) {
+ toast.error(`Failed to load ${folderData.folderType}: ${message}`);
+ }
+ // Still update tree to show empty folder
+ const updatedTree = updateNodeInTree(rootEntities, path, n => ({
+ ...n,
+ children: [],
+ hasChildren: false,
+ isLoading: false,
+ }));
+ set({
+ rootEntities: updatedTree,
+ loadingPaths: get().loadingPaths.filter(p => p !== path),
+ });
+ }
+ return;
+ }
+
+ // Regular node loading (areas, components)
if (node && Array.isArray(node.children) && node.children.length > 0) {
// Check if children have full data or just TopicNodeData
// TopicNodeData has isPublisher/isSubscriber but no 'type' field in data
@@ -286,9 +439,32 @@ export const useAppStore = create()(
// Select an entity and load its details
selectEntity: async (path: string) => {
- const { client, selectedPath, rootEntities, expandedPaths } = get();
+ const { client, selectedPath, rootEntities, expandedPaths, loadChildren } = get();
if (!client || path === selectedPath) return;
+ // Auto-expand parent paths and load children if needed
+ // This ensures navigation to deep paths (like /area/component/data/topic) works
+ const pathParts = path.split('/').filter(Boolean);
+ const newExpandedPaths = [...expandedPaths];
+ let currentPath = '';
+
+ for (let i = 0; i < pathParts.length - 1; i++) {
+ currentPath += '/' + pathParts[i];
+ if (!newExpandedPaths.includes(currentPath)) {
+ newExpandedPaths.push(currentPath);
+ }
+ // Check if this node needs children loaded
+ const parentNode = findNode(rootEntities, currentPath);
+ if (parentNode && parentNode.hasChildren !== false && !parentNode.children) {
+ // Trigger load but don't await - let it happen in background
+ loadChildren(currentPath);
+ }
+ }
+
+ if (newExpandedPaths.length !== expandedPaths.length) {
+ set({ expandedPaths: newExpandedPaths });
+ }
+
// OPTIMIZATION: Check if tree already has this data
const node = findNode(rootEntities, path);
@@ -361,49 +537,89 @@ export const useAppStore = create()(
return;
}
- // Optimization for Component - check if we have full ComponentTopic data in children
+ // Optimization for Component - just select it and auto-expand
+ // Don't modify children - virtual folders (data/, operations/, configurations/) are already there
if (node && node.type === 'component') {
- // Auto-expand component to show topics
+ // Auto-expand component to show virtual folders
const newExpandedPaths = expandedPaths.includes(path)
? expandedPaths
: [...expandedPaths, path];
- // Check if children have FULL ComponentTopic data (not just TopicNodeData)
- const hasFullTopicData = node.children &&
- node.children.length > 0 &&
- node.children[0].data &&
- typeof node.children[0].data === 'object' &&
- 'type' in (node.children[0].data as object);
+ set({
+ selectedPath: path,
+ expandedPaths: newExpandedPaths,
+ isLoadingDetails: false,
+ selectedEntity: {
+ id: node.id,
+ name: node.name,
+ type: node.type,
+ href: node.href,
+ // Pass topicsInfo if available for the Data tab
+ topicsInfo: node.topicsInfo,
+ }
+ });
+ return;
+ }
- if (hasFullTopicData) {
- // Use full ComponentTopic data from tree cache
- const fullTopics = node.children!
- .filter(child => child.type === 'topic' && child.data)
- .map(child => child.data as ComponentTopic);
+ // Handle virtual folder selection - show appropriate panel
+ if (node && isVirtualFolderData(node.data)) {
+ const folderData = node.data as VirtualFolderData;
+ set({
+ selectedPath: path,
+ isLoadingDetails: false,
+ selectedEntity: {
+ id: node.id,
+ name: `${folderData.componentId} / ${node.name}`,
+ type: 'folder',
+ href: node.href,
+ // Pass folder info so detail panel knows what to show
+ folderType: folderData.folderType,
+ componentId: folderData.componentId,
+ }
+ });
+ return;
+ }
- set({
- selectedPath: path,
- expandedPaths: newExpandedPaths,
- isLoadingDetails: false,
- selectedEntity: {
- id: node.id,
- name: node.name,
- type: node.type,
- href: node.href,
- // Full topic data with QoS, publishers, subscribers
- topics: fullTopics,
- // Simple lists for navigation
- topicsInfo: {
- publishes: fullTopics.map(t => t.topic),
- subscribes: [],
- }
- }
- });
- return;
- }
+ // Handle parameter selection - show parameter detail with data from tree
+ if (node && node.type === 'parameter' && node.data) {
+ // Extract componentId from path: /area/component/configurations/paramName
+ const pathSegments = path.split('/').filter(Boolean);
+ const componentId = pathSegments.length >= 2 ? pathSegments[1] : pathSegments[0];
- // Children only have TopicNodeData (from topicsInfo) - need to fetch full data from API
- // Fall through to the API fetch below
+ set({
+ selectedPath: path,
+ isLoadingDetails: false,
+ selectedEntity: {
+ id: node.id,
+ name: node.name,
+ type: 'parameter',
+ href: node.href,
+ data: node.data,
+ componentId,
+ }
+ });
+ return;
+ }
+
+ // Handle service/action selection - show operation detail with data from tree
+ if (node && (node.type === 'service' || node.type === 'action') && node.data) {
+ // Extract componentId from path: /area/component/operations/opName
+ const pathSegments = path.split('/').filter(Boolean);
+ const componentId = pathSegments.length >= 2 ? pathSegments[1] : pathSegments[0];
+
+ set({
+ selectedPath: path,
+ isLoadingDetails: false,
+ selectedEntity: {
+ id: node.id,
+ name: node.name,
+ type: node.type,
+ href: node.href,
+ data: node.data,
+ componentId,
+ }
+ });
+ return;
}
set({
@@ -414,81 +630,6 @@ export const useAppStore = create()(
try {
const details = await client.getEntityDetails(path);
-
- // SYNC: Update tree with fetched topics AND auto-expand the node
- // Prefer full topics array (with QoS, publishers) over topicsInfo (names only)
- if (details.topics && details.topics.length > 0) {
- const children = details.topics.map(topic => {
- const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic;
- const encodedName = encodeURIComponent(cleanName);
- return {
- id: encodedName,
- name: topic.topic,
- type: 'topic' as const,
- href: `${path}/${encodedName}`,
- hasChildren: false,
- path: `${path}/${encodedName}`,
- // Store FULL ComponentTopic data for rich view
- data: topic
- };
- });
-
- const updatedTree = updateNodeInTree(rootEntities, path, n => ({
- ...n,
- children,
- isLoading: false
- }));
-
- // Auto-expand the node if it has topics
- const newExpandedPaths = expandedPaths.includes(path)
- ? expandedPaths
- : [...expandedPaths, path];
-
- set({
- rootEntities: updatedTree,
- expandedPaths: newExpandedPaths
- });
- } else if (details.topicsInfo) {
- // Fallback to topicsInfo if topics array not available
- const allTopics = new Set();
- details.topicsInfo.publishes?.forEach(t => allTopics.add(t));
- details.topicsInfo.subscribes?.forEach(t => allTopics.add(t));
-
- const children = Array.from(allTopics).sort().map(topicName => {
- const cleanName = topicName.startsWith('/') ? topicName.slice(1) : topicName;
- const encodedName = encodeURIComponent(cleanName);
- return {
- id: encodedName,
- name: topicName,
- type: 'topic' as const,
- href: `${path}/${encodedName}`,
- hasChildren: false,
- path: `${path}/${encodedName}`,
- data: {
- topic: topicName,
- isPublisher: details.topicsInfo?.publishes?.includes(topicName) ?? false,
- isSubscriber: details.topicsInfo?.subscribes?.includes(topicName) ?? false,
- }
- };
- });
-
- const updatedTree = updateNodeInTree(rootEntities, path, n => ({
- ...n,
- children,
- isLoading: false
- }));
-
- // Auto-expand the node if it has topics
- const newExpandedPaths = expandedPaths.includes(path)
- ? expandedPaths
- : [...expandedPaths, path];
-
- set({
- rootEntities: updatedTree,
- expandedPaths: newExpandedPaths
- });
- }
-
set({ selectedEntity: details, isLoadingDetails: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -545,6 +686,219 @@ export const useAppStore = create()(
selectedEntity: null,
});
},
+
+ // ===========================================================================
+ // CONFIGURATIONS ACTIONS (ROS 2 Parameters)
+ // ===========================================================================
+
+ fetchConfigurations: async (componentId: string) => {
+ const { client, configurations } = get();
+ if (!client) return;
+
+ set({ isLoadingConfigurations: true });
+
+ try {
+ const result = await client.listConfigurations(componentId);
+ const newConfigs = new Map(configurations);
+ newConfigs.set(componentId, result.parameters);
+ set({ configurations: newConfigs, isLoadingConfigurations: false });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast.error(`Failed to load configurations: ${message}`);
+ set({ isLoadingConfigurations: false });
+ }
+ },
+
+ setParameter: async (componentId: string, paramName: string, value: unknown) => {
+ const { client, configurations } = get();
+ if (!client) return false;
+
+ try {
+ const result = await client.setConfiguration(componentId, paramName, { value });
+
+ if (result.status === 'success') {
+ // Update local state with new value
+ const newConfigs = new Map(configurations);
+ const params = newConfigs.get(componentId) || [];
+ const updatedParams = params.map(p =>
+ p.name === paramName ? { ...p, value: result.parameter.value } : p
+ );
+ newConfigs.set(componentId, updatedParams);
+ set({ configurations: newConfigs });
+ toast.success(`Parameter ${paramName} updated`);
+ return true;
+ } else {
+ toast.error(`Failed to set parameter: ${result.error}`);
+ return false;
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast.error(`Failed to set parameter: ${message}`);
+ return false;
+ }
+ },
+
+ resetParameter: async (componentId: string, paramName: string) => {
+ const { client, configurations } = get();
+ if (!client) return false;
+
+ try {
+ const result = await client.resetConfiguration(componentId, paramName);
+
+ // Update local state with reset value
+ const newConfigs = new Map(configurations);
+ const params = newConfigs.get(componentId) || [];
+ const updatedParams = params.map(p =>
+ p.name === paramName ? { ...p, value: result.value } : p
+ );
+ newConfigs.set(componentId, updatedParams);
+ set({ configurations: newConfigs });
+ toast.success(`Parameter ${paramName} reset to default`);
+ return true;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast.error(`Failed to reset parameter: ${message}`);
+ return false;
+ }
+ },
+
+ resetAllConfigurations: async (componentId: string) => {
+ const { client, fetchConfigurations } = get();
+ if (!client) return { reset_count: 0, failed_count: 0 };
+
+ try {
+ const result = await client.resetAllConfigurations(componentId);
+
+ if (result.failed_count === 0) {
+ toast.success(`Reset ${result.reset_count} parameters to defaults`);
+ } else {
+ toast.warning(`Reset ${result.reset_count} parameters, ${result.failed_count} failed`);
+ }
+
+ // Refresh configurations to get updated values
+ await fetchConfigurations(componentId);
+
+ return { reset_count: result.reset_count, failed_count: result.failed_count };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast.error(`Failed to reset configurations: ${message}`);
+ return { reset_count: 0, failed_count: 0 };
+ }
+ },
+
+ // ===========================================================================
+ // OPERATIONS ACTIONS (ROS 2 Services & Actions)
+ // ===========================================================================
+
+ fetchOperations: async (componentId: string) => {
+ const { client, operations } = get();
+ if (!client) return;
+
+ set({ isLoadingOperations: true });
+
+ try {
+ const result = await client.listOperations(componentId);
+ const newOps = new Map(operations);
+ newOps.set(componentId, result);
+ set({ operations: newOps, isLoadingOperations: false });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast.error(`Failed to load operations: ${message}`);
+ set({ isLoadingOperations: false });
+ }
+ },
+
+ invokeOperation: async (componentId: string, operationName: string, request: InvokeOperationRequest) => {
+ const { client, activeGoals } = get();
+ if (!client) return null;
+
+ try {
+ const result = await client.invokeOperation(componentId, operationName, request);
+
+ if (result.kind === 'action' && result.status === 'success') {
+ // Track the new action goal
+ // goal_status can be 'accepted', 'executing', etc. - use it directly
+ const newGoals = new Map(activeGoals);
+ newGoals.set(result.goal_id, {
+ goal_id: result.goal_id,
+ status: result.goal_status as ActionGoalStatus['status'],
+ action_path: `/${componentId}/${operationName}`,
+ action_type: request.type || 'unknown',
+ });
+ set({ activeGoals: newGoals });
+ toast.success(`Action goal ${result.goal_id.slice(0, 8)}... accepted`);
+ } else if (result.kind === 'service') {
+ toast.success(`Service ${operationName} called successfully`);
+ }
+
+ return result;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast.error(`Operation failed: ${message}`);
+ return null;
+ }
+ },
+
+ refreshActionStatus: async (componentId: string, operationName: string, goalId: string) => {
+ const { client, activeGoals } = get();
+ if (!client) return;
+
+ try {
+ const status = await client.getActionStatus(componentId, operationName, goalId);
+ const newGoals = new Map(activeGoals);
+ newGoals.set(goalId, status);
+ set({ activeGoals: newGoals });
+
+ // If goal is terminal, fetch result
+ if (['succeeded', 'canceled', 'aborted'].includes(status.status)) {
+ try {
+ const result = await client.getActionResult(componentId, operationName, goalId);
+ const updatedGoals = new Map(get().activeGoals);
+ const existing = updatedGoals.get(goalId);
+ if (existing) {
+ updatedGoals.set(goalId, { ...existing, last_feedback: result.result });
+ }
+ set({ activeGoals: updatedGoals });
+ } catch {
+ // Result might not be available yet
+ }
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast.error(`Failed to refresh action status: ${message}`);
+ }
+ },
+
+ cancelActionGoal: async (componentId: string, operationName: string, goalId: string) => {
+ const { client, activeGoals } = get();
+ if (!client) return false;
+
+ try {
+ const result = await client.cancelAction(componentId, operationName, goalId);
+
+ if (result.status === 'canceling') {
+ const newGoals = new Map(activeGoals);
+ const existing = newGoals.get(goalId);
+ if (existing) {
+ newGoals.set(goalId, { ...existing, status: 'canceling' });
+ }
+ set({ activeGoals: newGoals });
+ toast.success(`Cancel request sent for goal ${goalId.slice(0, 8)}...`);
+ return true;
+ } else {
+ toast.error(`Failed to cancel: ${result.message}`);
+ return false;
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ toast.error(`Failed to cancel action: ${message}`);
+ return false;
+ }
+ },
+
+ setAutoRefreshGoals: (enabled: boolean) => {
+ set({ autoRefreshGoals: enabled });
+ },
}),
{
name: STORAGE_KEY,
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 4fd4e85..b506075 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -122,6 +122,25 @@ export interface TopicNodeData {
isSubscriber: boolean;
}
+/**
+ * Virtual folder data for component subfolders
+ */
+export interface VirtualFolderData {
+ /** Type of virtual folder: data, operations, or configurations */
+ folderType: 'data' | 'operations' | 'configurations';
+ /** Parent component ID */
+ componentId: string;
+ /** Topics info (for data folder) */
+ topicsInfo?: ComponentTopicsInfo;
+}
+
+/**
+ * Type guard for VirtualFolderData
+ */
+export function isVirtualFolderData(data: unknown): data is VirtualFolderData {
+ return !!data && typeof data === 'object' && 'folderType' in data && 'componentId' in data;
+}
+
/**
* Component topic data from GET /components/{id}/data
*/
@@ -191,4 +210,238 @@ export interface ComponentTopicPublishRequest {
data: unknown;
}
+// =============================================================================
+// CONFIGURATIONS (ROS 2 Parameters)
+// =============================================================================
+
+/**
+ * Parameter type from ROS 2
+ */
+export type ParameterType = 'bool' | 'int' | 'double' | 'string' | 'byte_array' | 'bool_array' | 'int_array' | 'double_array' | 'string_array';
+
+/**
+ * Single parameter info from configurations endpoint
+ */
+export interface Parameter {
+ /** Parameter name */
+ name: string;
+ /** Current parameter value */
+ value: unknown;
+ /** Parameter type (bool, int, double, string, arrays) */
+ type: ParameterType;
+ /** Optional description of the parameter */
+ description?: string;
+ /** Whether the parameter is read-only */
+ read_only?: boolean;
+}
+
+/**
+ * Response from GET /components/{id}/configurations
+ */
+export interface ComponentConfigurations {
+ component_id: string;
+ node_name: string;
+ parameters: Parameter[];
+}
+
+/**
+ * Response from GET /components/{id}/configurations/{param}
+ */
+export interface ConfigurationDetail {
+ component_id: string;
+ parameter: Parameter;
+}
+
+/**
+ * Request body for PUT /components/{id}/configurations/{param}
+ */
+export interface SetConfigurationRequest {
+ value: unknown;
+}
+
+/**
+ * Response from PUT /components/{id}/configurations/{param}
+ */
+export interface SetConfigurationResponse {
+ status: 'success' | 'error';
+ component_id: string;
+ parameter: Parameter;
+ error?: string;
+}
+
+/**
+ * Response from DELETE /components/{id}/configurations/{param}
+ * Reset single parameter to default value
+ */
+export interface ResetConfigurationResponse {
+ name: string;
+ value: unknown;
+ type: ParameterType;
+ reset_to_default: boolean;
+}
+
+/**
+ * Response from DELETE /components/{id}/configurations
+ * Reset all parameters to default values
+ */
+export interface ResetAllConfigurationsResponse {
+ node_name: string;
+ reset_count: number;
+ failed_count: number;
+ failed_parameters?: string[];
+}
+
+// =============================================================================
+// OPERATIONS (ROS 2 Services & Actions)
+// =============================================================================
+
+/**
+ * Operation kind - service is sync, action is async
+ */
+export type OperationKind = 'service' | 'action';
+
+/**
+ * Service schema with request and response types
+ */
+export interface ServiceSchema {
+ request: TopicSchema;
+ response: TopicSchema;
+}
+
+/**
+ * Action schema with goal, result, and feedback types
+ */
+export interface ActionSchema {
+ goal: TopicSchema;
+ result: TopicSchema;
+ feedback: TopicSchema;
+}
+
+/**
+ * Type information for an operation (service or action)
+ */
+export interface OperationTypeInfo {
+ /** JSON schema describing request/response or goal/result/feedback types */
+ schema: ServiceSchema | ActionSchema;
+ /** Default values for the request/goal as YAML string */
+ default_value?: string;
+}
+
+/**
+ * Operation info from component discovery
+ */
+export interface Operation {
+ /** Operation name (e.g., "calibrate") */
+ name: string;
+ /** Full ROS path (e.g., "/powertrain/engine/calibrate") */
+ path: string;
+ /** ROS interface type (e.g., "std_srvs/srv/Trigger") */
+ type: string;
+ /** Whether it's a service or action */
+ kind: OperationKind;
+ /** Type information including schema for request/response */
+ type_info?: OperationTypeInfo;
+}
+
+/**
+ * Request body for POST /components/{id}/operations/{op}
+ */
+export interface InvokeOperationRequest {
+ /** Optional type override (auto-detected if not provided) */
+ type?: string;
+ /** Service request data (for services) */
+ request?: unknown;
+ /** Action goal data (for actions) */
+ goal?: unknown;
+}
+
+/**
+ * Response from POST /components/{id}/operations/{op} for services
+ */
+export interface ServiceOperationResponse {
+ status: 'success' | 'error';
+ kind: 'service';
+ component_id: string;
+ operation: string;
+ response: unknown;
+ error?: string;
+}
+
+/**
+ * Response from POST /components/{id}/operations/{op} for actions
+ */
+export interface ActionOperationResponse {
+ status: 'success' | 'error';
+ kind: 'action';
+ component_id: string;
+ operation: string;
+ goal_id: string;
+ goal_status: 'accepted' | 'rejected';
+ error?: string;
+}
+
+/**
+ * Union type for operation response
+ */
+export type OperationResponse = ServiceOperationResponse | ActionOperationResponse;
+
+/**
+ * Action goal status values
+ */
+export type ActionGoalStatusValue = 'accepted' | 'executing' | 'canceling' | 'succeeded' | 'canceled' | 'aborted';
+
+/**
+ * Response from GET /components/{id}/operations/{op}/status
+ */
+export interface ActionGoalStatus {
+ goal_id: string;
+ status: ActionGoalStatusValue;
+ action_path: string;
+ action_type: string;
+ last_feedback?: unknown;
+}
+
+/**
+ * Response from GET /components/{id}/operations/{op}/status?all=true
+ */
+export interface AllActionGoalsStatus {
+ action_path: string;
+ goals: ActionGoalStatus[];
+ count: number;
+}
+
+/**
+ * Response from GET /components/{id}/operations/{op}/result
+ */
+export interface ActionGoalResult {
+ goal_id: string;
+ status: ActionGoalStatusValue;
+ result: unknown;
+}
+
+/**
+ * Response from DELETE /components/{id}/operations/{op}
+ */
+export interface ActionCancelResponse {
+ status: 'canceling' | 'error';
+ goal_id: string;
+ message: string;
+}
+
+// =============================================================================
+// COMPONENT EXTENDED (with operations list from discovery)
+// =============================================================================
+
+/**
+ * Extended component info including operations
+ */
+export interface ComponentWithOperations {
+ id: string;
+ namespace: string;
+ fqn: string;
+ type: string;
+ area: string;
+ /** List of available operations (services + actions) */
+ operations?: Operation[];
+}