diff --git a/src/components/ActionStatusPanel.tsx b/src/components/ActionStatusPanel.tsx index 858d93b..d892047 100644 --- a/src/components/ActionStatusPanel.tsx +++ b/src/components/ActionStatusPanel.tsx @@ -5,40 +5,33 @@ 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'; +import type { ExecutionStatus } from '@/lib/types'; interface ActionStatusPanelProps { componentId: string; operationName: string; - goalId: string; + executionId: string; } /** - * Get status badge variant and icon + * Get status badge variant and icon for Execution status */ -function getStatusStyle(status: ActionGoalStatusValue): { +function getStatusStyle(status: ExecutionStatus): { variant: 'default' | 'secondary' | 'destructive' | 'outline'; icon: typeof CheckCircle; color: string; bgColor: string; } { switch (status) { - case 'accepted': + case 'pending': return { variant: 'outline', icon: Clock, color: 'text-blue-500', bgColor: 'bg-blue-500/10' }; - case 'executing': + case 'running': 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', @@ -53,7 +46,7 @@ function getStatusStyle(status: ActionGoalStatusValue): { color: 'text-gray-500', bgColor: 'bg-gray-500/10', }; - case 'aborted': + case 'failed': return { variant: 'destructive', icon: AlertCircle, @@ -73,64 +66,70 @@ function getStatusStyle(status: ActionGoalStatusValue): { /** * Check if status is terminal (no more updates expected) */ -function isTerminalStatus(status: ActionGoalStatusValue): boolean { - return ['succeeded', 'canceled', 'aborted'].includes(status); +function isTerminalStatus(status: ExecutionStatus): boolean { + return ['succeeded', 'canceled', 'failed'].includes(status); } /** * Check if status is active (action is in progress) */ -function isActiveStatus(status: ActionGoalStatusValue): boolean { - return ['accepted', 'executing', 'canceling'].includes(status); +function isActiveStatus(status: ExecutionStatus): boolean { + return ['pending', 'running'].includes(status); } -export function ActionStatusPanel({ componentId, operationName, goalId }: ActionStatusPanelProps) { - const { activeGoals, autoRefreshGoals, refreshActionStatus, cancelActionGoal, setAutoRefreshGoals } = useAppStore( +export function ActionStatusPanel({ componentId, operationName, executionId }: ActionStatusPanelProps) { + const { + activeExecutions, + autoRefreshExecutions, + refreshExecutionStatus, + cancelExecution, + setAutoRefreshExecutions, + } = useAppStore( useShallow((state: AppState) => ({ - activeGoals: state.activeGoals, - autoRefreshGoals: state.autoRefreshGoals, - refreshActionStatus: state.refreshActionStatus, - cancelActionGoal: state.cancelActionGoal, - setAutoRefreshGoals: state.setAutoRefreshGoals, + activeExecutions: state.activeExecutions, + autoRefreshExecutions: state.autoRefreshExecutions, + refreshExecutionStatus: state.refreshExecutionStatus, + cancelExecution: state.cancelExecution, + setAutoRefreshExecutions: state.setAutoRefreshExecutions, })) ); - const goalStatus = activeGoals.get(goalId); - const statusStyle = goalStatus ? getStatusStyle(goalStatus.status) : null; + const execution = activeExecutions.get(executionId); + const statusStyle = execution ? getStatusStyle(execution.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); + const isTerminal = execution ? isTerminalStatus(execution.status) : false; + const isActive = execution ? isActiveStatus(execution.status) : false; + const canCancel = execution && ['pending', 'running'].includes(execution.status); // Manual refresh const handleRefresh = useCallback(() => { - refreshActionStatus(componentId, operationName, goalId); - }, [componentId, operationName, goalId, refreshActionStatus]); + refreshExecutionStatus(componentId, operationName, executionId); + }, [componentId, operationName, executionId, refreshExecutionStatus]); // Cancel action const handleCancel = useCallback(async () => { - await cancelActionGoal(componentId, operationName, goalId); - }, [componentId, operationName, goalId, cancelActionGoal]); + await cancelExecution(componentId, operationName, executionId); + }, [componentId, operationName, executionId, cancelExecution]); // Auto-refresh effect useEffect(() => { - if (!autoRefreshGoals || isTerminal) return; + if (!autoRefreshExecutions || isTerminal) return; const interval = setInterval(() => { - refreshActionStatus(componentId, operationName, goalId); + refreshExecutionStatus(componentId, operationName, executionId); }, 1000); // Refresh every second return () => clearInterval(interval); - }, [autoRefreshGoals, isTerminal, componentId, operationName, goalId, refreshActionStatus]); + }, [autoRefreshExecutions, isTerminal, componentId, operationName, executionId, refreshExecutionStatus]); // Initial fetch useEffect(() => { - if (!goalStatus) { - refreshActionStatus(componentId, operationName, goalId); + if (!execution) { + refreshExecutionStatus(componentId, operationName, executionId); } - }, [goalId, goalStatus, componentId, operationName, refreshActionStatus]); + }, [executionId, execution, componentId, operationName, refreshExecutionStatus]); - if (!goalStatus) { + if (!execution) { return (
@@ -146,35 +145,35 @@ export function ActionStatusPanel({ componentId, operationName, goalId }: Action {isActive ? (
- {goalStatus.status === 'executing' && ( + {execution.status === 'running' && ( )}
) : ( )} - Action Status + Execution Status - {goalStatus.status} + {execution.status}
{/* Auto-refresh checkbox */} @@ -188,7 +187,7 @@ export function ActionStatusPanel({ componentId, operationName, goalId }: Action className="h-7 w-7 p-0" > @@ -210,9 +209,8 @@ export function ActionStatusPanel({ componentId, operationName, goalId }: Action
- {goalStatus.status === 'accepted' && 'Waiting to start...'} - {goalStatus.status === 'executing' && 'Action in progress...'} - {goalStatus.status === 'canceling' && 'Canceling...'} + {execution.status === 'pending' && 'Waiting to start...'} + {execution.status === 'running' && 'Execution in progress...'}
{/* Animated progress bar */} @@ -222,22 +220,32 @@ export function ActionStatusPanel({ componentId, operationName, goalId }: Action
)} - {/* Goal ID */} + {/* Execution ID */}
- Goal ID: + Execution ID: - {goalId.slice(0, 8)}...{goalId.slice(-8)} + {executionId.slice(0, 8)}...{executionId.slice(-8)}
- {/* Feedback */} - {goalStatus.last_feedback !== undefined && goalStatus.last_feedback !== null && ( + {/* Result or feedback */} + {execution.result !== undefined && execution.result !== null && (
{isTerminal ? 'Result:' : 'Last Feedback:'}
-                            {JSON.stringify(goalStatus.last_feedback, null, 2)}
+                            {JSON.stringify(execution.result, null, 2)}
+                        
+
+ )} + + {/* Error message for failed executions */} + {execution.error && ( +
+ Error: +
+                            {execution.error}
                         
)} @@ -247,9 +255,9 @@ export function ActionStatusPanel({ componentId, operationName, goalId }: Action
- {goalStatus.status === 'succeeded' && 'Action completed successfully'} - {goalStatus.status === 'canceled' && 'Action was canceled'} - {goalStatus.status === 'aborted' && 'Action was aborted due to an error'} + {execution.status === 'succeeded' && 'Execution completed successfully'} + {execution.status === 'canceled' && 'Execution was canceled'} + {execution.status === 'failed' && 'Execution failed'}
)} diff --git a/src/components/ConfigurationPanel.tsx b/src/components/ConfigurationPanel.tsx index a1132a7..de281dd 100644 --- a/src/components/ConfigurationPanel.tsx +++ b/src/components/ConfigurationPanel.tsx @@ -12,6 +12,8 @@ interface ConfigurationPanelProps { componentId: string; /** Optional parameter name to highlight */ highlightParam?: string; + /** Entity type for API calls */ + entityType?: 'components' | 'apps'; } /** @@ -259,7 +261,11 @@ function parseValue(input: string, type: ParameterType): unknown { } } -export function ConfigurationPanel({ componentId, highlightParam }: ConfigurationPanelProps) { +export function ConfigurationPanel({ + componentId, + highlightParam, + entityType = 'components', +}: ConfigurationPanelProps) { const { configurations, isLoadingConfigurations, @@ -284,36 +290,36 @@ export function ConfigurationPanel({ componentId, highlightParam }: Configuratio // Fetch configurations on mount (lazy loading) useEffect(() => { if (!configurations.has(componentId)) { - fetchConfigurations(componentId); + fetchConfigurations(componentId, entityType); } - }, [componentId, configurations, fetchConfigurations]); + }, [componentId, configurations, fetchConfigurations, entityType]); const handleRefresh = useCallback(() => { - fetchConfigurations(componentId); - }, [componentId, fetchConfigurations]); + fetchConfigurations(componentId, entityType); + }, [componentId, fetchConfigurations, entityType]); const handleSetParameter = useCallback( async (name: string, value: unknown) => { - return setParameter(componentId, name, value); + return setParameter(componentId, name, value, entityType); }, - [componentId, setParameter] + [componentId, setParameter, entityType] ); const handleResetParameter = useCallback( async (name: string) => { - return resetParameter(componentId, name); + return resetParameter(componentId, name, entityType); }, - [componentId, resetParameter] + [componentId, resetParameter, entityType] ); const handleResetAll = useCallback(async () => { setIsResettingAll(true); try { - await resetAllConfigurations(componentId); + await resetAllConfigurations(componentId, entityType); } finally { setIsResettingAll(false); } - }, [componentId, resetAllConfigurations]); + }, [componentId, resetAllConfigurations, entityType]); if (isLoadingConfigurations && parameters.length === 0) { return ( diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index cd34d43..3dc45d3 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -21,6 +21,7 @@ import { TopicDiagnosticsPanel } from '@/components/TopicDiagnosticsPanel'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; import { DataFolderPanel } from '@/components/DataFolderPanel'; +import { FaultsPanel } from '@/components/FaultsPanel'; import { useAppStore, type AppState } from '@/lib/store'; import type { ComponentTopic, Parameter } from '@/lib/types'; @@ -289,19 +290,27 @@ function ParameterDetailCard({ entity, componentId }: ParameterDetailCardProps) * Virtual folder content - redirect to appropriate panel */ interface VirtualFolderContentProps { - folderType: 'data' | 'operations' | 'configurations'; + folderType: 'data' | 'operations' | 'configurations' | 'faults'; componentId: string; basePath: string; + entityType?: 'components' | 'apps'; } -function VirtualFolderContent({ folderType, componentId, basePath }: VirtualFolderContentProps) { +function VirtualFolderContent({ + folderType, + componentId, + basePath, + entityType = 'components', +}: VirtualFolderContentProps) { switch (folderType) { case 'data': return ; case 'operations': - return ; + return ; case 'configurations': - return ; + return ; + case 'faults': + return ; default: return null; } @@ -503,13 +512,18 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { // 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) + folderPathParts.pop(); // Remove folder name (data/operations/configurations/faults) const basePath = folderPathParts.join('/'); + // Determine entity type from folder data + const entityType = selectedEntity.entityType === 'app' ? 'apps' : 'components'; return ( ); })() diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index 577a7ba..9a4e617 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -15,6 +15,9 @@ import { Clock, Settings, Sliders, + AlertTriangle, + Cpu, + Users, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -41,6 +44,10 @@ function getEntityIcon(type: string, data?: unknown) { return Zap; case 'configurations': return Settings; + case 'faults': + return AlertTriangle; + case 'apps': + return Users; } } @@ -62,6 +69,10 @@ function getEntityIcon(type: string, data?: unknown) { return Clock; case 'parameter': return Sliders; + case 'app': + return Cpu; + case 'fault': + return AlertTriangle; default: return FileJson; } diff --git a/src/components/FaultsPanel.tsx b/src/components/FaultsPanel.tsx new file mode 100644 index 0000000..9f3ba70 --- /dev/null +++ b/src/components/FaultsPanel.tsx @@ -0,0 +1,267 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { AlertTriangle, Loader2, RefreshCw, Trash2, AlertCircle, AlertOctagon, Info, CheckCircle } 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 { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; + +interface FaultsPanelProps { + componentId: string; + /** Type of entity: 'components' or 'apps' */ + entityType?: 'components' | 'apps'; +} + +/** + * Get badge variant for fault severity + */ +function getSeverityBadgeVariant(severity: FaultSeverity): 'default' | 'secondary' | 'destructive' | 'outline' { + switch (severity) { + case 'critical': + case 'error': + return 'destructive'; + case 'warning': + return 'default'; + case 'info': + return 'secondary'; + default: + return 'outline'; + } +} + +/** + * Get icon for fault severity + */ +function getSeverityIcon(severity: FaultSeverity) { + switch (severity) { + case 'critical': + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + default: + return ; + } +} + +/** + * Get badge variant for fault status + */ +function getStatusBadgeVariant(status: FaultStatus): 'default' | 'secondary' | 'outline' { + switch (status) { + case 'active': + return 'default'; + case 'pending': + return 'secondary'; + case 'cleared': + return 'outline'; + default: + return 'outline'; + } +} + +/** + * Format timestamp for display + */ +function formatTimestamp(timestamp: string): string { + try { + const date = new Date(timestamp); + return date.toLocaleString(); + } catch { + return timestamp; + } +} + +/** + * Single fault row component + */ +function FaultRow({ + fault, + onClear, + isClearing, +}: { + fault: Fault; + onClear: (code: string) => void; + isClearing: boolean; +}) { + const canClear = fault.status === 'active' || fault.status === 'pending'; + + return ( +
+ {/* Severity Icon */} +
+ {getSeverityIcon(fault.severity)} +
+ + {/* Fault details */} +
+
+ {fault.code} + + {fault.severity} + + + {fault.status} + +
+

{fault.message}

+
+ {formatTimestamp(fault.timestamp)} + + {fault.entity_type}: {fault.entity_id} + +
+
+ + {/* Clear button */} + {canClear && ( + + )} +
+ ); +} + +/** + * Panel displaying faults for a component or app + */ +export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPanelProps) { + const [faults, setFaults] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [clearingCodes, setClearingCodes] = useState>(new Set()); + const [error, setError] = useState(null); + + const { client } = useAppStore( + useShallow((state: AppState) => ({ + client: state.client, + })) + ); + + const loadFaults = useCallback(async () => { + if (!client) return; + + setIsLoading(true); + setError(null); + + try { + const response = await client.listEntityFaults(entityType, componentId); + setFaults(response.items || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load faults'); + setFaults([]); + } finally { + setIsLoading(false); + } + }, [client, componentId, entityType]); + + useEffect(() => { + loadFaults(); + }, [loadFaults]); + + const handleClear = useCallback( + async (code: string) => { + if (!client) return; + + setClearingCodes((prev) => new Set([...prev, code])); + + try { + await client.clearFault(entityType, componentId, code); + // Reload faults after clearing + await loadFaults(); + } catch { + // Error is handled by toast in the store + } finally { + setClearingCodes((prev) => { + const next = new Set(prev); + next.delete(code); + return next; + }); + } + }, + [client, componentId, entityType, loadFaults] + ); + + // Count faults by severity + const errorCount = faults.filter((f) => f.severity === 'error' || f.severity === 'critical').length; + const warningCount = faults.filter((f) => f.severity === 'warning').length; + + if (isLoading) { + return ( + + + + + + ); + } + + return ( + + +
+
+ + Faults + {faults.length > 0 && ( +
+ {errorCount > 0 && ( + + {errorCount} error{errorCount !== 1 ? 's' : ''} + + )} + {warningCount > 0 && ( + + {warningCount} warning{warningCount !== 1 ? 's' : ''} + + )} +
+ )} +
+ +
+
+ + {error ? ( +
{error}
+ ) : faults.length === 0 ? ( +
+ +

No faults detected

+

Component is operating normally

+
+ ) : ( +
+ {faults.map((fault) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/OperationResponse.tsx b/src/components/OperationResponse.tsx index f1883a6..f518b9e 100644 --- a/src/components/OperationResponse.tsx +++ b/src/components/OperationResponse.tsx @@ -1,144 +1,58 @@ -import { CheckCircle, XCircle, Clock, Zap, Hash } from 'lucide-react'; +import { CheckCircle, XCircle, Clock, Loader2, Hash } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import type { OperationResponse } from '@/lib/types'; +import type { CreateExecutionResponse, ExecutionStatus } from '@/lib/types'; interface OperationResponseProps { - response: OperationResponse; + response: CreateExecutionResponse; } -/** - * 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; +function getStatusConfig(status: ExecutionStatus): { + icon: typeof CheckCircle; + color: string; + variant: 'default' | 'secondary' | 'destructive' | 'outline'; +} { + switch (status) { + case 'succeeded': + return { icon: CheckCircle, color: 'text-green-500', variant: 'default' }; + case 'running': + case 'pending': + return { icon: Loader2, color: 'text-blue-500', variant: 'secondary' }; + case 'failed': + return { icon: XCircle, color: 'text-destructive', variant: 'destructive' }; + case 'canceled': + return { icon: Clock, color: 'text-yellow-500', variant: 'outline' }; + default: + return { icon: Clock, color: 'text-muted-foreground', variant: 'secondary' }; } - - 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; + const isSuccess = response.status === 'succeeded'; + const statusConfig = getStatusConfig(response.status); + const StatusIcon = statusConfig.icon; return (
{/* Header */}
- +
- {response.status} - - - {response.kind} - + {response.status}
- {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} -
- )} + {/* Execution ID */} +
+ + Execution ID: + {response.id} +
); diff --git a/src/components/OperationsPanel.tsx b/src/components/OperationsPanel.tsx index 6e10583..58dfcb0 100644 --- a/src/components/OperationsPanel.tsx +++ b/src/components/OperationsPanel.tsx @@ -23,7 +23,7 @@ import { useAppStore, type AppState } from '@/lib/store'; import type { Operation, OperationKind, - OperationResponse, + CreateExecutionResponse, TopicSchema, ServiceSchema, ActionSchema, @@ -37,14 +37,16 @@ import { OperationResponseDisplay } from './OperationResponse'; interface OperationHistoryEntry { id: string; timestamp: Date; - response: OperationResponse; - goalId?: string; + response: CreateExecutionResponse; + executionId?: string; } interface OperationsPanelProps { componentId: string; /** Optional: highlight and auto-expand a specific operation */ highlightOperation?: string; + /** Entity type for API calls */ + entityType?: 'components' | 'apps'; } /** @@ -110,7 +112,7 @@ function OperationRow({ }: { operation: Operation; componentId: string; - onInvoke: (opName: string, payload: unknown) => Promise; + onInvoke: (opName: string, payload: unknown) => Promise; defaultExpanded?: boolean; }) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); @@ -128,7 +130,7 @@ function OperationRow({ // Get latest entry for action status monitoring const latestEntry = history[0]; - const latestGoalId = latestEntry?.goalId; + const latestExecutionId = latestEntry?.executionId; // Initialize form data with schema defaults useEffect(() => { @@ -190,7 +192,7 @@ function OperationRow({ id: crypto.randomUUID(), timestamp: new Date(), response, - goalId: response.kind === 'action' && response.status === 'success' ? response.goal_id : undefined, + executionId: response.kind === 'action' && !response.error ? response.id : undefined, }; setHistory((prev) => [entry, ...prev.slice(0, 9)]); } @@ -352,11 +354,11 @@ function OperationRow({ )} {/* Action status monitoring for latest action */} - {latestGoalId && operation.kind === 'action' && ( + {latestExecutionId && operation.kind === 'action' && ( )} @@ -425,13 +427,13 @@ function OperationRow({ ); } -export function OperationsPanel({ componentId, highlightOperation }: OperationsPanelProps) { - const { operations, isLoadingOperations, fetchOperations, invokeOperation } = useAppStore( +export function OperationsPanel({ componentId, highlightOperation, entityType = 'components' }: OperationsPanelProps) { + const { operations, isLoadingOperations, fetchOperations, createExecution } = useAppStore( useShallow((state: AppState) => ({ operations: state.operations, isLoadingOperations: state.isLoadingOperations, fetchOperations: state.fetchOperations, - invokeOperation: state.invokeOperation, + createExecution: state.createExecution, })) ); @@ -442,19 +444,19 @@ export function OperationsPanel({ componentId, highlightOperation }: OperationsP // Fetch operations on mount (lazy loading) useEffect(() => { if (!operations.has(componentId)) { - fetchOperations(componentId); + fetchOperations(componentId, entityType); } - }, [componentId, operations, fetchOperations]); + }, [componentId, operations, fetchOperations, entityType]); const handleRefresh = useCallback(() => { - fetchOperations(componentId); - }, [componentId, fetchOperations]); + fetchOperations(componentId, entityType); + }, [componentId, fetchOperations, entityType]); const handleInvoke = useCallback( async (opName: string, payload: unknown) => { - return invokeOperation(componentId, opName, payload as Parameters[2]); + return createExecution(componentId, opName, payload as Parameters[2], entityType); }, - [componentId, invokeOperation] + [componentId, createExecution, entityType] ); if (isLoadingOperations && componentOperations.length === 0) { diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index bd40f68..0bd36bc 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -11,14 +11,38 @@ import type { ResetConfigurationResponse, ResetAllConfigurationsResponse, Operation, - InvokeOperationRequest, - OperationResponse, - ActionGoalStatus, - AllActionGoalsStatus, - ActionGoalResult, - ActionCancelResponse, + DataItemResponse, + // New SOVD-compliant types + Execution, + CreateExecutionRequest, + CreateExecutionResponse, + ListExecutionsResponse, + App, + AppCapabilities, + SovdFunction, + FunctionCapabilities, + Fault, + FaultSeverity, + FaultStatus, + ListFaultsResponse, + ListSnapshotsResponse, + ServerCapabilities, + VersionInfo, + SovdError, } from './types'; +/** + * Helper to unwrap items from SOVD API response + * API returns {items: [...]} format, but we often want just the array + */ +function unwrapItems(response: unknown): T[] { + if (Array.isArray(response)) { + return response as T[]; + } + const wrapped = response as { items?: T[] }; + return wrapped.items ?? []; +} + /** * Timeout wrapper for fetch requests. * Default timeout is 10 seconds to accommodate slower connections and large topic data responses. @@ -139,7 +163,11 @@ export class SovdApiClient { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const areas = await response.json(); + const areasResponse = await response.json(); + // Handle both array and wrapped {areas: [...]} response formats + const areas = Array.isArray(areasResponse) + ? areasResponse + : (areasResponse.areas ?? areasResponse.items ?? []); return areas.map((area: { id: string }) => ({ id: area.id, @@ -163,7 +191,11 @@ export class SovdApiClient { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const components = await response.json(); + const componentsResponse = await response.json(); + // Handle both array and wrapped {components: [...]} response formats + const components = Array.isArray(componentsResponse) + ? componentsResponse + : (componentsResponse.components ?? componentsResponse.items ?? []); return components.map((comp: { id: string; fqn?: string; topics?: ComponentTopicsInfo }) => { // Check if component has any topics (publishes or subscribes) @@ -192,22 +224,35 @@ export class SovdApiClient { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const topics = (await response.json()) as ComponentTopic[]; - // Return entities with FULL ComponentTopic data preserved - // This includes: type, type_info, publishers, subscribers, QoS, etc. - return topics.map((topic) => { - const cleanTopicName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; + // API returns {items: [{id, name, category, x-medkit}]} + interface DataItem { + id: string; + name: string; + category?: string; + 'x-medkit'?: { ros2?: { topic?: string; direction?: string } }; + } + const dataItems = unwrapItems(await response.json()); + + // Return entities with transformed data + return dataItems.map((item) => { + const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id; + const cleanTopicName = topicName.startsWith('/') ? topicName.slice(1) : topicName; const encodedTopicName = encodeURIComponent(cleanTopicName); return { id: encodedTopicName, - name: topic.topic, + name: topicName, type: 'topic', href: `/${parts[0]}/${parts[1]}/${encodedTopicName}`, hasChildren: false, - // IMPORTANT: Store full ComponentTopic for rich topic view - data: topic, + // Store transformed data as ComponentTopic + data: { + topic: topicName, + timestamp: Date.now() * 1000000, + data: null, + status: 'metadata_only' as const, + }, }; }); } @@ -223,13 +268,62 @@ export class SovdApiClient { // Path comes from the tree, e.g. "/area_id/component_id" const parts = path.split('/').filter((p) => p); - // 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]!; + // Handle virtual folder paths: /area/component/data/topic or /area/component/apps/app/data/topic + // Transform to: components/{component}/data/{topic} or apps/{app}/data/{topic} for API call + if (parts.length >= 4 && parts.includes('data')) { + const dataIndex = parts.indexOf('data'); + // Check if this is an app topic (path contains 'apps' before 'data') + const appsIndex = parts.indexOf('apps'); + const isAppTopic = appsIndex !== -1 && appsIndex < dataIndex; + + if (isAppTopic && dataIndex >= 2) { + // App topic: /area/component/apps/app/data/topic + const appId = parts[appsIndex + 1]!; + const encodedTopicName = parts[dataIndex + 1]!; + const decodedTopicName = decodeURIComponent(encodedTopicName); + + const response = await fetchWithTimeout(this.getUrl(`apps/${appId}/data/${encodedTopicName}`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Topic ${decodedTopicName} not found for app ${appId}`); + } + throw new Error(`HTTP ${response.status}`); + } - // Decode topic name using standard percent-decoding + // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} + const item = (await response.json()) as DataItemResponse; + const xMedkit = item['x-medkit']; + const ros2 = xMedkit?.ros2; + + const topic: ComponentTopic = { + topic: ros2?.topic || `/${decodedTopicName}`, + timestamp: xMedkit?.timestamp || Date.now() * 1000000, + data: item.data, + status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', + type: ros2?.type, + publisher_count: xMedkit?.publisher_count, + subscriber_count: xMedkit?.subscriber_count, + isPublisher: ros2?.direction === 'publish', + isSubscriber: ros2?.direction === 'subscribe', + }; + + return { + id: encodedTopicName, + name: topic.topic, + href: path, + topicData: topic, + rosType: topic.type, + type: 'topic', + }; + } + + // Component topic: /area/component/data/topic + const componentId = parts[1]!; + const encodedTopicName = parts[dataIndex + 1]!; const decodedTopicName = decodeURIComponent(encodedTopicName); const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data/${encodedTopicName}`), { @@ -244,11 +338,26 @@ export class SovdApiClient { throw new Error(`HTTP ${response.status}`); } - const topic = (await response.json()) as ComponentTopic; + // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} + const item = (await response.json()) as DataItemResponse; + const xMedkit = item['x-medkit']; + const ros2 = xMedkit?.ros2; + + const topic: ComponentTopic = { + topic: ros2?.topic || `/${decodedTopicName}`, + timestamp: xMedkit?.timestamp || Date.now() * 1000000, + data: item.data, + status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', + type: ros2?.type, + publisher_count: xMedkit?.publisher_count, + subscriber_count: xMedkit?.subscriber_count, + isPublisher: ros2?.direction === 'publish', + isSubscriber: ros2?.direction === 'subscribe', + }; return { id: encodedTopicName, - name: topic.topic || `/${decodedTopicName}`, + name: topic.topic, href: path, topicData: topic, rosType: topic.type, @@ -279,11 +388,26 @@ export class SovdApiClient { throw new Error(`HTTP ${response.status}`); } - const topic = (await response.json()) as ComponentTopic; + // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} + const item = (await response.json()) as DataItemResponse; + const xMedkit = item['x-medkit']; + const ros2 = xMedkit?.ros2; + + const topic: ComponentTopic = { + topic: ros2?.topic || `/${decodedTopicName}`, + timestamp: xMedkit?.timestamp || Date.now() * 1000000, + data: item.data, + status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', + type: ros2?.type, + publisher_count: xMedkit?.publisher_count, + subscriber_count: xMedkit?.subscriber_count, + isPublisher: ros2?.direction === 'publish', + isSubscriber: ros2?.direction === 'subscribe', + }; return { id: encodedTopicName, - name: topic.topic || `/${decodedTopicName}`, + name: topic.topic, href: path, topicData: topic, rosType: topic.type, @@ -299,7 +423,7 @@ export class SovdApiClient { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const topicsData = (await response.json()) as ComponentTopic[]; + const topicsData = unwrapItems(await response.json()); // Build topicsInfo from fetched data for navigation // AND keep full topics array for detailed view (QoS, publishers, etc.) @@ -407,11 +531,15 @@ export class SovdApiClient { // =========================================================================== /** - * List all configurations (parameters) for a component - * @param componentId Component ID + * List all configurations (parameters) for an entity + * @param entityId Entity ID (component or app) + * @param entityType Entity type ('components' or 'apps') */ - async listConfigurations(componentId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/configurations`), { + async listConfigurations( + entityId: string, + entityType: 'components' | 'apps' = 'components' + ): Promise { + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/configurations`), { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -424,17 +552,30 @@ export class SovdApiClient { throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); } - return await response.json(); + const data = await response.json(); + // API returns {items: [...], x-medkit: {parameters: [...]}} + // Transform to ComponentConfigurations format + const xMedkit = data['x-medkit'] || {}; + return { + component_id: xMedkit.entity_id || entityId, + node_name: xMedkit.ros2?.node || entityId, + parameters: xMedkit.parameters || [], + }; } /** * Get a specific configuration (parameter) value and metadata - * @param componentId Component ID + * @param entityId Entity ID (component or app) * @param paramName Parameter name + * @param entityType Entity type ('components' or 'apps') */ - async getConfiguration(componentId: string, paramName: string): Promise { + async getConfiguration( + entityId: string, + paramName: string, + entityType: 'components' | 'apps' = 'components' + ): Promise { const response = await fetchWithTimeout( - this.getUrl(`components/${componentId}/configurations/${encodeURIComponent(paramName)}`), + this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), { method: 'GET', headers: { Accept: 'application/json' }, @@ -454,17 +595,19 @@ export class SovdApiClient { /** * Set a configuration (parameter) value - * @param componentId Component ID + * @param entityId Entity ID (component or app) * @param paramName Parameter name * @param request Request with new value + * @param entityType Entity type ('components' or 'apps') */ async setConfiguration( - componentId: string, + entityId: string, paramName: string, - request: SetConfigurationRequest + request: SetConfigurationRequest, + entityType: 'components' | 'apps' = 'components' ): Promise { const response = await fetchWithTimeout( - this.getUrl(`components/${componentId}/configurations/${encodeURIComponent(paramName)}`), + this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), { method: 'PUT', headers: { @@ -488,12 +631,17 @@ export class SovdApiClient { /** * Reset a configuration (parameter) to its default value - * @param componentId Component ID + * @param entityId Entity ID (component or app) * @param paramName Parameter name + * @param entityType Entity type ('components' or 'apps') */ - async resetConfiguration(componentId: string, paramName: string): Promise { + async resetConfiguration( + entityId: string, + paramName: string, + entityType: 'components' | 'apps' = 'components' + ): Promise { const response = await fetchWithTimeout( - this.getUrl(`components/${componentId}/configurations/${encodeURIComponent(paramName)}`), + this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), { method: 'DELETE', headers: { @@ -514,11 +662,15 @@ export class SovdApiClient { } /** - * Reset all configurations for a component to their default values - * @param componentId Component ID + * Reset all configurations for an entity to their default values + * @param entityId Entity ID (component or app) + * @param entityType Entity type ('components' or 'apps') */ - async resetAllConfigurations(componentId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/configurations`), { + async resetAllConfigurations( + entityId: string, + entityType: 'components' | 'apps' = 'components' + ): Promise { + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/configurations`), { method: 'DELETE', headers: { Accept: 'application/json', @@ -538,45 +690,72 @@ export class SovdApiClient { } // =========================================================================== - // OPERATIONS API (ROS 2 Services & Actions) + // OPERATIONS API (ROS 2 Services & Actions) - SOVD Executions Model // =========================================================================== /** - * List all operations (services + actions) for a component - * This data comes from the component operations endpoint - * @param componentId Component ID + * List all operations (services + actions) for an entity (component or app) + * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity 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`), { + async listOperations(entityId: string, entityType: 'components' | 'apps' = 'components'): Promise { + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/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 unwrapItems(await response.json()); + } + + /** + * Get details of a specific operation + * @param entityId Entity ID + * @param operationName Operation name + * @param entityType Entity type ('components' or 'apps') + */ + async getOperation( + entityId: string, + operationName: string, + entityType: 'components' | 'apps' = 'components' + ): Promise { + const response = await fetchWithTimeout( + this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}`), + { + method: 'GET', + headers: { Accept: 'application/json' }, + } + ); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + return await response.json(); } /** - * Invoke an operation (service call or action goal) - * @param componentId Component ID + * Create an execution (invoke an operation) - SOVD-compliant + * @param entityId Entity ID (component or app) * @param operationName Operation name - * @param request Request data (request for services, goal for actions) + * @param request Execution request with input data + * @param entityType Entity type ('components' or 'apps') */ - async invokeOperation( - componentId: string, + async createExecution( + entityId: string, operationName: string, - request: InvokeOperationRequest - ): Promise { + request: CreateExecutionRequest, + entityType: 'components' | 'apps' = 'components' + ): Promise { const response = await fetchWithTimeout( - this.getUrl(`components/${componentId}/operations/${encodeURIComponent(operationName)}`), + this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions`), { method: 'POST', headers: { @@ -585,57 +764,555 @@ export class SovdApiClient { }, body: JSON.stringify(request), }, - 30000 // 30 second timeout for operations + 30000 ); if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; - }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + + return await response.json(); + } + + /** + * List all executions for an operation + * @param entityId Entity ID + * @param operationName Operation name + * @param entityType Entity type ('components' or 'apps') + */ + async listExecutions( + entityId: string, + operationName: string, + entityType: 'components' | 'apps' = 'components' + ): Promise { + const response = await fetchWithTimeout( + this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions`), + { + method: 'GET', + headers: { Accept: 'application/json' }, + } + ); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + + return await response.json(); + } + + /** + * Get execution status by ID + * @param entityId Entity ID + * @param operationName Operation name + * @param executionId Execution ID + * @param entityType Entity type ('components' or 'apps') + */ + async getExecution( + entityId: string, + operationName: string, + executionId: string, + entityType: 'components' | 'apps' = 'components' + ): Promise { + const response = await fetchWithTimeout( + this.getUrl( + `${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions/${encodeURIComponent(executionId)}` + ), + { + method: 'GET', + headers: { Accept: 'application/json' }, + } + ); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + + return await response.json(); + } + + /** + * Cancel an execution (for actions) + * @param entityId Entity ID + * @param operationName Operation name + * @param executionId Execution ID + * @param entityType Entity type ('components' or 'apps') + */ + async cancelExecution( + entityId: string, + operationName: string, + executionId: string, + entityType: 'components' | 'apps' = 'components' + ): Promise { + const response = await fetchWithTimeout( + this.getUrl( + `${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions/${encodeURIComponent(executionId)}` + ), + { + method: 'DELETE', + headers: { Accept: 'application/json' }, + } + ); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); } return await response.json(); } + // =========================================================================== + // APPS API (ROS 2 Nodes) + // =========================================================================== + + /** + * List all apps (ROS 2 nodes) in the system + */ + async listApps(): Promise { + const response = await fetchWithTimeout(this.getUrl('apps'), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + interface ApiAppResponse { + id: string; + name: string; + href?: string; + 'x-medkit'?: { + component_id?: string; + is_online?: boolean; + ros2?: { node?: string }; + source?: string; + }; + } + + const items = unwrapItems(await response.json()); + // Transform API response to App interface by extracting x-medkit fields + return items.map((item) => { + const xMedkit = item['x-medkit'] || {}; + const nodePath = xMedkit.ros2?.node || `/${item.name}`; + const lastSlash = nodePath.lastIndexOf('/'); + const namespace = lastSlash > 0 ? nodePath.substring(0, lastSlash) : '/'; + const nodeName = lastSlash >= 0 ? nodePath.substring(lastSlash + 1) : item.name; + + return { + id: item.id, + name: item.name, + href: item.href || `/api/v1/apps/${item.id}`, + type: 'app', + hasChildren: true, + node_name: nodeName, + namespace: namespace, + fqn: nodePath, + component_id: xMedkit.component_id, + }; + }); + } + /** - * Get action goal status + * List apps (ROS 2 nodes) belonging to a specific component + * Uses GET /components/{id}/hosts endpoint for efficient server-side filtering * @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`); + async listComponentApps(componentId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/hosts`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + interface ApiAppResponse { + id: string; + name: string; + href?: string; + 'x-medkit'?: { + is_online?: boolean; + ros2?: { node?: string }; + source?: string; + }; + } + + const items = unwrapItems(await response.json()); + return items.map((item) => { + const xMedkit = item['x-medkit'] || {}; + const nodePath = xMedkit.ros2?.node || `/${item.name}`; + const lastSlash = nodePath.lastIndexOf('/'); + const namespace = lastSlash > 0 ? nodePath.substring(0, lastSlash) : '/'; + const nodeName = lastSlash >= 0 ? nodePath.substring(lastSlash + 1) : item.name; + + return { + id: item.id, + name: item.name, + href: item.href || `/api/v1/apps/${item.id}`, + type: 'app', + hasChildren: true, + node_name: nodeName, + namespace: namespace, + fqn: nodePath, + component_id: componentId, + }; + }); + } + + /** + * Get app capabilities + * @param appId App identifier + */ + async getApp(appId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`apps/${appId}`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); - const response = await fetchWithTimeout(url, { + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + + return await response.json(); + } + + /** + * Get all topics (data) for an app + * @param appId App identifier + */ + async getAppData(appId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`apps/${appId}/data`), { method: 'GET', headers: { Accept: 'application/json' }, }); if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; + throw new Error(`HTTP ${response.status}`); + } + + // API returns {items: [{id, name, category, x-medkit}]} + interface DataItem { + id: string; + name: string; + category?: string; + 'x-medkit'?: { ros2?: { topic?: string; direction?: string; type?: string } }; + } + const dataItems = unwrapItems(await response.json()); + + // Transform to ComponentTopic format + // NOTE: Same topic can appear twice with different directions (publish/subscribe) + // We include direction in the key to make them unique + return dataItems.map((item) => { + const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id; + const direction = item['x-medkit']?.ros2?.direction; + const type = item['x-medkit']?.ros2?.type; + return { + topic: topicName, + timestamp: Date.now() * 1000000, + data: null, + status: 'metadata_only' as const, + // Include direction for unique key generation + isPublisher: direction === 'publish', + isSubscriber: direction === 'subscribe', + // Include unique key combining topic and direction + uniqueKey: direction ? `${topicName}:${direction}` : topicName, + type, }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); + }); + } + + /** + * Get a specific topic for an app + * @param appId App identifier + * @param topicName Topic name (will be URL encoded) + */ + async getAppDataItem(appId: string, topicName: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`apps/${appId}/data/${encodeURIComponent(topicName)}`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + + // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} + const item = (await response.json()) as DataItemResponse; + const xMedkit = item['x-medkit']; + const ros2 = xMedkit?.ros2; + + return { + topic: ros2?.topic || topicName, + timestamp: xMedkit?.timestamp || Date.now() * 1000000, + data: item.data, + status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', + type: ros2?.type, + publisher_count: xMedkit?.publisher_count, + subscriber_count: xMedkit?.subscriber_count, + isPublisher: ros2?.direction === 'publish', + isSubscriber: ros2?.direction === 'subscribe', + }; + } + + /** + * Publish to an app topic + * @param appId App identifier + * @param topicName Topic name + * @param request Publish request + */ + async publishToAppTopic(appId: string, topicName: string, request: ComponentTopicPublishRequest): Promise { + const response = await fetchWithTimeout( + this.getUrl(`apps/${appId}/data/${encodeURIComponent(topicName)}`), + { + method: 'PUT', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }, + 10000 + ); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + } + + /** + * Get app dependencies + * @param appId App identifier + */ + async getAppDependsOn(appId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`apps/${appId}/depends-on`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + return data.items || []; + } + + // =========================================================================== + // FUNCTIONS API (Capability Groupings) + // =========================================================================== + + /** + * List all functions + */ + async listFunctions(): Promise { + const response = await fetchWithTimeout(this.getUrl('functions'), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return unwrapItems(await response.json()); + } + + /** + * Get function capabilities + * @param functionId Function identifier + */ + async getFunction(functionId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); } return await response.json(); } /** - * Get all action goals status for an operation - * @param componentId Component ID - * @param operationName Action name + * Get apps hosting a function + * @param functionId Function identifier + */ + async getFunctionHosts(functionId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/hosts`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + return data.items || []; + } + + /** + * Get aggregated data for a function + * @param functionId Function identifier + */ + async getFunctionData(functionId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/data`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return []; + } + + return unwrapItems(await response.json()); + } + + /** + * Get aggregated operations for a function + * @param functionId Function identifier + */ + async getFunctionOperations(functionId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/operations`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return []; + } + + return unwrapItems(await response.json()); + } + + // =========================================================================== + // FAULTS API (Diagnostic Trouble Codes) + // =========================================================================== + + /** + * Transform API fault response to Fault interface + * API returns: {fault_code, description, severity (number), severity_label, status, first_occurred, ...} + * We need: {code, message, severity (string), status (lowercase), timestamp, entity_id, entity_type} + */ + private transformFault(apiFault: { + fault_code: string; + description: string; + severity: number; + severity_label: string; + status: string; + first_occurred: number; + last_occurred?: number; + occurrence_count?: number; + reporting_sources?: string[]; + }): Fault { + // Map severity number/label to FaultSeverity + // Order matters: check critical first, then error, then warning + let severity: FaultSeverity = 'info'; + const label = apiFault.severity_label?.toLowerCase() || ''; + if (label === 'critical' || apiFault.severity >= 3) { + severity = 'critical'; + } else if (label === 'error' || apiFault.severity === 2) { + severity = 'error'; + } else if (label === 'warn' || label === 'warning' || apiFault.severity === 1) { + severity = 'warning'; + } + + // Map status to FaultStatus + let status: FaultStatus = 'active'; + const apiStatus = apiFault.status?.toLowerCase() || ''; + if (apiStatus === 'confirmed' || apiStatus === 'active') { + status = 'active'; + } else if (apiStatus === 'pending') { + status = 'pending'; + } else if (apiStatus === 'cleared' || apiStatus === 'resolved') { + status = 'cleared'; + } + + // Extract entity info from reporting_sources + const source = apiFault.reporting_sources?.[0] || ''; + const entity_id = source.split('/').pop() || 'unknown'; + const entity_type = source.includes('/bridge/') ? 'bridge' : 'component'; + + return { + code: apiFault.fault_code, + message: apiFault.description, + severity, + status, + timestamp: new Date(apiFault.first_occurred * 1000).toISOString(), + entity_id, + entity_type, + parameters: { + occurrence_count: apiFault.occurrence_count, + last_occurred: apiFault.last_occurred, + reporting_sources: apiFault.reporting_sources, + }, + }; + } + + /** + * List all faults across the system + */ + async listAllFaults(): Promise { + const response = await fetchWithTimeout(this.getUrl('faults'), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const items = (data.items || []).map((f: unknown) => + this.transformFault(f as Parameters[0]) + ); + return { items, count: data['x-medkit']?.count || items.length }; + } + + /** + * List faults for a specific entity + * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity identifier */ - async getAllActionGoalsStatus(componentId: string, operationName: string): Promise { + async listEntityFaults(entityType: 'components' | 'apps', entityId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/faults`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + if (response.status === 404) { + return { items: [], count: 0 }; + } + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const items = (data.items || []).map((f: unknown) => + this.transformFault(f as Parameters[0]) + ); + return { items, count: data['x-medkit']?.count || items.length }; + } + + /** + * Get a specific fault by code + * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity identifier + * @param faultCode Fault code + */ + async getFault(entityType: 'components' | 'apps', entityId: string, faultCode: string): Promise { const response = await fetchWithTimeout( - this.getUrl(`components/${componentId}/operations/${encodeURIComponent(operationName)}/status?all=true`), + this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), { method: 'GET', headers: { Accept: 'application/json' }, @@ -643,67 +1320,166 @@ export class SovdApiClient { ); if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; - }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `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 + * Clear a specific fault + * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity identifier + * @param faultCode Fault code */ - async getActionResult(componentId: string, operationName: string, goalId: string): Promise { + async clearFault(entityType: 'components' | 'apps', entityId: string, faultCode: string): Promise { const response = await fetchWithTimeout( - this.getUrl( - `components/${componentId}/operations/${encodeURIComponent(operationName)}/result?goal_id=${encodeURIComponent(goalId)}` - ), + this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), { - method: 'GET', + 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}`); + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + } + + /** + * Clear all faults for an entity + * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity identifier + */ + async clearAllFaults(entityType: 'components' | 'apps', entityId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/faults`), { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + } + + /** + * Get fault snapshots + * @param faultCode Fault code + */ + async getFaultSnapshots(faultCode: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`faults/${encodeURIComponent(faultCode)}/snapshots`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + if (response.status === 404) { + return { items: [], count: 0 }; + } + throw new Error(`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 + * Get fault snapshots for a specific entity + * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity identifier + * @param faultCode Fault code */ - async cancelAction(componentId: string, operationName: string, goalId: string): Promise { + async getEntityFaultSnapshots( + entityType: 'components' | 'apps', + entityId: string, + faultCode: string + ): Promise { const response = await fetchWithTimeout( - this.getUrl( - `components/${componentId}/operations/${encodeURIComponent(operationName)}?goal_id=${encodeURIComponent(goalId)}` - ), + this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}/snapshots`), { - method: 'DELETE', + 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}`); + if (response.status === 404) { + return { items: [], count: 0 }; + } + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); + } + + /** + * Subscribe to real-time fault events via SSE + * @param onFault Callback for new fault events + * @param onError Callback for errors + * @returns Cleanup function to close the connection + */ + subscribeFaultStream(onFault: (fault: Fault) => void, onError?: (error: Error) => void): () => void { + const eventSource = new EventSource(this.getUrl('faults/stream')); + + eventSource.onmessage = (event) => { + try { + // API may return raw fault format that needs transformation + const rawData = JSON.parse(event.data); + // Check if this is the raw API format (has fault_code) or already transformed + if ('fault_code' in rawData) { + const fault = this.transformFault(rawData); + onFault(fault); + } else { + // Already in Fault format + onFault(rawData as Fault); + } + } catch { + onError?.(new Error('Failed to parse fault event')); + } + }; + + eventSource.onerror = () => { + onError?.(new Error('Fault stream connection error')); + }; + + return () => { + eventSource.close(); + }; + } + + // =========================================================================== + // SERVER CAPABILITIES API (SOVD Discovery) + // =========================================================================== + + /** + * Get server capabilities (root endpoint) + */ + async getServerCapabilities(): Promise { + const response = await fetchWithTimeout(this.getUrl(''), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); + } + + /** + * Get SOVD version information + */ + async getVersionInfo(): Promise { + const response = await fetchWithTimeout(this.getUrl('version-info'), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); } return await response.json(); diff --git a/src/lib/store.ts b/src/lib/store.ts index d643de9..0dbf7d4 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -9,10 +9,12 @@ import type { TopicNodeData, Parameter, Operation, - ActionGoalStatus, - InvokeOperationRequest, - OperationResponse, + Execution, + CreateExecutionRequest, + CreateExecutionResponse, + Fault, VirtualFolderData, + App, } from './types'; import { isVirtualFolderData } from './types'; import { createSovdClient, type SovdApiClient } from './sovd-api'; @@ -47,9 +49,14 @@ export interface AppState { operations: Map; // componentId -> operations isLoadingOperations: boolean; - // Active action goals (for monitoring async actions) - activeGoals: Map; // goalId -> status - autoRefreshGoals: boolean; // checkbox state for auto-refresh + // Active executions (for monitoring async actions) - SOVD Execution Model + activeExecutions: Map; // executionId -> execution + autoRefreshExecutions: boolean; // checkbox state for auto-refresh + + // Faults state (diagnostic trouble codes) + faults: Fault[]; + isLoadingFaults: boolean; + faultStreamCleanup: (() => void) | null; // Actions connect: (url: string, baseEndpoint?: string) => Promise; @@ -62,21 +69,46 @@ export interface AppState { 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, + fetchConfigurations: (entityId: string, entityType?: 'components' | 'apps') => Promise; + setParameter: ( + entityId: string, + paramName: string, + value: unknown, + entityType?: 'components' | 'apps' + ) => Promise; + resetParameter: (entityId: string, paramName: string, entityType?: 'components' | 'apps') => Promise; + resetAllConfigurations: ( + entityId: string, + entityType?: 'components' | 'apps' + ) => Promise<{ reset_count: number; failed_count: number }>; + + // Operations actions - updated for SOVD Execution model + fetchOperations: (entityId: string, entityType?: 'components' | 'apps') => Promise; + createExecution: ( + entityId: string, + operationName: string, + request: CreateExecutionRequest, + entityType?: 'components' | 'apps' + ) => Promise; + refreshExecutionStatus: ( + entityId: string, + operationName: string, + executionId: string, + entityType?: 'components' | 'apps' + ) => Promise; + cancelExecution: ( + entityId: 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; + executionId: string, + entityType?: 'components' | 'apps' + ) => Promise; + setAutoRefreshExecutions: (enabled: boolean) => void; + + // Faults actions + fetchFaults: () => Promise; + clearFault: (entityType: 'components' | 'apps', entityId: string, faultCode: string) => Promise; + subscribeFaultStream: () => void; + unsubscribeFaultStream: () => void; } /** @@ -85,7 +117,7 @@ export interface AppState { function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode { const path = parentPath ? `${parentPath}/${entity.id}` : `/${entity.id}`; - // If this is a component, create virtual subfolders: data/, operations/, configurations/ + // If this is a component, create virtual subfolders: data/, operations/, configurations/, faults/, apps/ let children: EntityTreeNode[] | undefined; if (entity.type === 'component') { // Create virtual subfolder nodes for component @@ -99,7 +131,12 @@ function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode hasChildren: true, // Topics will be loaded here isLoading: false, isExpanded: false, - data: { folderType: 'data', componentId: entity.id, topicsInfo: entity.topicsInfo }, + data: { + folderType: 'data', + componentId: entity.id, + entityType: 'component', + topicsInfo: entity.topicsInfo, + }, }, { id: 'operations', @@ -110,7 +147,7 @@ function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode hasChildren: true, // Services/actions loaded on demand isLoading: false, isExpanded: false, - data: { folderType: 'operations', componentId: entity.id }, + data: { folderType: 'operations', componentId: entity.id, entityType: 'component' }, }, { id: 'configurations', @@ -121,7 +158,78 @@ function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode hasChildren: true, // Parameters loaded on demand isLoading: false, isExpanded: false, - data: { folderType: 'configurations', componentId: entity.id }, + data: { folderType: 'configurations', componentId: entity.id, entityType: 'component' }, + }, + { + id: 'faults', + name: 'faults', + type: 'folder', + href: `${path}/faults`, + path: `${path}/faults`, + hasChildren: true, // Faults loaded on demand + isLoading: false, + isExpanded: false, + data: { folderType: 'faults', componentId: entity.id, entityType: 'component' }, + }, + { + id: 'apps', + name: 'apps', + type: 'folder', + href: `${path}/apps`, + path: `${path}/apps`, + hasChildren: true, // Apps (ROS 2 nodes) loaded on demand + isLoading: false, + isExpanded: false, + data: { folderType: 'apps', componentId: entity.id, entityType: 'component' }, + }, + ]; + } + // If this is an app, create virtual subfolders: data/, operations/, configurations/, faults/ + else if (entity.type === 'app') { + children = [ + { + id: 'data', + name: 'data', + type: 'folder', + href: `${path}/data`, + path: `${path}/data`, + hasChildren: true, + isLoading: false, + isExpanded: false, + data: { folderType: 'data', componentId: entity.id, entityType: 'app' }, + }, + { + id: 'operations', + name: 'operations', + type: 'folder', + href: `${path}/operations`, + path: `${path}/operations`, + hasChildren: true, + isLoading: false, + isExpanded: false, + data: { folderType: 'operations', componentId: entity.id, entityType: 'app' }, + }, + { + id: 'configurations', + name: 'configurations', + type: 'folder', + href: `${path}/configurations`, + path: `${path}/configurations`, + hasChildren: true, + isLoading: false, + isExpanded: false, + data: { folderType: 'configurations', componentId: entity.id, entityType: 'app' }, + }, + { + id: 'faults', + name: 'faults', + type: 'folder', + href: `${path}/faults`, + path: `${path}/faults`, + hasChildren: true, + isLoading: false, + isExpanded: false, + data: { folderType: 'faults', componentId: entity.id, entityType: 'app' }, }, ]; } @@ -202,9 +310,14 @@ export const useAppStore = create()( operations: new Map(), isLoadingOperations: false, - // Active goals state - activeGoals: new Map(), - autoRefreshGoals: false, + // Active executions state - SOVD Execution model + activeExecutions: new Map(), + autoRefreshExecutions: false, + + // Faults state + faults: [], + isLoadingFaults: false, + faultStreamCleanup: null, // Connect to SOVD server connect: async (url: string, baseEndpoint: string = '') => { @@ -305,29 +418,58 @@ export const useAppStore = create()( 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, - }, - }; - }); + // For apps, use apps API; for components, use getEntities + if (folderData.entityType === 'app') { + const topics = await client.getAppData(folderData.componentId); + children = topics.map((topic) => { + // Use uniqueKey if available (includes direction), otherwise just topic name + const uniqueId = topic.uniqueKey || topic.topic; + const cleanName = uniqueId.startsWith('/') ? uniqueId.slice(1) : uniqueId; + const encodedName = encodeURIComponent(cleanName); + return { + id: encodedName, + name: topic.topic, + type: 'topic', + href: `${path}/${encodedName}`, + path: `${path}/${encodedName}`, + hasChildren: false, + isLoading: false, + isExpanded: false, + data: { + ...topic, + isPublisher: topic.isPublisher ?? false, + isSubscriber: topic.isSubscriber ?? false, + }, + }; + }); + } else { + 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); + const entityType = folderData.entityType === 'app' ? 'apps' : 'components'; + const ops = await client.listOperations(folderData.componentId, entityType); children = ops.map((op) => ({ id: op.name, name: op.name, @@ -353,6 +495,27 @@ export const useAppStore = create()( isExpanded: false, data: param, })); + } else if (folderData.folderType === 'faults') { + // Load faults for this entity + const entityType = folderData.entityType === 'app' ? 'apps' : 'components'; + const faultsResponse = await client.listEntityFaults(entityType, folderData.componentId); + children = faultsResponse.items.map((fault) => ({ + id: fault.code, + name: `${fault.code}: ${fault.message}`, + type: 'fault', + href: `${path}/${encodeURIComponent(fault.code)}`, + path: `${path}/${encodeURIComponent(fault.code)}`, + hasChildren: false, + isLoading: false, + isExpanded: false, + data: fault, + })); + } else if (folderData.folderType === 'apps') { + // Load apps belonging to this component using efficient server-side filtering + const componentApps = await client.listComponentApps(folderData.componentId); + children = componentApps.map((app) => + toTreeNode({ ...app, type: 'app', hasChildren: true }, path) + ); } const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ @@ -541,7 +704,7 @@ export const useAppStore = create()( } // Optimization for Component - just select it and auto-expand - // Don't modify children - virtual folders (data/, operations/, configurations/) are already there + // Don't modify children - virtual folders (data/, operations/, configurations/, faults/, apps/) are already there if (node && node.type === 'component') { // Auto-expand component to show virtual folders const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; @@ -562,6 +725,52 @@ export const useAppStore = create()( return; } + // Handle App entity selection - auto-expand to show virtual folders + if (node && node.type === 'app') { + const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; + const appData = node.data as App | undefined; + + set({ + selectedPath: path, + expandedPaths: newExpandedPaths, + isLoadingDetails: false, + selectedEntity: { + id: node.id, + name: node.name, + type: 'app', + href: node.href, + // Pass app-specific data + fqn: appData?.fqn || node.name, + node_name: appData?.node_name, + namespace: appData?.namespace, + component_id: appData?.component_id, + }, + }); + return; + } + + // Handle fault selection - show fault details + if (node && node.type === 'fault' && node.data) { + const fault = node.data as Fault; + // Extract entity info from path: /area/component/faults/code + const pathSegments = path.split('/').filter(Boolean); + const entityId = pathSegments.length >= 2 ? pathSegments[pathSegments.length - 3] : ''; + + set({ + selectedPath: path, + isLoadingDetails: false, + selectedEntity: { + id: node.id, + name: fault.message, + type: 'fault', + href: node.href, + data: fault, + entityId, + }, + }); + return; + } + // Handle virtual folder selection - show appropriate panel if (node && isVirtualFolderData(node.data)) { const folderData = node.data as VirtualFolderData; @@ -576,6 +785,7 @@ export const useAppStore = create()( // Pass folder info so detail panel knows what to show folderType: folderData.folderType, componentId: folderData.componentId, + entityType: folderData.entityType, }, }); return; @@ -692,16 +902,16 @@ export const useAppStore = create()( // CONFIGURATIONS ACTIONS (ROS 2 Parameters) // =========================================================================== - fetchConfigurations: async (componentId: string) => { + fetchConfigurations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { const { client, configurations } = get(); if (!client) return; set({ isLoadingConfigurations: true }); try { - const result = await client.listConfigurations(componentId); + const result = await client.listConfigurations(entityId, entityType); const newConfigs = new Map(configurations); - newConfigs.set(componentId, result.parameters); + newConfigs.set(entityId, result.parameters); set({ configurations: newConfigs, isLoadingConfigurations: false }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -710,26 +920,52 @@ export const useAppStore = create()( } }, - setParameter: async (componentId: string, paramName: string, value: unknown) => { + setParameter: async ( + entityId: string, + paramName: string, + value: unknown, + entityType: 'components' | 'apps' = 'components' + ) => { const { client, configurations } = get(); if (!client) return false; try { - const result = await client.setConfiguration(componentId, paramName, { value }); + const result = await client.setConfiguration(entityId, paramName, { value }, entityType); + + // API returns {data: ..., id: ..., x-medkit: {parameter: {...}}} + // Success is indicated by presence of x-medkit.parameter (no status field) + const xMedkit = (result as { 'x-medkit'?: { parameter?: { name: string; value: unknown } } })[ + 'x-medkit' + ]; + const parameter = xMedkit?.parameter; - if (result.status === 'success') { + if (parameter) { // Update local state with new value const newConfigs = new Map(configurations); - const params = newConfigs.get(componentId) || []; + const params = newConfigs.get(entityId) || []; const updatedParams = params.map((p) => - p.name === paramName ? { ...p, value: result.parameter.value } : p + p.name === paramName ? { ...p, value: parameter.value } : p ); - newConfigs.set(componentId, updatedParams); + newConfigs.set(entityId, updatedParams); + set({ configurations: newConfigs }); + toast.success(`Parameter ${paramName} updated`); + return true; + } else if ((result as { status?: string }).status === 'success') { + // Legacy format fallback + const legacyResult = result as { parameter: { value: unknown } }; + const newConfigs = new Map(configurations); + const params = newConfigs.get(entityId) || []; + const updatedParams = params.map((p) => + p.name === paramName ? { ...p, value: legacyResult.parameter.value } : p + ); + newConfigs.set(entityId, updatedParams); set({ configurations: newConfigs }); toast.success(`Parameter ${paramName} updated`); return true; } else { - toast.error(`Failed to set parameter: ${result.error}`); + toast.error( + `Failed to set parameter: ${(result as { error?: string }).error || 'Unknown error'}` + ); return false; } } catch (error) { @@ -739,18 +975,22 @@ export const useAppStore = create()( } }, - resetParameter: async (componentId: string, paramName: string) => { + resetParameter: async ( + entityId: string, + paramName: string, + entityType: 'components' | 'apps' = 'components' + ) => { const { client, configurations } = get(); if (!client) return false; try { - const result = await client.resetConfiguration(componentId, paramName); + const result = await client.resetConfiguration(entityId, paramName, entityType); // Update local state with reset value const newConfigs = new Map(configurations); - const params = newConfigs.get(componentId) || []; + const params = newConfigs.get(entityId) || []; const updatedParams = params.map((p) => (p.name === paramName ? { ...p, value: result.value } : p)); - newConfigs.set(componentId, updatedParams); + newConfigs.set(entityId, updatedParams); set({ configurations: newConfigs }); toast.success(`Parameter ${paramName} reset to default`); return true; @@ -761,12 +1001,12 @@ export const useAppStore = create()( } }, - resetAllConfigurations: async (componentId: string) => { + resetAllConfigurations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { const { client, fetchConfigurations } = get(); if (!client) return { reset_count: 0, failed_count: 0 }; try { - const result = await client.resetAllConfigurations(componentId); + const result = await client.resetAllConfigurations(entityId, entityType); if (result.failed_count === 0) { toast.success(`Reset ${result.reset_count} parameters to defaults`); @@ -775,7 +1015,7 @@ export const useAppStore = create()( } // Refresh configurations to get updated values - await fetchConfigurations(componentId); + await fetchConfigurations(entityId, entityType); return { reset_count: result.reset_count, failed_count: result.failed_count }; } catch (error) { @@ -786,19 +1026,19 @@ export const useAppStore = create()( }, // =========================================================================== - // OPERATIONS ACTIONS (ROS 2 Services & Actions) + // OPERATIONS ACTIONS (ROS 2 Services & Actions) - SOVD Execution Model // =========================================================================== - fetchOperations: async (componentId: string) => { + fetchOperations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { const { client, operations } = get(); if (!client) return; set({ isLoadingOperations: true }); try { - const result = await client.listOperations(componentId); + const result = await client.listOperations(entityId, entityType); const newOps = new Map(operations); - newOps.set(componentId, result); + newOps.set(entityId, result); set({ operations: newOps, isLoadingOperations: false }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -807,27 +1047,33 @@ export const useAppStore = create()( } }, - invokeOperation: async (componentId: string, operationName: string, request: InvokeOperationRequest) => { - const { client, activeGoals } = get(); + createExecution: async ( + entityId: string, + operationName: string, + request: CreateExecutionRequest, + entityType: 'components' | 'apps' = 'components' + ) => { + const { client, activeExecutions } = 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', + const result = await client.createExecution(entityId, operationName, request, entityType); + + if (result.kind === 'action' && !result.error) { + // Track the new execution for actions + const newExecutions = new Map(activeExecutions); + newExecutions.set(result.id, { + id: result.id, + status: result.status, + created_at: new Date().toISOString(), + result: result.result, }); - 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`); + set({ activeExecutions: newExecutions }); + toast.success(`Action execution ${result.id.slice(0, 8)}... started`); + } else if (result.kind === 'service' && !result.error) { + toast.success(`Service ${operationName} executed successfully`); + } else if (result.error) { + toast.error(`Operation failed: ${result.error}`); } return result; @@ -838,65 +1084,129 @@ export const useAppStore = create()( } }, - refreshActionStatus: async (componentId: string, operationName: string, goalId: string) => { - const { client, activeGoals } = get(); + refreshExecutionStatus: async ( + entityId: string, + operationName: string, + executionId: string, + entityType: 'components' | 'apps' = 'components' + ) => { + const { client, activeExecutions } = 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 - } - } + const execution = await client.getExecution(entityId, operationName, executionId, entityType); + const newExecutions = new Map(activeExecutions); + newExecutions.set(executionId, execution); + set({ activeExecutions: newExecutions }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to refresh action status: ${message}`); + toast.error(`Failed to refresh execution status: ${message}`); } }, - cancelActionGoal: async (componentId: string, operationName: string, goalId: string) => { - const { client, activeGoals } = get(); + cancelExecution: async ( + entityId: string, + operationName: string, + executionId: string, + entityType: 'components' | 'apps' = 'components' + ) => { + const { client, activeExecutions } = get(); if (!client) return false; try { - const result = await client.cancelAction(componentId, operationName, goalId); + const execution = await client.cancelExecution(entityId, operationName, executionId, entityType); + const newExecutions = new Map(activeExecutions); + newExecutions.set(executionId, execution); + set({ activeExecutions: newExecutions }); + toast.success(`Cancel request sent for execution ${executionId.slice(0, 8)}...`); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to cancel execution: ${message}`); + return false; + } + }, - 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; - } + setAutoRefreshExecutions: (enabled: boolean) => { + set({ autoRefreshExecutions: enabled }); + }, + + // =========================================================================== + // FAULTS ACTIONS (Diagnostic Trouble Codes) + // =========================================================================== + + fetchFaults: async () => { + const { client } = get(); + if (!client) return; + + set({ isLoadingFaults: true }); + + try { + const result = await client.listAllFaults(); + set({ faults: result.items, isLoadingFaults: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to load faults: ${message}`); + set({ isLoadingFaults: false }); + } + }, + + clearFault: async (entityType: 'components' | 'apps', entityId: string, faultCode: string) => { + const { client, fetchFaults } = get(); + if (!client) return false; + + try { + await client.clearFault(entityType, entityId, faultCode); + toast.success(`Fault ${faultCode} cleared`); + // Refresh faults list + await fetchFaults(); + return true; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to cancel action: ${message}`); + toast.error(`Failed to clear fault: ${message}`); return false; } }, - setAutoRefreshGoals: (enabled: boolean) => { - set({ autoRefreshGoals: enabled }); + subscribeFaultStream: () => { + const { client, faultStreamCleanup } = get(); + if (!client) return; + + // Clean up existing subscription + if (faultStreamCleanup) { + faultStreamCleanup(); + } + + const cleanup = client.subscribeFaultStream( + (fault) => { + const { faults } = get(); + // Add or update fault in the list + const existingIndex = faults.findIndex( + (f) => f.code === fault.code && f.entity_id === fault.entity_id + ); + if (existingIndex >= 0) { + const newFaults = [...faults]; + newFaults[existingIndex] = fault; + set({ faults: newFaults }); + } else { + set({ faults: [...faults, fault] }); + } + toast.warning(`Fault: ${fault.message}`, { autoClose: 5000 }); + }, + (error) => { + toast.error(`Fault stream error: ${error.message}`); + } + ); + + set({ faultStreamCleanup: cleanup }); + }, + + unsubscribeFaultStream: () => { + const { faultStreamCleanup } = get(); + if (faultStreamCleanup) { + faultStreamCleanup(); + set({ faultStreamCleanup: null }); + } }, }), { diff --git a/src/lib/types.ts b/src/lib/types.ts index 6e14182..54e4263 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -123,13 +123,15 @@ export interface TopicNodeData { } /** - * Virtual folder data for component subfolders + * Virtual folder data for component/app subfolders */ export interface VirtualFolderData { - /** Type of virtual folder: data, operations, or configurations */ - folderType: 'data' | 'operations' | 'configurations'; - /** Parent component ID */ + /** Type of virtual folder: data, operations, configurations, faults, or apps */ + folderType: 'data' | 'operations' | 'configurations' | 'faults' | 'apps'; + /** Parent entity ID (component or app) */ componentId: string; + /** Parent entity type */ + entityType: 'component' | 'app'; /** Topics info (for data folder) */ topicsInfo?: ComponentTopicsInfo; } @@ -165,6 +167,35 @@ export interface ComponentTopic { publishers?: TopicEndpoint[]; /** List of subscriber endpoints with QoS */ subscribers?: TopicEndpoint[]; + /** Whether this node publishes to the topic */ + isPublisher?: boolean; + /** Whether this node subscribes to the topic */ + isSubscriber?: boolean; + /** Unique key combining topic and direction for React key */ + uniqueKey?: string; +} + +/** + * API response for data item (topic) from GET /components/{id}/data/{topic} + * This is the raw response structure from the gateway API. + */ +export interface DataItemResponse { + /** Topic data payload */ + data: unknown; + /** Item identifier */ + id: string; + /** Extended metadata from ros2_medkit gateway */ + 'x-medkit'?: { + ros2?: { + type?: string; + topic?: string; + direction?: string; + }; + timestamp?: number; + status?: string; + publisher_count?: number; + subscriber_count?: number; + }; } /** @@ -364,92 +395,324 @@ export interface InvokeOperationRequest { goal?: unknown; } +// ============================================================================= +// COMPONENT EXTENDED (with operations list from discovery) +// ============================================================================= + /** - * Response from POST /components/{id}/operations/{op} for services + * Extended component info including operations */ -export interface ServiceOperationResponse { - status: 'success' | 'error'; - kind: 'service'; - component_id: string; - operation: string; - response: unknown; +export interface ComponentWithOperations { + id: string; + namespace: string; + fqn: string; + type: string; + area: string; + /** List of available operations (services + actions) */ + operations?: Operation[]; +} + +// ============================================================================= +// EXECUTIONS (SOVD-compliant Operations Model) +// ============================================================================= + +/** + * Execution status values for SOVD operations + */ +export type ExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'canceled'; + +/** + * Execution resource representing an operation invocation (SOVD-compliant) + */ +export interface Execution { + /** Unique execution identifier */ + id: string; + /** Current execution status */ + status: ExecutionStatus; + /** ISO 8601 timestamp when execution was created */ + created_at: string; + /** ISO 8601 timestamp when execution started running */ + started_at?: string; + /** ISO 8601 timestamp when execution finished */ + finished_at?: string; + /** Result data for completed executions */ + result?: unknown; + /** Error message for failed executions */ error?: string; + /** Last feedback for action executions */ + last_feedback?: unknown; } /** - * Response from POST /components/{id}/operations/{op} for actions + * Request to create a new execution */ -export interface ActionOperationResponse { - status: 'success' | 'error'; - kind: 'action'; - component_id: string; - operation: string; - goal_id: string; - goal_status: 'accepted' | 'rejected'; +export interface CreateExecutionRequest { + /** Optional type override (auto-detected if not provided) */ + type?: string; + /** Input data for the operation (request for services, goal for actions) */ + input?: unknown; +} + +/** + * Response from POST /{entity}/operations/{op}/executions + */ +export interface CreateExecutionResponse { + /** Execution ID for tracking */ + id: string; + /** Initial execution status */ + status: ExecutionStatus; + /** Operation kind (service or action) */ + kind: OperationKind; + /** Result data (for synchronous service calls) */ + result?: unknown; + /** Error message if execution creation failed */ error?: string; } /** - * Union type for operation response + * Response from GET /{entity}/operations/{op}/executions */ -export type OperationResponse = ServiceOperationResponse | ActionOperationResponse; +export interface ListExecutionsResponse { + /** Array of executions */ + items: Execution[]; + /** Total count of executions */ + count: number; +} + +// ============================================================================= +// APPS (ROS 2 Nodes as SOVD Apps) +// ============================================================================= /** - * Action goal status values + * App entity representing a ROS 2 node */ -export type ActionGoalStatusValue = 'accepted' | 'executing' | 'canceling' | 'succeeded' | 'canceled' | 'aborted'; +export interface App extends SovdEntity { + /** ROS 2 node name */ + node_name: string; + /** ROS 2 namespace */ + namespace: string; + /** Fully qualified name (namespace + node_name) */ + fqn: string; + /** Parent component ID */ + component_id?: string; +} /** - * Response from GET /components/{id}/operations/{op}/status + * App capabilities response */ -export interface ActionGoalStatus { - goal_id: string; - status: ActionGoalStatusValue; - action_path: string; - action_type: string; - last_feedback?: unknown; +export interface AppCapabilities { + /** App identifier */ + id: string; + /** FQN of the node */ + fqn: string; + /** Available resource collections */ + resources: { + data?: boolean; + operations?: boolean; + configurations?: boolean; + faults?: boolean; + }; + /** Links to related resources */ + links?: Record; } +// ============================================================================= +// FUNCTIONS (Capability Groupings) +// ============================================================================= + /** - * Response from GET /components/{id}/operations/{op}/status?all=true + * SovdFunction entity representing a capability grouping + * (Named SovdFunction to avoid shadowing JavaScript's global Function type) */ -export interface AllActionGoalsStatus { - action_path: string; - goals: ActionGoalStatus[]; - count: number; +export interface SovdFunction extends SovdEntity { + /** Description of the function */ + description?: string; + /** IDs of apps that host this function */ + hosts: string[]; } /** - * Response from GET /components/{id}/operations/{op}/result + * Function capabilities response */ -export interface ActionGoalResult { - goal_id: string; - status: ActionGoalStatusValue; - result: unknown; +export interface FunctionCapabilities { + /** Function identifier */ + id: string; + /** Function description */ + description?: string; + /** Available resource collections */ + resources: { + data?: boolean; + operations?: boolean; + hosts?: boolean; + }; + /** Links to related resources */ + links?: Record; } +// ============================================================================= +// FAULTS (SOVD Diagnostic Trouble Codes) +// ============================================================================= + +/** + * Fault severity levels + */ +export type FaultSeverity = 'info' | 'warning' | 'error' | 'critical'; + +/** + * Fault status values + */ +export type FaultStatus = 'active' | 'pending' | 'cleared'; + /** - * Response from DELETE /components/{id}/operations/{op} + * Fault entity representing a diagnostic trouble code */ -export interface ActionCancelResponse { - status: 'canceling' | 'error'; - goal_id: string; +export interface Fault { + /** Unique fault code identifier */ + code: string; + /** Human-readable fault message */ message: string; + /** Fault severity level */ + severity: FaultSeverity; + /** Current fault status */ + status: FaultStatus; + /** ISO 8601 timestamp when fault was detected */ + timestamp: string; + /** Entity ID where fault originated */ + entity_id: string; + /** Entity type (component, app, etc.) */ + entity_type: string; + /** Additional fault parameters */ + parameters?: Record; + /** Whether snapshots are available for this fault */ + has_snapshots?: boolean; +} + +/** + * Response from GET /faults or GET /{entity}/faults + */ +export interface ListFaultsResponse { + /** Array of faults */ + items: Fault[]; + /** Total count of faults */ + count: number; +} + +/** + * Fault snapshot for debugging + */ +export interface FaultSnapshot { + /** Snapshot identifier */ + id: string; + /** ISO 8601 timestamp when snapshot was captured */ + timestamp: string; + /** Captured data at time of fault */ + data: Record; +} + +/** + * Response from GET /{entity}/faults/{code}/snapshots + */ +export interface ListSnapshotsResponse { + /** Array of snapshots */ + items: FaultSnapshot[]; + /** Total count of snapshots */ + count: number; } // ============================================================================= -// COMPONENT EXTENDED (with operations list from discovery) +// SERVER CAPABILITIES (SOVD Discovery) // ============================================================================= /** - * Extended component info including operations + * Server capabilities from GET / */ -export interface ComponentWithOperations { - id: string; - namespace: string; - fqn: string; - type: string; - area: string; - /** List of available operations (services + actions) */ - operations?: Operation[]; +export interface ServerCapabilities { + /** SOVD specification version */ + sovd_version: string; + /** Server implementation name */ + server_name?: string; + /** Server implementation version */ + server_version?: string; + /** Supported features */ + supported_features: string[]; + /** Entry points (resource collection URLs) */ + entry_points: Record; +} + +/** + * Version info from GET /version-info + */ +export interface VersionInfo { + /** SOVD specification version */ + sovd_version: string; + /** Server implementation version */ + implementation_version?: string; + /** Additional version details */ + details?: Record; +} + +// ============================================================================= +// AUTHENTICATION (Optional SOVD Auth) +// ============================================================================= + +/** + * Authentication request for client_credentials grant + */ +export interface AuthRequest { + /** Grant type (client_credentials) */ + grant_type: 'client_credentials'; + /** Client identifier */ + client_id: string; + /** Client secret */ + client_secret: string; +} + +/** + * Authentication response with tokens + */ +export interface AuthResponse { + /** JWT access token */ + access_token: string; + /** Token type (Bearer) */ + token_type: 'Bearer'; + /** Access token expiry in seconds */ + expires_in: number; + /** Refresh token for obtaining new access tokens */ + refresh_token?: string; +} + +/** + * Token refresh request + */ +export interface TokenRefreshRequest { + /** Grant type for refresh */ + grant_type: 'refresh_token'; + /** Refresh token */ + refresh_token: string; +} + +/** + * Token revocation request + */ +export interface TokenRevokeRequest { + /** Token to revoke */ + token: string; + /** Token type hint */ + token_type_hint?: 'refresh_token' | 'access_token'; +} + +// ============================================================================= +// GENERIC ERROR (SOVD Error Format) +// ============================================================================= + +/** + * SOVD-compliant generic error response + */ +export interface SovdError { + /** Error code identifier */ + error_code: string; + /** Human-readable error message */ + message: string; + /** Additional error parameters */ + parameters?: Record; }