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 && (
-
- )}
-
- {/* 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;
}