diff --git a/.gitignore b/.gitignore index a547bf3..fd85165 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Serena +.serena/ diff --git a/index.html b/index.html index 099890c..a88e82f 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - SOVD Web UI + ros2_medkit Web UI
diff --git a/package-lock.json b/package-lock.json index 8f40d92..6cbd24f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,13 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", + "@selfpatch/ros2-medkit-client-ts": "^0.1.1", "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.544.0", + "openapi-fetch": "^0.17.0", "radix-ui": "^1.4.3", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -30,6 +32,7 @@ "devDependencies": { "@eslint/js": "^9.36.0", "@tailwindcss/postcss": "^4.1.14", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -3245,6 +3248,18 @@ "win32" ] }, + "node_modules/@selfpatch/ros2-medkit-client-ts": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@selfpatch/ros2-medkit-client-ts/-/ros2-medkit-client-ts-0.1.1.tgz", + "integrity": "sha512-xM4OKvMQN738EEj7ROK+yBKWQwmAb6QZKoT8bDIj/JqaSyAnZPRrpLgsHDryysIzCnDsZkHMCbHINSJ8S1Nc8w==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "openapi-fetch": "^0.17.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3463,60 +3478,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.6.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.6.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", @@ -3583,7 +3544,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3672,8 +3632,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4308,7 +4267,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4844,8 +4802,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.262", @@ -6122,7 +6079,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6346,6 +6302,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6547,7 +6518,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6563,7 +6533,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6702,8 +6671,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", diff --git a/package.json b/package.json index 6692ca1..e61c4bc 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,13 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", + "@selfpatch/ros2-medkit-client-ts": "^0.1.1", "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.544.0", + "openapi-fetch": "^0.17.0", "radix-ui": "^1.4.3", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -52,6 +54,7 @@ "devDependencies": { "@eslint/js": "^9.36.0", "@tailwindcss/postcss": "^4.1.14", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/src/App.tsx b/src/App.tsx index 9bf153f..89e267f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,11 +16,10 @@ import { useAppStore } from '@/lib/store'; type ViewMode = 'entity' | 'faults-dashboard'; function App() { - const { isConnected, serverUrl, baseEndpoint, connect, clearSelection, selectedPath } = useAppStore( + const { isConnected, serverUrl, connect, clearSelection, selectedPath } = useAppStore( useShallow((state) => ({ isConnected: state.isConnected, serverUrl: state.serverUrl, - baseEndpoint: state.baseEndpoint, connect: state.connect, clearSelection: state.clearSelection, selectedPath: state.selectedPath, @@ -63,17 +62,20 @@ function App() { } }, [selectedPath]); - // Auto-connect on mount if we have a stored URL + // Auto-connect on mount if we have a stored URL (once only) useEffect(() => { - if (serverUrl && !isConnected && !autoConnectAttempted.current) { - autoConnectAttempted.current = true; - connect(serverUrl, baseEndpoint).then((success) => { + if (!serverUrl || isConnected || autoConnectAttempted.current) return; + autoConnectAttempted.current = true; + + const timeoutId = setTimeout(() => { + connect(serverUrl).then((success) => { if (!success) { - toast.error('Auto-connect failed. Please check your server settings.'); setShowConnectionDialog(true); } }); - } + }, 0); + + return () => clearTimeout(timeoutId); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index ff22272..4cf84f3 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -53,26 +53,27 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI const [faults, setFaults] = useState([]); const [isLoading, setIsLoading] = useState(false); - const { client, selectEntity, configurations } = useAppStore( + const { selectEntity, configurations, fetchEntityData, fetchEntityOperations, listEntityFaults } = useAppStore( useShallow((state) => ({ - client: state.client, selectEntity: state.selectEntity, configurations: state.configurations, + fetchEntityData: state.fetchEntityData, + fetchEntityOperations: state.fetchEntityOperations, + listEntityFaults: state.listEntityFaults, })) ); // Load app resources on mount (configurations are loaded by ConfigurationPanel) useEffect(() => { const loadAppData = async () => { - if (!client) return; setIsLoading(true); try { // Load resources in parallel (configurations handled by ConfigurationPanel) const [topicsData, opsData, faultsData] = await Promise.all([ - client.getAppData(appId).catch(() => []), - client.listOperations(appId, 'apps').catch(() => []), - client.listEntityFaults('apps', appId).catch(() => ({ items: [] })), + fetchEntityData('apps', appId).catch(() => [] as ComponentTopic[]), + fetchEntityOperations('apps', appId).catch(() => [] as Operation[]), + listEntityFaults('apps', appId).catch(() => ({ items: [] as Fault[], count: 0 })), ]); setTopics(topicsData); @@ -86,7 +87,7 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI }; loadAppData(); - }, [client, appId]); + }, [fetchEntityData, fetchEntityOperations, listEntityFaults, appId]); const handleResourceClick = (resourcePath: string) => { if (onNavigate) { diff --git a/src/components/ConfigurationPanel.tsx b/src/components/ConfigurationPanel.tsx index 12e3e24..0bbbb9f 100644 --- a/src/components/ConfigurationPanel.tsx +++ b/src/components/ConfigurationPanel.tsx @@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { useAppStore, type AppState } from '@/lib/store'; import type { Parameter, ParameterType } from '@/lib/types'; -import type { SovdResourceEntityType } from '@/lib/sovd-api'; +import type { SovdResourceEntityType } from '@/lib/types'; interface ConfigurationPanelProps { entityId: string; diff --git a/src/components/DataPanel.tsx b/src/components/DataPanel.tsx index ca4262a..584eebc 100644 --- a/src/components/DataPanel.tsx +++ b/src/components/DataPanel.tsx @@ -7,8 +7,8 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { JsonFormViewer } from '@/components/JsonFormViewer'; import { TopicPublishForm } from '@/components/TopicPublishForm'; import type { ComponentTopic, TopicEndpoint, QosProfile, SovdResourceEntityType } from '@/lib/types'; -import type { SovdApiClient } from '@/lib/sovd-api'; import { cn } from '@/lib/utils'; +import { useAppStore } from '@/lib/store'; interface DataPanelProps { /** Data item from the API */ @@ -17,8 +17,6 @@ interface DataPanelProps { entityId: string; /** Entity type for API endpoint */ entityType?: SovdResourceEntityType; - /** API client for publishing */ - client: SovdApiClient | null; /** Whether a refresh is in progress */ isRefreshing?: boolean; /** Callback when refresh is requested */ @@ -196,14 +194,14 @@ export function DataPanel({ topic, entityId, entityType = 'components', - client, isRefreshing = false, onRefresh, }: DataPanelProps) { const [publishValue, setPublishValue] = useState(topic.type_info?.default_value || topic.data || {}); + const isConnected = useAppStore((state) => state.isConnected); const hasData = topic.status === 'data' && topic.data !== null && topic.data !== undefined; - const canPublish = !!(topic.type || topic.type_info || topic.data); + const canPublish = isConnected && !!(topic.type || topic.type_info || topic.data); const handleCopyFromLast = () => { if (topic.data) { @@ -273,14 +271,13 @@ export function DataPanel({ {/* Publish Section */} - {canPublish && client && ( + {canPublish && (
Publish Message diff --git a/src/components/EmptyState.test.tsx b/src/components/EmptyState.test.tsx index 3921b5e..74794f5 100644 --- a/src/components/EmptyState.test.tsx +++ b/src/components/EmptyState.test.tsx @@ -12,7 +12,7 @@ describe('EmptyState', () => { render(); expect(screen.getByText('No Server Connected')).toBeInTheDocument(); - expect(screen.getByText('Connect to a SOVD server to browse entities.')).toBeInTheDocument(); + expect(screen.getByText('Connect to a ros2_medkit gateway to browse entities.')).toBeInTheDocument(); }); it('renders no-entities state', () => { diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index b5dbd86..038bee7 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -11,7 +11,7 @@ export function EmptyState({ type, onAction }: EmptyStateProps) { 'no-connection': { icon: Server, title: 'No Server Connected', - description: 'Connect to a SOVD server to browse entities.', + description: 'Connect to a ros2_medkit gateway to browse entities.', actionLabel: 'Connect to Server', }, 'no-entities': { diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 49fd200..cf6488c 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -33,8 +33,7 @@ import { FunctionsPanel } from '@/components/FunctionsPanel'; import { ServerInfoPanel } from '@/components/ServerInfoPanel'; import { FaultsDashboard } from '@/components/FaultsDashboard'; import { useAppStore, type AppState } from '@/lib/store'; -import type { ComponentTopic, Parameter } from '@/lib/types'; -import type { SovdResourceEntityType } from '@/lib/sovd-api'; +import type { ComponentTopic, Parameter, SovdResourceEntityType } from '@/lib/types'; type ComponentTab = 'data' | 'operations' | 'configurations' | 'faults'; @@ -367,9 +366,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit isLoadingDetails, isRefreshing, isConnected, - client, selectEntity, refreshSelectedEntity, + prefetchResourceCounts, + fetchEntityData, } = useAppStore( useShallow((state: AppState) => ({ selectedPath: state.selectedPath, @@ -377,9 +377,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit isLoadingDetails: state.isLoadingDetails, isRefreshing: state.isRefreshing, isConnected: state.isConnected, - client: state.client, selectEntity: state.selectEntity, refreshSelectedEntity: state.refreshSelectedEntity, + prefetchResourceCounts: state.prefetchResourceCounts, + fetchEntityData: state.fetchEntityData, })) ); @@ -392,8 +393,8 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // Fetch resource counts when entity changes useEffect(() => { - const fetchResourceCounts = async () => { - if (!client || !selectedEntity) { + const doFetchResourceCounts = async () => { + if (!selectedEntity) { setResourceCounts({ data: 0, operations: 0, configurations: 0, faults: 0 }); setTopicsData([]); return; @@ -419,30 +420,25 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit else if (isFunction) entityType = 'functions'; try { - const [dataRes, opsRes, configRes, faultsRes] = await Promise.all([ - client.getEntityData(entityType, entityId).catch(() => []), - client.listOperations(entityId, entityType).catch(() => []), - client.listConfigurations(entityId, entityType).catch(() => ({ parameters: [] })), - client.listEntityFaults(entityType, entityId).catch(() => ({ items: [] })), + // Fetch resource counts and data in parallel + const [counts, dataRes] = await Promise.all([ + prefetchResourceCounts(entityType, entityId), + fetchEntityData(entityType, entityId).catch(() => [] as ComponentTopic[]), ]); // Store the fetched data for the Data tab const fetchedData = Array.isArray(dataRes) ? dataRes : []; setTopicsData(fetchedData); - setResourceCounts({ - data: fetchedData.length, - operations: Array.isArray(opsRes) ? opsRes.length : 0, - configurations: configRes.parameters?.length || 0, - faults: faultsRes.items?.length || 0, - }); + // Use the already-fetched data length instead of a separate request + setResourceCounts({ ...counts, data: fetchedData.length }); } catch { // Silently handle errors - counts will stay at 0 } }; - fetchResourceCounts(); - }, [client, selectedEntity]); + doFetchResourceCounts(); + }, [selectedEntity, prefetchResourceCounts, fetchEntityData]); const handleCopyEntity = async () => { if (selectedEntity) { @@ -785,7 +781,6 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit topic={topic} entityId={entityId} entityType={entityType} - client={client} isRefreshing={isRefreshing} onRefresh={refreshSelectedEntity} /> diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index 7331243..1700391 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useShallow } from 'zustand/shallow'; import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; @@ -7,8 +7,8 @@ import { useAppStore } from '@/lib/store'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; import { FaultsPanel } from '@/components/FaultsPanel'; -import type { SovdResourceEntityType } from '@/lib/sovd-api'; -import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; +import type { SovdResourceEntityType } from '@/lib/types'; +import type { ComponentTopic, Operation, Fault } from '@/lib/types'; type ResourceTab = 'data' | 'operations' | 'configurations' | 'faults'; @@ -56,49 +56,58 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate configurations: false, faults: false, }); + const loadedTabsRef = useRef(loadedTabs); + loadedTabsRef.current = loadedTabs; const [data, setData] = useState([]); const [operations, setOperations] = useState([]); - const [configurations, setConfigurations] = useState([]); const [faults, setFaults] = useState([]); - const { client, selectEntity } = useAppStore( + const { + selectEntity, + fetchEntityData, + fetchEntityOperations, + fetchConfigurations, + listEntityFaults, + storeConfigurations, + } = useAppStore( useShallow((state) => ({ - client: state.client, selectEntity: state.selectEntity, + fetchEntityData: state.fetchEntityData, + fetchEntityOperations: state.fetchEntityOperations, + fetchConfigurations: state.fetchConfigurations, + listEntityFaults: state.listEntityFaults, + storeConfigurations: state.configurations, })) ); // Lazy load resources for the active tab const loadTabResources = useCallback( async (tab: ResourceTab) => { - if (!client || loadedTabs[tab]) return; + if (loadedTabsRef.current[tab]) return; setIsLoading(true); try { switch (tab) { case 'data': { - const dataRes = await client - .getEntityData(entityType, entityId) - .catch(() => [] as ComponentTopic[]); + const dataRes = await fetchEntityData(entityType, entityId).catch(() => [] as ComponentTopic[]); setData(dataRes); break; } case 'operations': { - const opsRes = await client.listOperations(entityId, entityType).catch(() => [] as Operation[]); + const opsRes = await fetchEntityOperations(entityType, entityId).catch(() => [] as Operation[]); setOperations(opsRes); break; } case 'configurations': { - const configRes = await client - .listConfigurations(entityId, entityType) - .catch(() => ({ parameters: [] })); - setConfigurations(configRes.parameters || []); + await fetchConfigurations(entityId, entityType); + // Configurations are stored in the store's configurations map break; } case 'faults': { - const faultsRes = await client - .listEntityFaults(entityType, entityId) - .catch(() => ({ items: [] })); + const faultsRes = await listEntityFaults(entityType, entityId).catch(() => ({ + items: [] as Fault[], + count: 0, + })); setFaults(faultsRes.items || []); break; } @@ -110,7 +119,8 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate setIsLoading(false); } }, - [client, entityId, entityType, loadedTabs] + + [fetchEntityData, fetchEntityOperations, fetchConfigurations, listEntityFaults, entityId, entityType] ); // Load resources when tab changes @@ -136,7 +146,7 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate let count = 0; if (tab.id === 'data') count = data.length; if (tab.id === 'operations') count = operations.length; - if (tab.id === 'configurations') count = configurations.length; + if (tab.id === 'configurations') count = storeConfigurations.get(entityId)?.length || 0; if (tab.id === 'faults') count = faults.length; return ( diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx index 5cab03b..45b5d15 100644 --- a/src/components/FaultsDashboard.tsx +++ b/src/components/FaultsDashboard.tsx @@ -30,7 +30,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { SnapshotCard } from './SnapshotCard'; import { useAppStore } from '@/lib/store'; import type { Fault, FaultSeverity, FaultStatus, FaultResponse } from '@/lib/types'; -import { mapFaultEntityTypeToResourceType } from '@/lib/sovd-api'; +import { mapFaultEntityTypeToResourceType } from '@/lib/utils'; /** * Default polling interval in milliseconds @@ -254,7 +254,7 @@ function FaultRow({
{environmentData.snapshots.map((snapshot, idx) => ( @@ -409,14 +409,22 @@ export function FaultsDashboard() { const [groupByEntity, setGroupByEntity] = useState(true); // Use shared faults state from store - const { faults, isLoadingFaults, isConnected, fetchFaults, clearFault, client, hasFaultStream } = useAppStore( + const { + faults, + isLoadingFaults, + isConnected, + fetchFaults, + clearFault, + getFaultWithEnvironmentData, + hasFaultStream, + } = useAppStore( useShallow((state) => ({ faults: state.faults, isLoadingFaults: state.isLoadingFaults, isConnected: state.isConnected, fetchFaults: state.fetchFaults, clearFault: state.clearFault, - client: state.client, + getFaultWithEnvironmentData: state.getFaultWithEnvironmentData, hasFaultStream: state.faultStreamCleanup !== null, })) ); @@ -486,16 +494,12 @@ export function FaultsDashboard() { newExpanded.add(faultCode); // Fetch details if not cached - if (!faultDetails.has(faultCode) && client) { + if (!faultDetails.has(faultCode)) { setLoadingDetails((prev) => new Set([...prev, faultCode])); try { const entityGroup = mapFaultEntityTypeToResourceType(fault.entity_type); - const details = await client.getFaultWithEnvironmentData( - entityGroup, - fault.entity_id, - faultCode - ); - setFaultDetails((prev) => new Map(prev).set(faultCode, details)); + const details = await getFaultWithEnvironmentData(entityGroup, fault.entity_id, faultCode); + setFaultDetails((prev) => new Map(prev).set(faultCode, details as FaultResponse)); } catch (err) { console.error('Failed to fetch fault details:', err); } finally { @@ -510,7 +514,7 @@ export function FaultsDashboard() { setExpandedFaults(newExpanded); }, - [client, expandedFaults, faultDetails] + [getFaultWithEnvironmentData, expandedFaults, faultDetails] ); // Filter faults diff --git a/src/components/FaultsPanel.tsx b/src/components/FaultsPanel.tsx index ae6196d..85aa1ae 100644 --- a/src/components/FaultsPanel.tsx +++ b/src/components/FaultsPanel.tsx @@ -18,9 +18,8 @@ import { Badge } from '@/components/ui/badge'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { SnapshotCard } from './SnapshotCard'; import { useAppStore, type AppState } from '@/lib/store'; -import type { Fault, FaultSeverity, FaultStatus, FaultResponse } from '@/lib/types'; -import { mapFaultEntityTypeToResourceType } from '@/lib/sovd-api'; -import type { SovdResourceEntityType } from '@/lib/sovd-api'; +import type { Fault, FaultSeverity, FaultStatus, FaultResponse, SovdResourceEntityType } from '@/lib/types'; +import { mapFaultEntityTypeToResourceType } from '@/lib/utils'; interface FaultsPanelProps { entityId: string; @@ -235,7 +234,7 @@ function FaultRow({
{environmentData.snapshots.map((snapshot, idx) => ( @@ -274,20 +273,20 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel const [faultDetails, setFaultDetails] = useState>(new Map()); const [loadingDetails, setLoadingDetails] = useState>(new Set()); - const { client } = useAppStore( + const { listEntityFaults, getFaultWithEnvironmentData, clearFault } = useAppStore( useShallow((state: AppState) => ({ - client: state.client, + listEntityFaults: state.listEntityFaults, + getFaultWithEnvironmentData: state.getFaultWithEnvironmentData, + clearFault: state.clearFault, })) ); const loadFaults = useCallback(async () => { - if (!client) return; - setIsLoading(true); setError(null); try { - const response = await client.listEntityFaults(entityType, entityId); + const response = await listEntityFaults(entityType, entityId); setFaults(response.items || []); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load faults'); @@ -295,7 +294,7 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel } finally { setIsLoading(false); } - }, [client, entityId, entityType]); + }, [listEntityFaults, entityId, entityType]); useEffect(() => { loadFaults(); @@ -311,7 +310,7 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel newExpanded.add(faultCode); // Fetch details if not cached - if (!faultDetails.has(faultCode) && client) { + if (!faultDetails.has(faultCode)) { setLoadingDetails((prev) => new Set([...prev, faultCode])); try { // Use the fault's own entity info (app-level) for correct bulk_data_uri. @@ -322,12 +321,8 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel ? mapFaultEntityTypeToResourceType(fault.entity_type) : entityType; const detailEntityId = fault?.entity_id || entityId; - const details = await client.getFaultWithEnvironmentData( - detailEntityType, - detailEntityId, - faultCode - ); - setFaultDetails((prev) => new Map(prev).set(faultCode, details)); + const details = await getFaultWithEnvironmentData(detailEntityType, detailEntityId, faultCode); + setFaultDetails((prev) => new Map(prev).set(faultCode, details as FaultResponse)); } catch (err) { console.error('Failed to fetch fault details:', err); } finally { @@ -342,17 +337,15 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel setExpandedFaults(newExpanded); }, - [client, entityType, entityId, expandedFaults, faultDetails, faults] + [getFaultWithEnvironmentData, entityType, entityId, expandedFaults, faultDetails, faults] ); const handleClear = useCallback( async (code: string) => { - if (!client) return; - setClearingCodes((prev) => new Set([...prev, code])); try { - await client.clearFault(entityType, entityId, code); + await clearFault(entityType, entityId, code); // Reload faults after clearing await loadFaults(); } catch { @@ -365,7 +358,7 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel }); } }, - [client, entityId, entityType, loadFaults] + [clearFault, entityId, entityType, loadFaults] ); // Count faults by severity diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx index 431808f..dd21fc3 100644 --- a/src/components/FunctionsPanel.tsx +++ b/src/components/FunctionsPanel.tsx @@ -18,7 +18,7 @@ import { useAppStore } from '@/lib/store'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; import { FaultsPanel } from '@/components/FaultsPanel'; -import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; +import type { ComponentTopic, Operation, Fault } from '@/lib/types'; /** Host app object returned from /functions/{id}/hosts */ interface FunctionHost { @@ -65,37 +65,42 @@ export function FunctionsPanel({ functionId, functionName, description, path, on const [hosts, setHosts] = useState([]); const [topics, setTopics] = useState([]); const [operations, setOperations] = useState([]); - const [configurations, setConfigurations] = useState([]); const [faults, setFaults] = useState([]); const [isLoading, setIsLoading] = useState(false); - const { client, selectEntity } = useAppStore( + const { + selectEntity, + getFunctionHosts, + fetchEntityData, + fetchEntityOperations, + fetchConfigurations, + listEntityFaults, + storeConfigurations, + } = useAppStore( useShallow((state) => ({ - client: state.client, selectEntity: state.selectEntity, + getFunctionHosts: state.getFunctionHosts, + fetchEntityData: state.fetchEntityData, + fetchEntityOperations: state.fetchEntityOperations, + fetchConfigurations: state.fetchConfigurations, + listEntityFaults: state.listEntityFaults, + storeConfigurations: state.configurations, })) ); // Load function resources on mount useEffect(() => { const loadFunctionData = async () => { - if (!client) return; setIsLoading(true); try { // Load hosts, data, operations, configurations, and faults in parallel - const [hostsData, topicsData, opsData, configData, faultsData] = await Promise.all([ - client.getFunctionHosts - ? client.getFunctionHosts(functionId).catch(() => [] as FunctionHost[]) - : Promise.resolve([]), - client.getFunctionData - ? client.getFunctionData(functionId).catch(() => [] as ComponentTopic[]) - : Promise.resolve([]), - client.getFunctionOperations - ? client.getFunctionOperations(functionId).catch(() => [] as Operation[]) - : Promise.resolve([]), - client.listConfigurations(functionId, 'functions').catch(() => ({ parameters: [] })), - client.listEntityFaults('functions', functionId).catch(() => ({ items: [] })), + const [hostsData, topicsData, opsData, , faultsData] = await Promise.all([ + getFunctionHosts(functionId).catch(() => [] as unknown[]), + fetchEntityData('functions', functionId).catch(() => [] as ComponentTopic[]), + fetchEntityOperations('functions', functionId).catch(() => [] as Operation[]), + fetchConfigurations(functionId, 'functions'), + listEntityFaults('functions', functionId).catch(() => ({ items: [] as Fault[], count: 0 })), ]); // Normalize hosts - API returns objects with {id, name, href} @@ -110,7 +115,6 @@ export function FunctionsPanel({ functionId, functionName, description, path, on setHosts(normalizedHosts); setTopics(topicsData); setOperations(opsData); - setConfigurations(configData.parameters || []); setFaults(faultsData.items || []); } catch (error) { console.error('Failed to load function data:', error); @@ -120,7 +124,7 @@ export function FunctionsPanel({ functionId, functionName, description, path, on }; loadFunctionData(); - }, [client, functionId]); + }, [getFunctionHosts, fetchEntityData, fetchEntityOperations, fetchConfigurations, listEntityFaults, functionId]); const handleResourceClick = (resourcePath: string) => { if (onNavigate) { @@ -162,7 +166,7 @@ export function FunctionsPanel({ functionId, functionName, description, path, on if (tab.id === 'hosts') count = hosts.length; if (tab.id === 'data') count = topics.length; if (tab.id === 'operations') count = operations.length; - if (tab.id === 'configurations') count = configurations.length; + if (tab.id === 'configurations') count = storeConfigurations.get(functionId)?.length || 0; if (tab.id === 'faults') count = faults.length; return ( @@ -240,7 +244,9 @@ export function FunctionsPanel({ functionId, functionName, description, path, on className="p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left" > -
{configurations.length}
+
+ {storeConfigurations.get(functionId)?.length || 0} +
Configs
- Connect to SOVD Server - Enter the URL and base endpoint of your SOVD server + Connect to ros2_medkit Gateway + Enter the URL of your ros2_medkit gateway
@@ -68,7 +65,7 @@ export function ServerConnectionDialog({ open, onOpenChange }: ServerConnectionD setUrl(e.target.value)} onKeyDown={handleKeyDown} @@ -76,24 +73,7 @@ export function ServerConnectionDialog({ open, onOpenChange }: ServerConnectionD aria-invalid={!!connectionError} />

- You can enter just IP:port or a full URL with protocol -

-
- -
- - setEndpoint(e.target.value)} - onKeyDown={handleKeyDown} - disabled={isConnecting} - /> -

- The path prefix for SOVD entities (leave empty for root) + Enter IP:port or a full URL. The API path (/api/v1) is added automatically.

diff --git a/src/components/ServerInfoPanel.tsx b/src/components/ServerInfoPanel.tsx index e3971f2..afb9a61 100644 --- a/src/components/ServerInfoPanel.tsx +++ b/src/components/ServerInfoPanel.tsx @@ -22,17 +22,18 @@ export function ServerInfoPanel() { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const { client, isConnected, serverUrl } = useAppStore( + const { isConnected, serverUrl, getServerCapabilities, getVersionInfoAction } = useAppStore( useShallow((state) => ({ - client: state.client, isConnected: state.isConnected, serverUrl: state.serverUrl, + getServerCapabilities: state.getServerCapabilities, + getVersionInfoAction: state.getVersionInfoAction, })) ); useEffect(() => { const loadServerInfo = async () => { - if (!client || !isConnected) { + if (!isConnected) { setIsLoading(false); return; } @@ -42,11 +43,11 @@ export function ServerInfoPanel() { try { const [caps, version] = await Promise.all([ - client.getServerCapabilities().catch(() => null), - client.getVersionInfo().catch(() => null), + getServerCapabilities().catch(() => null), + getVersionInfoAction().catch(() => null), ]); - setCapabilities(caps); + setCapabilities(caps as ServerCapabilities | null); setVersionInfo(version); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load server info'); @@ -56,7 +57,7 @@ export function ServerInfoPanel() { }; loadServerInfo(); - }, [client, isConnected]); + }, [isConnected, getServerCapabilities, getVersionInfoAction]); if (!isConnected) { return ( @@ -122,7 +123,7 @@ export function ServerInfoPanel() {
- {sovdInfo?.vendor_info?.name || capabilities?.server_name || 'SOVD Server'} + {sovdInfo?.vendor_info?.name || capabilities?.server_name || 'ros2_medkit Gateway'} diff --git a/src/components/SnapshotCard.tsx b/src/components/SnapshotCard.tsx index bb3b992..fe5e163 100644 --- a/src/components/SnapshotCard.tsx +++ b/src/components/SnapshotCard.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Clock, Database, FileBox } from 'lucide-react'; import { RosbagDownloadButton } from './RosbagDownloadButton'; -import { formatBytes, formatDuration } from '@/lib/sovd-api'; +import { formatBytes, formatDuration } from '@/lib/utils'; import type { RosbagSnapshot, Snapshot } from '@/lib/types'; import { isRosbagSnapshot } from '@/lib/types'; diff --git a/src/components/TopicPublishForm.tsx b/src/components/TopicPublishForm.tsx index 78be907..ce2b97c 100644 --- a/src/components/TopicPublishForm.tsx +++ b/src/components/TopicPublishForm.tsx @@ -5,8 +5,9 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { SchemaForm } from '@/components/SchemaFormField'; import { getSchemaDefaults, deepMerge } from '@/lib/schema-utils'; +import { useShallow } from 'zustand/shallow'; +import { useAppStore } from '@/lib/store'; import type { ComponentTopic, TopicSchema, SovdResourceEntityType } from '@/lib/types'; -import type { SovdApiClient } from '@/lib/sovd-api'; interface TopicPublishFormProps { /** The topic to publish to */ @@ -15,8 +16,6 @@ interface TopicPublishFormProps { entityId: string; /** Entity type for API endpoint */ entityType?: SovdResourceEntityType; - /** API client instance */ - client: SovdApiClient; /** External initial value (overrides topic-based defaults) */ initialValue?: unknown; /** Callback when value changes */ @@ -65,10 +64,14 @@ export function TopicPublishForm({ topic, entityId, entityType = 'components', - client, initialValue, onValueChange, }: TopicPublishFormProps) { + const { publishToEntityData } = useAppStore( + useShallow((state) => ({ + publishToEntityData: state.publishToEntityData, + })) + ); const [viewMode, setViewMode] = useState('form'); const [formValues, setFormValues] = useState>(() => { if (initialValue && typeof initialValue === 'object') { @@ -165,9 +168,8 @@ export function TopicPublishForm({ setIsPublishing(true); try { - await client.publishToEntityData(entityType, entityId, topicName, { - type: messageType, - data: dataToPublish, + await publishToEntityData(entityType, entityId, topicName, { + value: { type: messageType, data: dataToPublish }, }); toast.success(`Published to ${topic.topic}`); } catch (error) { diff --git a/src/lib/api-dispatch.test.ts b/src/lib/api-dispatch.test.ts new file mode 100644 index 0000000..5445804 --- /dev/null +++ b/src/lib/api-dispatch.test.ts @@ -0,0 +1,600 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SovdResourceEntityType } from './types'; +import { + getEntityDetail, + getEntityData, + getEntityDataItem, + putEntityDataItem, + getEntityOperations, + getEntityFaults, + getEntityFaultDetail, + deleteEntityFault, + deleteEntityFaults, + getEntityConfigurations, + getEntityConfiguration, + putEntityConfiguration, + deleteEntityConfiguration, + deleteEntityConfigurations, + postEntityExecution, + getEntityExecution, + deleteEntityExecution, + getEntityBulkDataCategories, + getEntityBulkData, +} from './api-dispatch'; + +// --------------------------------------------------------------------------- +// Mock client factory +// --------------------------------------------------------------------------- + +function createMockClient() { + return { + GET: vi.fn().mockResolvedValue({ data: { ok: true }, error: undefined }), + POST: vi.fn().mockResolvedValue({ data: { ok: true }, error: undefined }), + PUT: vi.fn().mockResolvedValue({ data: { ok: true }, error: undefined }), + DELETE: vi.fn().mockResolvedValue({ data: { ok: true }, error: undefined }), + streams: {}, + }; +} + +// Cast helper - avoids repeating `as any` everywhere +type MockClient = ReturnType; + +const ENTITY_TYPES: SovdResourceEntityType[] = ['apps', 'components', 'areas', 'functions']; + +const ID_PARAM_MAP: Record = { + apps: 'app_id', + components: 'component_id', + areas: 'area_id', + functions: 'function_id', +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Assert a single GET was called with the expected path pattern and path params. */ +function expectGet(client: MockClient, pathSubstring: string, pathParams: Record) { + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toContain(pathSubstring); + expect(opts.params.path).toEqual(expect.objectContaining(pathParams)); +} + +function expectPut(client: MockClient, pathSubstring: string, pathParams: Record, body?: unknown) { + expect(client.PUT).toHaveBeenCalledTimes(1); + const [path, opts] = client.PUT.mock.calls[0]!; + expect(path).toContain(pathSubstring); + expect(opts.params.path).toEqual(expect.objectContaining(pathParams)); + if (body !== undefined) { + expect(opts.body).toEqual(body); + } +} + +function expectPost(client: MockClient, pathSubstring: string, pathParams: Record, body?: unknown) { + expect(client.POST).toHaveBeenCalledTimes(1); + const [path, opts] = client.POST.mock.calls[0]!; + expect(path).toContain(pathSubstring); + expect(opts.params.path).toEqual(expect.objectContaining(pathParams)); + if (body !== undefined) { + expect(opts.body).toEqual(body); + } +} + +function expectDelete(client: MockClient, pathSubstring: string, pathParams: Record) { + expect(client.DELETE).toHaveBeenCalledTimes(1); + const [path, opts] = client.DELETE.mock.calls[0]!; + expect(path).toContain(pathSubstring); + expect(opts.params.path).toEqual(expect.objectContaining(pathParams)); +} + +// ============================================================================= +// getEntityDetail +// ============================================================================= + +describe('getEntityDetail', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id} for entity type "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityDetail(client as any, entityType, 'my-entity'); + expectGet(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity' }); + }); + + it('returns the client response', async () => { + client.GET.mockResolvedValueOnce({ data: { id: 'e1', name: 'Engine' }, error: undefined }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await getEntityDetail(client as any, 'apps', 'e1'); + expect(result).toEqual({ data: { id: 'e1', name: 'Engine' }, error: undefined }); + }); + + it('returns error when client returns error', async () => { + const errorResponse = { data: undefined, error: { message: 'Not found' } }; + client.GET.mockResolvedValueOnce(errorResponse); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await getEntityDetail(client as any, 'components', 'missing'); + expect(result).toEqual(errorResponse); + }); +}); + +// ============================================================================= +// getEntityData +// ============================================================================= + +describe('getEntityData', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/data for entity type "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityData(client as any, entityType, 'my-entity'); + expectGet(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity' }); + const path = client.GET.mock.calls[0]![0] as string; + expect(path).toContain('/data'); + }); +}); + +// ============================================================================= +// getEntityDataItem +// ============================================================================= + +describe('getEntityDataItem', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/data/{data_id} for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityDataItem(client as any, entityType, 'my-entity', 'temp-sensor'); + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toContain(`/${entityType}/`); + expect(path).toContain('/data/'); + expect(opts.params.path).toEqual( + expect.objectContaining({ [ID_PARAM_MAP[entityType]]: 'my-entity', data_id: 'temp-sensor' }) + ); + }); +}); + +// ============================================================================= +// putEntityDataItem +// ============================================================================= + +describe('putEntityDataItem', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls PUT /%s/{id}/data/{data_id} for "%s"', async (entityType) => { + const body = { value: 42 }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await putEntityDataItem(client as any, entityType, 'my-entity', 'temp-sensor', body); + expectPut(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity', data_id: 'temp-sensor' }, body); + const path = client.PUT.mock.calls[0]![0] as string; + expect(path).toContain('/data/'); + }); +}); + +// ============================================================================= +// getEntityOperations +// ============================================================================= + +describe('getEntityOperations', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/operations for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityOperations(client as any, entityType, 'my-entity'); + expectGet(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity' }); + const path = client.GET.mock.calls[0]![0] as string; + expect(path).toContain('/operations'); + }); +}); + +// ============================================================================= +// getEntityFaults +// ============================================================================= + +describe('getEntityFaults', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/faults for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityFaults(client as any, entityType, 'my-entity'); + expectGet(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity' }); + const path = client.GET.mock.calls[0]![0] as string; + expect(path).toContain('/faults'); + }); +}); + +// ============================================================================= +// getEntityFaultDetail +// ============================================================================= + +describe('getEntityFaultDetail', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/faults/{fault_code} for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityFaultDetail(client as any, entityType, 'my-entity', 'ENGINE_OVERHEAT'); + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toContain(`/${entityType}/`); + expect(path).toContain('/faults/'); + expect(opts.params.path).toEqual( + expect.objectContaining({ [ID_PARAM_MAP[entityType]]: 'my-entity', fault_code: 'ENGINE_OVERHEAT' }) + ); + }); +}); + +// ============================================================================= +// deleteEntityFault +// ============================================================================= + +describe('deleteEntityFault', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls DELETE /%s/{id}/faults/{fault_code} for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await deleteEntityFault(client as any, entityType, 'my-entity', 'BRAKE_FAIL'); + expect(client.DELETE).toHaveBeenCalledTimes(1); + const [path, opts] = client.DELETE.mock.calls[0]!; + expect(path).toContain(`/${entityType}/`); + expect(path).toContain('/faults/'); + expect(opts.params.path).toEqual( + expect.objectContaining({ [ID_PARAM_MAP[entityType]]: 'my-entity', fault_code: 'BRAKE_FAIL' }) + ); + }); +}); + +// ============================================================================= +// deleteEntityFaults +// ============================================================================= + +describe('deleteEntityFaults', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls DELETE /%s/{id}/faults for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await deleteEntityFaults(client as any, entityType, 'my-entity'); + expectDelete(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity' }); + const path = client.DELETE.mock.calls[0]![0] as string; + expect(path).toContain('/faults'); + }); +}); + +// ============================================================================= +// getEntityConfigurations +// ============================================================================= + +describe('getEntityConfigurations', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/configurations for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityConfigurations(client as any, entityType, 'my-entity'); + expectGet(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity' }); + const path = client.GET.mock.calls[0]![0] as string; + expect(path).toContain('/configurations'); + }); +}); + +// ============================================================================= +// getEntityConfiguration +// ============================================================================= + +describe('getEntityConfiguration', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/configurations/{config_id} for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityConfiguration(client as any, entityType, 'my-entity', 'max_rpm'); + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toContain(`/${entityType}/`); + expect(path).toContain('/configurations/'); + expect(opts.params.path).toEqual( + expect.objectContaining({ [ID_PARAM_MAP[entityType]]: 'my-entity', config_id: 'max_rpm' }) + ); + }); +}); + +// ============================================================================= +// putEntityConfiguration +// ============================================================================= + +describe('putEntityConfiguration', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls PUT /%s/{id}/configurations/{config_id} for "%s"', async (entityType) => { + const body = { value: 9000 }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await putEntityConfiguration(client as any, entityType, 'my-entity', 'max_rpm', body); + expectPut(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity', config_id: 'max_rpm' }, body); + const path = client.PUT.mock.calls[0]![0] as string; + expect(path).toContain('/configurations/'); + }); + + it('passes complex body values', async () => { + const body = { value: [1, 2, 3] }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await putEntityConfiguration(client as any, 'apps', 'my-app', 'thresholds', body); + const opts = client.PUT.mock.calls[0]![1]; + expect(opts.body).toEqual({ value: [1, 2, 3] }); + }); +}); + +// ============================================================================= +// deleteEntityConfiguration +// ============================================================================= + +describe('deleteEntityConfiguration', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls DELETE /%s/{id}/configurations/{config_id} for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await deleteEntityConfiguration(client as any, entityType, 'my-entity', 'max_rpm'); + expect(client.DELETE).toHaveBeenCalledTimes(1); + const [path, opts] = client.DELETE.mock.calls[0]!; + expect(path).toContain(`/${entityType}/`); + expect(path).toContain('/configurations/'); + expect(opts.params.path).toEqual( + expect.objectContaining({ [ID_PARAM_MAP[entityType]]: 'my-entity', config_id: 'max_rpm' }) + ); + }); +}); + +// ============================================================================= +// deleteEntityConfigurations +// ============================================================================= + +describe('deleteEntityConfigurations', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls DELETE /%s/{id}/configurations for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await deleteEntityConfigurations(client as any, entityType, 'my-entity'); + expectDelete(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity' }); + const path = client.DELETE.mock.calls[0]![0] as string; + expect(path).toContain('/configurations'); + }); +}); + +// ============================================================================= +// postEntityExecution +// ============================================================================= + +describe('postEntityExecution', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls POST /%s/{id}/operations/{op_id}/executions for "%s"', async (entityType) => { + const body = { input: { request: {} } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await postEntityExecution(client as any, entityType, 'my-entity', 'calibrate', body); + expectPost( + client, + `/${entityType}/`, + { [ID_PARAM_MAP[entityType]]: 'my-entity', operation_id: 'calibrate' }, + body + ); + const path = client.POST.mock.calls[0]![0] as string; + expect(path).toContain('/operations/'); + expect(path).toContain('/executions'); + }); + + it('passes empty input body', async () => { + const body = { input: undefined }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await postEntityExecution(client as any, 'apps', 'e1', 'trigger', body); + const opts = client.POST.mock.calls[0]![1]; + expect(opts.body).toEqual(body); + }); +}); + +// ============================================================================= +// getEntityExecution +// ============================================================================= + +describe('getEntityExecution', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/operations/{op_id}/executions/{exec_id} for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityExecution(client as any, entityType, 'my-entity', 'calibrate', 'exec-001'); + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toContain(`/${entityType}/`); + expect(path).toContain('/operations/'); + expect(path).toContain('/executions/'); + expect(opts.params.path).toEqual( + expect.objectContaining({ + [ID_PARAM_MAP[entityType]]: 'my-entity', + operation_id: 'calibrate', + execution_id: 'exec-001', + }) + ); + }); +}); + +// ============================================================================= +// deleteEntityExecution +// ============================================================================= + +describe('deleteEntityExecution', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)( + 'calls DELETE /%s/{id}/operations/{op_id}/executions/{exec_id} for "%s"', + async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await deleteEntityExecution(client as any, entityType, 'my-entity', 'calibrate', 'exec-001'); + expect(client.DELETE).toHaveBeenCalledTimes(1); + const [path, opts] = client.DELETE.mock.calls[0]!; + expect(path).toContain(`/${entityType}/`); + expect(path).toContain('/operations/'); + expect(path).toContain('/executions/'); + expect(opts.params.path).toEqual( + expect.objectContaining({ + [ID_PARAM_MAP[entityType]]: 'my-entity', + operation_id: 'calibrate', + execution_id: 'exec-001', + }) + ); + } + ); +}); + +// ============================================================================= +// getEntityBulkDataCategories +// ============================================================================= + +describe('getEntityBulkDataCategories', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/bulk-data for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityBulkDataCategories(client as any, entityType, 'my-entity'); + expectGet(client, `/${entityType}/`, { [ID_PARAM_MAP[entityType]]: 'my-entity' }); + const path = client.GET.mock.calls[0]![0] as string; + expect(path).toContain('/bulk-data'); + }); +}); + +// ============================================================================= +// getEntityBulkData +// ============================================================================= + +describe('getEntityBulkData', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(ENTITY_TYPES)('calls GET /%s/{id}/bulk-data/{category_id} for "%s"', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityBulkData(client as any, entityType, 'my-entity', 'rosbag-captures'); + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toContain(`/${entityType}/`); + expect(path).toContain('/bulk-data/'); + expect(opts.params.path).toEqual( + expect.objectContaining({ + [ID_PARAM_MAP[entityType]]: 'my-entity', + category_id: 'rosbag-captures', + }) + ); + }); + + it('returns data from the client', async () => { + const mockData = { items: [{ id: 'abc', name: 'capture.mcap' }] }; + client.GET.mockResolvedValueOnce({ data: mockData, error: undefined }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await getEntityBulkData(client as any, 'apps', 'e1', 'rosbags'); + expect(result).toEqual({ data: mockData, error: undefined }); + }); +}); + +// ============================================================================= +// Cross-cutting: error propagation +// ============================================================================= + +describe('error propagation', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it('propagates GET errors from getEntityConfigurations', async () => { + const err = { message: 'Internal Server Error', status: 500 }; + client.GET.mockResolvedValueOnce({ data: undefined, error: err }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await getEntityConfigurations(client as any, 'apps', 'e1'); + expect(result?.error).toEqual(err); + }); + + it('propagates PUT errors from putEntityConfiguration', async () => { + const err = { message: 'Validation failed', status: 400 }; + client.PUT.mockResolvedValueOnce({ data: undefined, error: err }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await putEntityConfiguration(client as any, 'components', 'c1', 'param', { value: 'bad' }); + expect(result?.error).toEqual(err); + }); + + it('propagates POST errors from postEntityExecution', async () => { + const err = { message: 'Service unavailable', status: 503 }; + client.POST.mockResolvedValueOnce({ data: undefined, error: err }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await postEntityExecution(client as any, 'areas', 'a1', 'op', { input: {} }); + expect(result?.error).toEqual(err); + }); + + it('propagates DELETE errors from deleteEntityFault', async () => { + const err = { message: 'Forbidden', status: 403 }; + client.DELETE.mockResolvedValueOnce({ data: undefined, error: err }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await deleteEntityFault(client as any, 'functions', 'f1', 'FC_001'); + expect(result?.error).toEqual(err); + }); +}); diff --git a/src/lib/api-dispatch.ts b/src/lib/api-dispatch.ts new file mode 100644 index 0000000..0a408c5 --- /dev/null +++ b/src/lib/api-dispatch.ts @@ -0,0 +1,496 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Entity-type dispatch helpers for the generated openapi-fetch client. + * + * The generated client uses per-entity-type paths (/apps/{app_id}/configurations, + * /components/{component_id}/configurations, etc.) rather than generic + * /{entity_type}/{entity_id}/... paths. These helpers route calls to the correct + * typed path based on the entity type string. + */ + +import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; +import type { SovdResourceEntityType } from './types'; + +// ============================================================================= +// Entity Detail +// ============================================================================= + +export function getEntityDetail(client: MedkitClient, entityType: SovdResourceEntityType, entityId: string) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}', { params: { path: { app_id: entityId } } }); + case 'components': + return client.GET('/components/{component_id}', { params: { path: { component_id: entityId } } }); + case 'areas': + return client.GET('/areas/{area_id}', { params: { path: { area_id: entityId } } }); + case 'functions': + return client.GET('/functions/{function_id}', { params: { path: { function_id: entityId } } }); + } +} + +// ============================================================================= +// Configurations +// ============================================================================= + +export function getEntityConfigurations(client: MedkitClient, entityType: SovdResourceEntityType, entityId: string) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/configurations', { params: { path: { app_id: entityId } } }); + case 'components': + return client.GET('/components/{component_id}/configurations', { + params: { path: { component_id: entityId } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/configurations', { params: { path: { area_id: entityId } } }); + case 'functions': + return client.GET('/functions/{function_id}/configurations', { + params: { path: { function_id: entityId } }, + }); + } +} + +export function getEntityConfiguration( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + configId: string +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/configurations/{config_id}', { + params: { path: { app_id: entityId, config_id: configId } }, + }); + case 'components': + return client.GET('/components/{component_id}/configurations/{config_id}', { + params: { path: { component_id: entityId, config_id: configId } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/configurations/{config_id}', { + params: { path: { area_id: entityId, config_id: configId } }, + }); + case 'functions': + return client.GET('/functions/{function_id}/configurations/{config_id}', { + params: { path: { function_id: entityId, config_id: configId } }, + }); + } +} + +export function putEntityConfiguration( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + configId: string, + body: { value: unknown } +) { + switch (entityType) { + case 'apps': + return client.PUT('/apps/{app_id}/configurations/{config_id}', { + params: { path: { app_id: entityId, config_id: configId } }, + body, + }); + case 'components': + return client.PUT('/components/{component_id}/configurations/{config_id}', { + params: { path: { component_id: entityId, config_id: configId } }, + body, + }); + case 'areas': + return client.PUT('/areas/{area_id}/configurations/{config_id}', { + params: { path: { area_id: entityId, config_id: configId } }, + body, + }); + case 'functions': + return client.PUT('/functions/{function_id}/configurations/{config_id}', { + params: { path: { function_id: entityId, config_id: configId } }, + body, + }); + } +} + +export function deleteEntityConfiguration( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + configId: string +) { + switch (entityType) { + case 'apps': + return client.DELETE('/apps/{app_id}/configurations/{config_id}', { + params: { path: { app_id: entityId, config_id: configId } }, + }); + case 'components': + return client.DELETE('/components/{component_id}/configurations/{config_id}', { + params: { path: { component_id: entityId, config_id: configId } }, + }); + case 'areas': + return client.DELETE('/areas/{area_id}/configurations/{config_id}', { + params: { path: { area_id: entityId, config_id: configId } }, + }); + case 'functions': + return client.DELETE('/functions/{function_id}/configurations/{config_id}', { + params: { path: { function_id: entityId, config_id: configId } }, + }); + } +} + +export function deleteEntityConfigurations(client: MedkitClient, entityType: SovdResourceEntityType, entityId: string) { + switch (entityType) { + case 'apps': + return client.DELETE('/apps/{app_id}/configurations', { params: { path: { app_id: entityId } } }); + case 'components': + return client.DELETE('/components/{component_id}/configurations', { + params: { path: { component_id: entityId } }, + }); + case 'areas': + return client.DELETE('/areas/{area_id}/configurations', { params: { path: { area_id: entityId } } }); + case 'functions': + return client.DELETE('/functions/{function_id}/configurations', { + params: { path: { function_id: entityId } }, + }); + } +} + +// ============================================================================= +// Data +// ============================================================================= + +export function getEntityData(client: MedkitClient, entityType: SovdResourceEntityType, entityId: string) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/data', { params: { path: { app_id: entityId } } }); + case 'components': + return client.GET('/components/{component_id}/data', { params: { path: { component_id: entityId } } }); + case 'areas': + return client.GET('/areas/{area_id}/data', { params: { path: { area_id: entityId } } }); + case 'functions': + return client.GET('/functions/{function_id}/data', { params: { path: { function_id: entityId } } }); + } +} + +export function getEntityDataItem( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + dataId: string +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/data/{data_id}', { + params: { path: { app_id: entityId, data_id: dataId } }, + }); + case 'components': + return client.GET('/components/{component_id}/data/{data_id}', { + params: { path: { component_id: entityId, data_id: dataId } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/data/{data_id}', { + params: { path: { area_id: entityId, data_id: dataId } }, + }); + case 'functions': + return client.GET('/functions/{function_id}/data/{data_id}', { + params: { path: { function_id: entityId, data_id: dataId } }, + }); + } +} + +export function putEntityDataItem( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + dataId: string, + body: { value: unknown } +) { + switch (entityType) { + case 'apps': + return client.PUT('/apps/{app_id}/data/{data_id}', { + params: { path: { app_id: entityId, data_id: dataId } }, + body, + }); + case 'components': + return client.PUT('/components/{component_id}/data/{data_id}', { + params: { path: { component_id: entityId, data_id: dataId } }, + body, + }); + case 'areas': + return client.PUT('/areas/{area_id}/data/{data_id}', { + params: { path: { area_id: entityId, data_id: dataId } }, + body, + }); + case 'functions': + return client.PUT('/functions/{function_id}/data/{data_id}', { + params: { path: { function_id: entityId, data_id: dataId } }, + body, + }); + } +} + +// ============================================================================= +// Operations +// ============================================================================= + +export function getEntityOperations(client: MedkitClient, entityType: SovdResourceEntityType, entityId: string) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/operations', { params: { path: { app_id: entityId } } }); + case 'components': + return client.GET('/components/{component_id}/operations', { + params: { path: { component_id: entityId } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/operations', { params: { path: { area_id: entityId } } }); + case 'functions': + return client.GET('/functions/{function_id}/operations', { + params: { path: { function_id: entityId } }, + }); + } +} + +// ============================================================================= +// Executions +// ============================================================================= + +export function postEntityExecution( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + operationId: string, + body: { input?: unknown } +) { + switch (entityType) { + case 'apps': + return client.POST('/apps/{app_id}/operations/{operation_id}/executions', { + params: { path: { app_id: entityId, operation_id: operationId } }, + body, + }); + case 'components': + return client.POST('/components/{component_id}/operations/{operation_id}/executions', { + params: { path: { component_id: entityId, operation_id: operationId } }, + body, + }); + case 'areas': + return client.POST('/areas/{area_id}/operations/{operation_id}/executions', { + params: { path: { area_id: entityId, operation_id: operationId } }, + body, + }); + case 'functions': + return client.POST('/functions/{function_id}/operations/{operation_id}/executions', { + params: { path: { function_id: entityId, operation_id: operationId } }, + body, + }); + } +} + +export function getEntityExecution( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + operationId: string, + executionId: string +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/operations/{operation_id}/executions/{execution_id}', { + params: { path: { app_id: entityId, operation_id: operationId, execution_id: executionId } }, + }); + case 'components': + return client.GET('/components/{component_id}/operations/{operation_id}/executions/{execution_id}', { + params: { + path: { component_id: entityId, operation_id: operationId, execution_id: executionId }, + }, + }); + case 'areas': + return client.GET('/areas/{area_id}/operations/{operation_id}/executions/{execution_id}', { + params: { path: { area_id: entityId, operation_id: operationId, execution_id: executionId } }, + }); + case 'functions': + return client.GET('/functions/{function_id}/operations/{operation_id}/executions/{execution_id}', { + params: { + path: { function_id: entityId, operation_id: operationId, execution_id: executionId }, + }, + }); + } +} + +export function deleteEntityExecution( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + operationId: string, + executionId: string +) { + switch (entityType) { + case 'apps': + return client.DELETE('/apps/{app_id}/operations/{operation_id}/executions/{execution_id}', { + params: { path: { app_id: entityId, operation_id: operationId, execution_id: executionId } }, + }); + case 'components': + return client.DELETE('/components/{component_id}/operations/{operation_id}/executions/{execution_id}', { + params: { + path: { component_id: entityId, operation_id: operationId, execution_id: executionId }, + }, + }); + case 'areas': + return client.DELETE('/areas/{area_id}/operations/{operation_id}/executions/{execution_id}', { + params: { path: { area_id: entityId, operation_id: operationId, execution_id: executionId } }, + }); + case 'functions': + return client.DELETE('/functions/{function_id}/operations/{operation_id}/executions/{execution_id}', { + params: { + path: { function_id: entityId, operation_id: operationId, execution_id: executionId }, + }, + }); + } +} + +// ============================================================================= +// Faults +// ============================================================================= + +export function getEntityFaults(client: MedkitClient, entityType: SovdResourceEntityType, entityId: string) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/faults', { params: { path: { app_id: entityId } } }); + case 'components': + return client.GET('/components/{component_id}/faults', { + params: { path: { component_id: entityId } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/faults', { params: { path: { area_id: entityId } } }); + case 'functions': + return client.GET('/functions/{function_id}/faults', { + params: { path: { function_id: entityId } }, + }); + } +} + +export function getEntityFaultDetail( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + faultCode: string +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/faults/{fault_code}', { + params: { path: { app_id: entityId, fault_code: faultCode } }, + }); + case 'components': + return client.GET('/components/{component_id}/faults/{fault_code}', { + params: { path: { component_id: entityId, fault_code: faultCode } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/faults/{fault_code}', { + params: { path: { area_id: entityId, fault_code: faultCode } }, + }); + case 'functions': + return client.GET('/functions/{function_id}/faults/{fault_code}', { + params: { path: { function_id: entityId, fault_code: faultCode } }, + }); + } +} + +export function deleteEntityFault( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + faultCode: string +) { + switch (entityType) { + case 'apps': + return client.DELETE('/apps/{app_id}/faults/{fault_code}', { + params: { path: { app_id: entityId, fault_code: faultCode } }, + }); + case 'components': + return client.DELETE('/components/{component_id}/faults/{fault_code}', { + params: { path: { component_id: entityId, fault_code: faultCode } }, + }); + case 'areas': + return client.DELETE('/areas/{area_id}/faults/{fault_code}', { + params: { path: { area_id: entityId, fault_code: faultCode } }, + }); + case 'functions': + return client.DELETE('/functions/{function_id}/faults/{fault_code}', { + params: { path: { function_id: entityId, fault_code: faultCode } }, + }); + } +} + +export function deleteEntityFaults(client: MedkitClient, entityType: SovdResourceEntityType, entityId: string) { + switch (entityType) { + case 'apps': + return client.DELETE('/apps/{app_id}/faults', { params: { path: { app_id: entityId } } }); + case 'components': + return client.DELETE('/components/{component_id}/faults', { + params: { path: { component_id: entityId } }, + }); + case 'areas': + return client.DELETE('/areas/{area_id}/faults', { params: { path: { area_id: entityId } } }); + case 'functions': + return client.DELETE('/functions/{function_id}/faults', { + params: { path: { function_id: entityId } }, + }); + } +} + +// ============================================================================= +// Bulk Data +// ============================================================================= + +export function getEntityBulkDataCategories( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/bulk-data', { params: { path: { app_id: entityId } } }); + case 'components': + return client.GET('/components/{component_id}/bulk-data', { + params: { path: { component_id: entityId } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/bulk-data', { params: { path: { area_id: entityId } } }); + case 'functions': + return client.GET('/functions/{function_id}/bulk-data', { + params: { path: { function_id: entityId } }, + }); + } +} + +export function getEntityBulkData( + client: MedkitClient, + entityType: SovdResourceEntityType, + entityId: string, + categoryId: string +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/bulk-data/{category_id}', { + params: { path: { app_id: entityId, category_id: categoryId } }, + }); + case 'components': + return client.GET('/components/{component_id}/bulk-data/{category_id}', { + params: { path: { component_id: entityId, category_id: categoryId } }, + }); + case 'areas': + return client.GET('/areas/{area_id}/bulk-data/{category_id}', { + params: { path: { area_id: entityId, category_id: categoryId } }, + }); + case 'functions': + return client.GET('/functions/{function_id}/bulk-data/{category_id}', { + params: { path: { function_id: entityId, category_id: categoryId } }, + }); + } +} diff --git a/src/lib/sovd-api.test.ts b/src/lib/sovd-api.test.ts deleted file mode 100644 index 3bf9077..0000000 --- a/src/lib/sovd-api.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SovdApiClient, formatBytes, formatDuration } from './sovd-api'; - -describe('SovdApiClient', () => { - let client: SovdApiClient; - - beforeEach(() => { - client = new SovdApiClient('http://localhost:8080', 'api/v1'); - vi.stubGlobal('fetch', vi.fn()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - describe('getFaultWithEnvironmentData', () => { - it('returns FaultResponse with environment_data', async () => { - const mockResponse = { - item: { - code: 'TEST_FAULT', - fault_name: 'Test', - severity: 2, - status: { aggregatedStatus: 'active', testFailed: '1', confirmedDTC: '1', pendingDTC: '0' }, - }, - environment_data: { - extended_data_records: { - first_occurrence: '2026-02-04T10:00:00Z', - last_occurrence: '2026-02-04T10:05:00Z', - }, - snapshots: [], - }, - }; - - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - } as Response); - - const result = await client.getFaultWithEnvironmentData('apps', 'motor', 'TEST_FAULT'); - - expect(fetch).toHaveBeenCalledWith( - 'http://localhost:8080/api/v1/apps/motor/faults/TEST_FAULT', - expect.objectContaining({ method: 'GET' }) - ); - expect(result.item.code).toBe('TEST_FAULT'); - expect(result.environment_data).toBeDefined(); - }); - - it('throws on failure', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - json: () => Promise.resolve({ message: 'Fault not found' }), - } as Response); - - await expect(client.getFaultWithEnvironmentData('apps', 'motor', 'UNKNOWN')).rejects.toThrow( - 'Fault not found' - ); - }); - }); - - describe('listAllFaults', () => { - const makeFaultItem = (overrides: Record = {}) => ({ - fault_code: 'TEST_FAULT', - description: 'A test fault', - severity: 2, - severity_label: 'error', - status: 'CONFIRMED', - first_occurred: 1700000000, - reporting_sources: ['/test/node'], - ...overrides, - }); - - it('passes status query parameter when provided', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: [] }), - } as Response); - - await client.listAllFaults('all'); - - expect(fetch).toHaveBeenCalledWith( - 'http://localhost:8080/api/v1/faults?status=all', - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('omits status parameter when not provided', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: [] }), - } as Response); - - await client.listAllFaults(); - - expect(fetch).toHaveBeenCalledWith( - 'http://localhost:8080/api/v1/faults', - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('maps HEALED API status to healed', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: [makeFaultItem({ status: 'HEALED' })] }), - } as Response); - - const result = await client.listAllFaults('all'); - expect(result.items[0]?.status).toBe('healed'); - }); - - it('maps PREPASSED API status to healed', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: [makeFaultItem({ status: 'PREPASSED' })] }), - } as Response); - - const result = await client.listAllFaults('all'); - expect(result.items[0]?.status).toBe('healed'); - }); - - it('maps PREFAILED API status to pending', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: [makeFaultItem({ status: 'PREFAILED' })] }), - } as Response); - - const result = await client.listAllFaults('all'); - expect(result.items[0]?.status).toBe('pending'); - }); - - it('maps CONFIRMED API status to active', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: [makeFaultItem({ status: 'CONFIRMED' })] }), - } as Response); - - const result = await client.listAllFaults(); - expect(result.items[0]?.status).toBe('active'); - }); - - it('maps CLEARED API status to cleared', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: [makeFaultItem({ status: 'CLEARED' })] }), - } as Response); - - const result = await client.listAllFaults(); - expect(result.items[0]?.status).toBe('cleared'); - }); - }); - - describe('listBulkDataCategories', () => { - it('returns categories array', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: ['rosbags'] }), - } as Response); - - const result = await client.listBulkDataCategories('apps', 'motor'); - - expect(fetch).toHaveBeenCalledWith( - 'http://localhost:8080/api/v1/apps/motor/bulk-data', - expect.objectContaining({ method: 'GET' }) - ); - expect(result.items).toContain('rosbags'); - }); - - it('returns empty array on 404', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - } as Response); - - const result = await client.listBulkDataCategories('apps', 'motor'); - expect(result.items).toHaveLength(0); - }); - }); - - describe('listBulkData', () => { - it('returns BulkDataDescriptor array', async () => { - const mockDescriptor = { - id: 'uuid-123', - name: 'FAULT recording', - mimetype: 'application/x-mcap', - size: 12345, - creation_date: '2026-02-04T10:00:00Z', - }; - - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ items: [mockDescriptor] }), - } as Response); - - const result = await client.listBulkData('apps', 'motor', 'rosbags'); - - expect(fetch).toHaveBeenCalledWith( - 'http://localhost:8080/api/v1/apps/motor/bulk-data/rosbags', - expect.objectContaining({ method: 'GET' }) - ); - expect(result.items[0]?.id).toBe('uuid-123'); - }); - }); - - describe('getBulkDataUrl', () => { - it('builds correct URL from absolute bulk_data_uri', () => { - const url = client.getBulkDataUrl('/apps/motor_controller/bulk-data/rosbags/550e8400-uuid'); - - expect(url).toBe('http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/550e8400-uuid'); - }); - - it('handles nested entity paths', () => { - const url = client.getBulkDataUrl('/areas/perception/subareas/lidar/bulk-data/rosbags/uuid'); - - expect(url).toBe('http://localhost:8080/api/v1/areas/perception/subareas/lidar/bulk-data/rosbags/uuid'); - }); - }); - - describe('downloadBulkData', () => { - it('downloads blob and extracts filename', async () => { - const mockBlob = new Blob(['test data'], { type: 'application/x-mcap' }); - - vi.mocked(fetch).mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(mockBlob), - headers: new Headers({ - 'Content-Disposition': 'attachment; filename="MOTOR_OVERHEAT.mcap"', - }), - } as Response); - - const result = await client.downloadBulkData('apps', 'motor', 'rosbags', 'uuid-123'); - - expect(result.blob).toBe(mockBlob); - expect(result.filename).toBe('MOTOR_OVERHEAT.mcap'); - }); - - it('uses default filename if header missing', async () => { - const mockBlob = new Blob(['test data']); - - vi.mocked(fetch).mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(mockBlob), - headers: new Headers({}), - } as Response); - - const result = await client.downloadBulkData('apps', 'motor', 'rosbags', 'my-uuid'); - - expect(result.filename).toBe('my-uuid.mcap'); - }); - - it('throws on failure', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - } as Response); - - await expect(client.downloadBulkData('apps', 'motor', 'rosbags', 'uuid')).rejects.toThrow('HTTP 404'); - }); - }); -}); - -describe('Utility Functions', () => { - describe('formatBytes', () => { - it('formats bytes correctly', () => { - expect(formatBytes(0)).toBe('0 B'); - expect(formatBytes(500)).toBe('500 B'); - expect(formatBytes(1024)).toBe('1 KB'); - expect(formatBytes(1536)).toBe('1.5 KB'); - expect(formatBytes(1048576)).toBe('1 MB'); - expect(formatBytes(1234567)).toBe('1.2 MB'); - }); - }); - - describe('formatDuration', () => { - it('formats seconds correctly', () => { - expect(formatDuration(5)).toBe('5.0s'); - expect(formatDuration(30.5)).toBe('30.5s'); - expect(formatDuration(60)).toBe('1m 0s'); - expect(formatDuration(90)).toBe('1m 30s'); - expect(formatDuration(125)).toBe('2m 5s'); - }); - }); -}); diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts deleted file mode 100644 index db880fe..0000000 --- a/src/lib/sovd-api.ts +++ /dev/null @@ -1,2091 +0,0 @@ -import type { - SovdEntity, - SovdEntityDetails, - ComponentTopic, - ComponentTopicPublishRequest, - ComponentTopicsInfo, - ComponentConfigurations, - ConfigurationDetail, - SetConfigurationRequest, - SetConfigurationResponse, - ResetConfigurationResponse, - ResetAllConfigurationsResponse, - Operation, - OperationKind, - DataItemResponse, - Parameter, - // New SOVD-compliant types - Execution, - ExecutionStatus, - CreateExecutionRequest, - CreateExecutionResponse, - ListExecutionsResponse, - App, - AppCapabilities, - SovdFunction, - FunctionCapabilities, - Fault, - FaultSeverity, - FaultStatus, - ListFaultsResponse, - ListSnapshotsResponse, - ServerCapabilities, - VersionInfo, - SovdError, - SovdResourceEntityType, - // SOVD Bulk Data and Environment Data types - FaultResponse, - BulkDataCategory, - BulkDataList, -} from './types'; -import { convertJsonSchemaToTopicSchema } from './schema-utils'; - -// Re-export SovdResourceEntityType for convenience -export type { SovdResourceEntityType }; - -/** Resource collection types available on entities */ -export type ResourceCollectionType = 'data' | 'operations' | 'configurations' | 'faults'; - -/** Map of resource types to their list result types */ -export interface ResourceListResults { - data: ComponentTopic[]; - operations: Operation[]; - configurations: ComponentConfigurations; - faults: ListFaultsResponse; -} - -/** - * 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. - */ -async function fetchWithTimeout(url: string, options: RequestInit = {}, timeout = 10000): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(url, { - ...options, - signal: controller.signal, - }); - clearTimeout(timeoutId); - return response; - } catch (error) { - clearTimeout(timeoutId); - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('Request timeout - server did not respond in time'); - } - throw error; - } -} - -/** - * Normalize URL to ensure it has a protocol - * Accepts: "ip:port", "http://ip:port", "https://domain" - */ -function normalizeUrl(url: string): string { - let normalized = url.trim(); - - // Remove trailing slash - if (normalized.endsWith('/')) { - normalized = normalized.slice(0, -1); - } - - // Add http:// if no protocol specified - if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) { - normalized = `http://${normalized}`; - } - - return normalized; -} - -/** - * Normalize base endpoint path - * Accepts: "api/v1", "/api/v1", "/api/v1/", "api/v1/" - * Returns: "api/v1" (no leading or trailing slashes) - */ -function normalizeBasePath(path: string): string { - let normalized = path.trim(); - - // Remove leading slashes - while (normalized.startsWith('/')) { - normalized = normalized.slice(1); - } - - // Remove trailing slashes - while (normalized.endsWith('/')) { - normalized = normalized.slice(0, -1); - } - - return normalized; -} - -/** - * Strip direction suffix (:publish, :subscribe, :both) from topic name - * The frontend adds these suffixes to uniqueKey for UI purposes, - * but the API expects the original topic ID without the suffix. - */ -function stripDirectionSuffix(topicName: string): string { - return topicName.replace(/:(publish|subscribe|both)$/, ''); -} - -/** - * SOVD API Client for discovery endpoints - */ -export class SovdApiClient { - private baseUrl: string; - private baseEndpoint: string; - - constructor(serverUrl: string, baseEndpoint: string = '') { - this.baseUrl = normalizeUrl(serverUrl); - // Normalize base endpoint using helper function - this.baseEndpoint = normalizeBasePath(baseEndpoint); - } - - /** - * Helper to construct full URL - */ - private getUrl(endpoint: string): string { - const prefix = this.baseEndpoint ? `${this.baseEndpoint}/` : ''; - return `${this.baseUrl}/${prefix}${endpoint}`; - } - - /** - * Test connection to the SOVD server - */ - async ping(): Promise { - try { - const response = await fetchWithTimeout( - this.getUrl('health'), - { - method: 'GET', - headers: { - Accept: 'application/json', - }, - }, - 3000 - ); // 3 second timeout for ping - return response.ok; - } catch { - return false; - } - } - - // =========================================================================== - // GENERIC RESOURCE API (unified entry point for all resource collections) - // =========================================================================== - - /** - * Generic method to fetch any resource collection for any entity type. - * This is the single entry point for all resource operations to ensure consistency. - * - * @param entityType The type of entity (areas, components, apps, functions) - * @param entityId The entity identifier - * @param resourceType The resource collection type (data, operations, configurations, faults) - * @returns The resource collection with appropriate type - */ - async getResources( - entityType: SovdResourceEntityType, - entityId: string, - resourceType: T - ): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/${resourceType}`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - // Return empty collection for 404 - return this.emptyResourceResult(resourceType); - } - throw new Error(`HTTP ${response.status}`); - } - - const rawData = await response.json(); - return this.transformResourceData(resourceType, rawData, entityId); - } - - /** - * Get empty result for a resource type (used for 404 responses) - */ - private emptyResourceResult(resourceType: T): ResourceListResults[T] { - const results: ResourceListResults = { - data: [], - operations: [], - configurations: { component_id: '', node_name: '', parameters: [] }, - faults: { items: [], count: 0 }, - }; - return results[resourceType] as ResourceListResults[T]; - } - - /** - * Transform raw API response to typed resource collection - */ - private transformResourceData( - resourceType: T, - rawData: unknown, - entityId: string - ): ResourceListResults[T] { - switch (resourceType) { - case 'data': - return this.transformDataResponse(rawData) as ResourceListResults[T]; - case 'operations': - return this.transformOperationsResponse(rawData) as ResourceListResults[T]; - case 'configurations': - return this.transformConfigurationsResponse(rawData, entityId) as ResourceListResults[T]; - case 'faults': - return this.transformFaultsResponse(rawData) as ResourceListResults[T]; - default: - throw new Error(`Unknown resource type: ${resourceType}`); - } - } - - /** - * Transform operations API response to Operation[] - * Extracts kind, type, and type_info from x-medkit extension - */ - private transformOperationsResponse(rawData: unknown): Operation[] { - interface RawOperation { - id: string; - name: string; - asynchronous_execution?: boolean; - 'x-medkit'?: { - entity_id?: string; - ros2?: { - kind?: 'service' | 'action'; - service?: string; - action?: string; - type?: string; - }; - type_info?: { - request?: unknown; - response?: unknown; - goal?: unknown; - result?: unknown; - feedback?: unknown; - }; - }; - } - const rawOps = unwrapItems(rawData); - return rawOps.map((op) => { - const xMedkit = op['x-medkit']; - const ros2Info = xMedkit?.ros2; - const rawTypeInfo = xMedkit?.type_info; - - // Determine kind from x-medkit.ros2.kind or from asynchronous_execution - let kind: OperationKind = 'service'; - if (ros2Info?.kind) { - kind = ros2Info.kind; - } else if (op.asynchronous_execution) { - kind = 'action'; - } - - // Build type_info with appropriate schema structure - let typeInfo: Operation['type_info'] | undefined; - if (rawTypeInfo) { - if (kind === 'service' && (rawTypeInfo.request || rawTypeInfo.response)) { - typeInfo = { - schema: { - request: - (rawTypeInfo.request - ? convertJsonSchemaToTopicSchema(rawTypeInfo.request) - : undefined) ?? {}, - response: - (rawTypeInfo.response - ? convertJsonSchemaToTopicSchema(rawTypeInfo.response) - : undefined) ?? {}, - }, - }; - } else if (kind === 'action' && (rawTypeInfo.goal || rawTypeInfo.result)) { - typeInfo = { - schema: { - goal: - (rawTypeInfo.goal ? convertJsonSchemaToTopicSchema(rawTypeInfo.goal) : undefined) ?? {}, - result: - (rawTypeInfo.result ? convertJsonSchemaToTopicSchema(rawTypeInfo.result) : undefined) ?? - {}, - feedback: - (rawTypeInfo.feedback - ? convertJsonSchemaToTopicSchema(rawTypeInfo.feedback) - : undefined) ?? {}, - }, - }; - } - } - - return { - name: op.name || op.id, - path: ros2Info?.service || ros2Info?.action || `/${op.name}`, - type: ros2Info?.type || '', - kind, - type_info: typeInfo, - }; - }); - } - - /** - * Transform data API response to ComponentTopic[] - */ - private transformDataResponse(rawData: unknown): ComponentTopic[] { - interface DataItem { - id: string; - name: string; - category?: string; - 'x-medkit'?: { - ros2?: { topic?: string; type?: string; direction?: string }; - type_info?: { schema?: unknown; default_value?: unknown }; - }; - } - const dataItems = unwrapItems(rawData); - return dataItems.map((item) => { - const rawTypeInfo = item['x-medkit']?.type_info; - const convertedSchema = rawTypeInfo?.schema - ? convertJsonSchemaToTopicSchema(rawTypeInfo.schema) - : undefined; - const direction = item['x-medkit']?.ros2?.direction; - const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id; - return { - topic: topicName, - timestamp: Date.now() * 1000000, - data: null, - status: 'metadata_only' as const, - type: item['x-medkit']?.ros2?.type, - type_info: convertedSchema - ? { - schema: convertedSchema, - default_value: rawTypeInfo?.default_value as Record, - } - : undefined, - // Direction-based fields for apps/functions - isPublisher: direction === 'publish' || direction === 'both', - isSubscriber: direction === 'subscribe' || direction === 'both', - uniqueKey: direction ? `${topicName}:${direction}` : topicName, - }; - }); - } - - /** - * Transform configurations API response to ComponentConfigurations - */ - private transformConfigurationsResponse(rawData: unknown, entityId: string): ComponentConfigurations { - const data = rawData as { - 'x-medkit'?: { - entity_id?: string; - ros2?: { node?: string }; - parameters?: Parameter[]; - }; - }; - const xMedkit = data['x-medkit'] || {}; - return { - component_id: xMedkit.entity_id || entityId, - node_name: xMedkit.ros2?.node || entityId, - parameters: xMedkit.parameters || [], - }; - } - - /** - * Transform faults API response to ListFaultsResponse - */ - private transformFaultsResponse(rawData: unknown): ListFaultsResponse { - const data = rawData as { - items?: unknown[]; - 'x-medkit'?: { count?: number }; - }; - const items = (data.items || []).map((f: unknown) => - this.transformFault(f as Parameters[0]) - ); - return { items, count: data['x-medkit']?.count || items.length }; - } - - // =========================================================================== - // ENTITY TREE NAVIGATION - // =========================================================================== - - /** - * Get root entities or children of a specific path - * @param path Optional path to get children of (e.g., "/devices/robot1") - */ - async getEntities(path?: string): Promise { - // Root level -> fetch areas - if (!path || path === '/') { - const response = await fetchWithTimeout(this.getUrl('areas'), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) throw new Error(`HTTP ${response.status}`); - 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, - name: area.id, - type: 'area', - href: `/areas/${area.id}`, - hasChildren: true, - })); - } - - // Area level -> fetch components - // Path format: /area_id - const parts = path.replace(/^\//, '').split('/'); - - // Level 1: /area -> fetch components - if (parts.length === 1) { - const areaId = parts[0]!; - const response = await fetchWithTimeout(this.getUrl(`areas/${areaId}/components`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) throw new Error(`HTTP ${response.status}`); - 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) - const hasTopics = - comp.topics && - ((comp.topics.publishes?.length ?? 0) > 0 || (comp.topics.subscribes?.length ?? 0) > 0); - - return { - id: comp.id, - name: comp.fqn || comp.id, - type: 'component', - href: `/${areaId}/${comp.id}`, - hasChildren: hasTopics, - topicsInfo: comp.topics, - }; - }); - } - - // Level 2: /area/component -> fetch full topic data with QoS, publishers, subscribers - // This fetches actual topic samples which include rich metadata - if (parts.length === 2) { - const componentId = parts[1]!; - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) 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 } }; - } - 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: topicName, - type: 'topic', - href: `/${parts[0]}/${parts[1]}/${encodedTopicName}`, - hasChildren: false, - // Store transformed data as ComponentTopic - data: { - topic: topicName, - timestamp: Date.now() * 1000000, - data: null, - status: 'metadata_only' as const, - }, - }; - }); - } - - return []; - } - - /** - * Get detailed information about a specific entity - * @param path Entity path (e.g., "/area/component") - */ - async getEntityDetails(path: string): Promise { - // Path comes from the tree, e.g. "/server/area_id/component_id" or "/area_id/component_id" - let parts = path.split('/').filter((p) => p); - - // Remove 'server' prefix if present (tree paths start with /server) - if (parts[0] === 'server') { - parts = parts.slice(1); - } - - // Handle virtual folder paths with /data/ - // Patterns: - // /area/data/topic → areas/{area}/data/{topic} - // /area/component/data/topic → components/{component}/data/{topic} - // /area/component/apps/app/data/topic → apps/{app}/data/{topic} - // /functions/function/data/topic → functions/{function}/data/{topic} - if (parts.includes('data')) { - const dataIndex = parts.indexOf('data'); - const topicName = parts[dataIndex + 1]; - - if (!topicName) { - throw new Error('Invalid path: missing topic name after /data/'); - } - - const decodedTopicName = decodeURIComponent(topicName); - - // Determine entity type and ID based on path structure - let entityType: SovdResourceEntityType; - let entityId: string; - - const appsIndex = parts.indexOf('apps'); - const functionsIndex = parts.indexOf('functions'); - - if (appsIndex !== -1 && appsIndex < dataIndex) { - // App topic: /area/component/apps/app/data/topic - entityType = 'apps'; - entityId = parts[appsIndex + 1]!; - } else if (functionsIndex !== -1 && functionsIndex < dataIndex) { - // Function topic: /functions/function/data/topic - entityType = 'functions'; - entityId = parts[functionsIndex + 1]!; - } else if (dataIndex === 1) { - // Area topic: /area/data/topic (dataIndex is 1, meaning only area before data) - entityType = 'areas'; - entityId = parts[0]!; - } else { - // Component topic: /area/component/data/topic (dataIndex is 2+) - entityType = 'components'; - entityId = parts[dataIndex - 1]!; - } - - // Use the generic getTopicDetails method - const topic = await this.getTopicDetails(entityType, entityId, decodedTopicName); - - return { - id: topicName, - name: topic.topic, - href: path, - topicData: topic, - rosType: topic.type, - type: 'topic', - }; - } - - // Handle virtual folder paths with /operations/ - // Patterns: - // /area/operations/op → areas/{area}/operations/{op} - // /area/component/operations/op → components/{component}/operations/{op} - // /area/component/apps/app/operations/op → apps/{app}/operations/{op} - // /functions/function/operations/op → functions/{function}/operations/{op} - if (parts.includes('operations')) { - const opsIndex = parts.indexOf('operations'); - const operationName = parts[opsIndex + 1]; - - if (!operationName) { - throw new Error('Invalid path: missing operation name after /operations/'); - } - - const decodedOpName = decodeURIComponent(operationName); - - // Determine entity type and ID based on path structure - let entityType: SovdResourceEntityType; - let entityId: string; - - const appsIndex = parts.indexOf('apps'); - const functionsIndex = parts.indexOf('functions'); - - if (appsIndex !== -1 && appsIndex < opsIndex) { - // App operation: /area/component/apps/app/operations/op - entityType = 'apps'; - entityId = parts[appsIndex + 1]!; - } else if (functionsIndex !== -1 && functionsIndex < opsIndex) { - // Function operation: /functions/function/operations/op - entityType = 'functions'; - entityId = parts[functionsIndex + 1]!; - } else if (opsIndex === 1) { - // Area operation: /area/operations/op (opsIndex is 1, only area before operations) - entityType = 'areas'; - entityId = parts[0]!; - } else { - // Component operation: /area/component/operations/op (opsIndex is 2+) - entityType = 'components'; - entityId = parts[opsIndex - 1]!; - } - - // Fetch the operation details - const operation = await this.getOperation(entityId, decodedOpName, entityType); - - return { - id: operationName, - name: operation.name, - href: path, - type: operation.kind, // 'service' or 'action' - data: operation, - componentId: entityId, - entityType, - }; - } - - // Level 3: /area/component/topic -> fetch topic details (legacy path format) - if (parts.length === 3) { - const componentId = parts[1]!; - const encodedTopicName = parts[2]!; - - // Decode topic name using standard percent-decoding - // e.g., 'powertrain%2Fengine%2Ftemp' -> 'powertrain/engine/temp' - const decodedTopicName = decodeURIComponent(encodedTopicName); - - // Use the generic getTopicDetails method - const topic = await this.getTopicDetails('components', componentId, decodedTopicName); - - return { - id: encodedTopicName, - name: topic.topic, - href: path, - topicData: topic, - rosType: topic.type, - type: 'topic', - }; - } - - if (parts.length === 2) { - const componentId = parts[1]!; - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) throw new Error(`HTTP ${response.status}`); - // Use the proper transformation to convert API format to ComponentTopic[] - const topicsData = this.transformDataResponse(await response.json()); - - // Build topicsInfo from fetched data for navigation - // AND keep full topics array for detailed view (QoS, publishers, etc.) - const topicNames = topicsData.map((t) => t.topic); - return { - id: componentId, - name: componentId, - type: 'component', - href: `/${parts[0]!}/${parts[1]!}`, - // Full topic data with QoS, publishers, subscribers - topics: topicsData, - // Simple lists for navigation - topicsInfo: { - publishes: topicNames, - subscribes: [], - }, - }; - } - - // If it's an area (length 1), maybe return basic info? - // For now return empty object or basic info - return { - id: parts[0] ?? path, - name: parts[0] ?? path, - type: 'area', - href: path, - hasChildren: true, - }; - } - - /** - * Get the base URL of the server - */ - getBaseUrl(): string { - return this.baseUrl; - } - - /** - * Publish to entity data (generic for components, apps, functions, areas) - * @param entityType Entity type (components, apps, functions, areas) - * @param entityId Entity ID - * @param topicName Topic name (full path without leading slash) - * @param request Publish request with type and data - */ - async publishToEntityData( - entityType: SovdResourceEntityType, - entityId: string, - topicName: string, - request: ComponentTopicPublishRequest - ): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/data/${topicName}`), - { - method: 'PUT', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }, - 10000 - ); // 10 second timeout for publish - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - message?: string; - }; - throw new Error(errorData.error || errorData.message || `Server error (HTTP ${response.status})`); - } - } - - /** - * Force a server-side refresh of the entity tree and topic map - * This triggers the backend to rebuild its cache immediately - * @returns Refresh stats (duration_ms, areas_count, components_count) - */ - async refreshTree(): Promise<{ - duration_ms: number; - areas_count: number; - components_count: number; - }> { - const response = await fetchWithTimeout( - this.getUrl('refresh'), - { - method: 'POST', - headers: { - Accept: 'application/json', - }, - }, - 30000 - ); // 30 second timeout for full refresh - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - message?: string; - }; - throw new Error(errorData.error || errorData.message || `Server error (HTTP ${response.status})`); - } - - return await response.json(); - } - - // =========================================================================== - // CONFIGURATIONS API (ROS 2 Parameters) - // =========================================================================== - - /** - * List all configurations (parameters) for an entity - * @param entityId Entity ID - * @param entityType Entity type - */ - async listConfigurations( - entityId: string, - entityType: SovdResourceEntityType = 'components' - ): Promise { - return this.getResources(entityType, entityId, 'configurations'); - } - - /** - * Get a specific configuration (parameter) value and metadata - * @param entityId Entity ID - * @param paramName Parameter name - * @param entityType Entity type - */ - async getConfiguration( - entityId: string, - paramName: string, - entityType: SovdResourceEntityType = 'components' - ): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), - { - method: 'GET', - headers: { Accept: 'application/json' }, - } - ); - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; - }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); - } - - return await response.json(); - } - - /** - * Set a configuration (parameter) value - * @param entityId Entity ID - * @param paramName Parameter name - * @param request Request with new value - * @param entityType Entity type - */ - async setConfiguration( - entityId: string, - paramName: string, - request: SetConfigurationRequest, - entityType: SovdResourceEntityType = 'components' - ): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), - { - method: 'PUT', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - } - ); - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; - }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); - } - - return await response.json(); - } - - /** - * Reset a configuration (parameter) to its default value - * @param entityId Entity ID - * @param paramName Parameter name - * @param entityType Entity type - */ - async resetConfiguration( - entityId: string, - paramName: string, - entityType: SovdResourceEntityType = 'components' - ): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), - { - method: 'DELETE', - headers: { - Accept: 'application/json', - }, - } - ); - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; - }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); - } - - // 204 No Content means success - return the response body or defaults - const data = await response.json().catch(() => ({})); - return data as ResetConfigurationResponse; - } - - /** - * Reset all configurations for an entity to their default values - * @param entityId Entity ID - * @param entityType Entity type - */ - async resetAllConfigurations( - entityId: string, - entityType: SovdResourceEntityType = 'components' - ): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/configurations`), { - method: 'DELETE', - headers: { - Accept: 'application/json', - }, - }); - - // Accept both 200 (full success) and 207 (partial success) - if (!response.ok && response.status !== 207) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; - }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); - } - - return await response.json(); - } - - // =========================================================================== - // OPERATIONS API (ROS 2 Services & Actions) - SOVD Executions Model - // =========================================================================== - - /** - * List all operations (services + actions) for an entity (component or app) - * @param entityType Entity type - * @param entityId Entity ID - */ - async listOperations(entityId: string, entityType: SovdResourceEntityType = 'components'): Promise { - return this.getResources(entityType, entityId, 'operations'); - } - - /** - * Get details of a specific operation - * @param entityId Entity ID - * @param operationName Operation name - * @param entityType Entity type - */ - async getOperation( - entityId: string, - operationName: string, - entityType: SovdResourceEntityType = '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(); - } - - /** - * Create an execution (invoke an operation) - SOVD-compliant - * @param entityId Entity ID (component or app) - * @param operationName Operation name - * @param request Execution request with input data - * @param entityType Entity type - */ - async createExecution( - entityId: string, - operationName: string, - request: CreateExecutionRequest, - entityType: SovdResourceEntityType = 'components' - ): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions`), - { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }, - 30000 - ); - - if (!response.ok) { - 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 - */ - async listExecutions( - entityId: string, - operationName: string, - entityType: SovdResourceEntityType = '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(); - } - - /** - * Normalize execution status from API response. - * Maps SOVD-compliant status values to frontend ExecutionStatus. - * Uses x-medkit.ros2_status when available for actual ROS 2 outcome. - */ - private normalizeExecutionStatus(apiResponse: { - status?: string; - 'x-medkit'?: { - ros2_status?: string; - }; - }): ExecutionStatus { - const apiStatus = apiResponse.status?.toLowerCase() || 'pending'; - const ros2Status = apiResponse['x-medkit']?.ros2_status?.toLowerCase(); - - // SOVD-compliant: use 'completed' directly for terminal success state - if (apiStatus === 'completed') { - // Check ros2_status for more specific outcome if available - if (ros2Status === 'failed' || ros2Status === 'failure' || ros2Status === 'aborted') { - return 'failed'; - } - if (ros2Status === 'canceled' || ros2Status === 'cancelled') { - return 'canceled'; - } - // Return 'completed' (SOVD standard) - includes succeeded, success, etc. - return 'completed'; - } - - // Map other API statuses - if (apiStatus === 'running' || apiStatus === 'executing' || apiStatus === 'in_progress') { - return 'running'; - } - if (apiStatus === 'pending' || apiStatus === 'created' || apiStatus === 'queued') { - return 'pending'; - } - if (apiStatus === 'canceled' || apiStatus === 'cancelled') { - return 'canceled'; - } - if (apiStatus === 'failed' || apiStatus === 'failure' || apiStatus === 'error') { - return 'failed'; - } - if (apiStatus === 'succeeded' || apiStatus === 'success' || apiStatus === 'finished') { - return 'succeeded'; - } - - // Fallback to a neutral state for unknown statuses and log for investigation - console.warn('[SovdApiClient] Unknown execution status encountered', { apiStatus, ros2Status }); - return 'pending'; - } - - /** - * Get execution status by ID - * @param entityId Entity ID - * @param operationName Operation name - * @param executionId Execution ID - * @param entityType Entity type - */ - async getExecution( - entityId: string, - operationName: string, - executionId: string, - entityType: SovdResourceEntityType = '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}`); - } - - const rawData = await response.json(); - - // Normalize status from API response - const normalizedStatus = this.normalizeExecutionStatus(rawData); - - // Extract result from x-medkit if present - const xMedkit = rawData['x-medkit'] as - | { - result?: unknown; - feedback?: unknown; - error?: string; - } - | undefined; - - return { - id: rawData.id || executionId, - status: normalizedStatus, - created_at: rawData.created_at || new Date().toISOString(), - started_at: rawData.started_at, - finished_at: rawData.finished_at, - result: rawData.result ?? xMedkit?.result, - error: rawData.error ?? xMedkit?.error, - last_feedback: rawData.last_feedback ?? xMedkit?.feedback, - }; - } - - /** - * Cancel an execution (for actions) - * @param entityId Entity ID - * @param operationName Operation name - * @param executionId Execution ID - * @param entityType Entity type - */ - async cancelExecution( - entityId: string, - operationName: string, - executionId: string, - entityType: SovdResourceEntityType = '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}`); - } - - const rawData = await response.json(); - - // Normalize status from API response - const normalizedStatus = this.normalizeExecutionStatus(rawData); - - return { - id: rawData.id || executionId, - status: normalizedStatus, - created_at: rawData.created_at || new Date().toISOString(), - result: rawData.result, - error: rawData.error, - }; - } - - // =========================================================================== - // 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, - }; - }); - } - - /** - * 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 - */ - 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' }, - }); - - 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 { - return this.getResources('apps', appId, 'data'); - } - - /** - * 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/${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 apps hosting a function - * @param functionId Function identifier - * @returns Array of host objects (with id, name, href) - */ - 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 { - return this.getResources('functions', functionId, 'data'); - } - - /** - * Get aggregated operations for a function - * @param functionId Function identifier - */ - async getFunctionOperations(functionId: string): Promise { - return this.getResources('functions', functionId, 'operations'); - } - - // =========================================================================== - // GENERIC ENTITY RESOURCES (for aggregated views) - // =========================================================================== - - /** - * Get data items for any entity type (areas, components, apps, functions) - * Returns aggregated data from child entities - * @param entityType Entity type - * @param entityId Entity identifier - */ - async getEntityData(entityType: SovdResourceEntityType, entityId: string): Promise { - return this.getResources(entityType, entityId, 'data'); - } - - // =========================================================================== - // GENERIC TOPIC DETAILS (works for all entity types) - // =========================================================================== - - /** - * Get topic details for any entity type (areas, components, apps, functions) - * @param entityType The type of entity - * @param entityId The entity identifier - * @param topicName The topic name (will strip direction suffix if present) - */ - async getTopicDetails( - entityType: SovdResourceEntityType, - entityId: string, - topicName: string - ): Promise { - // Strip direction suffix (:publish/:subscribe/:both) that frontend adds for unique keys - const cleanTopicName = stripDirectionSuffix(topicName); - const encodedTopicName = encodeURIComponent(cleanTopicName); - - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/data/${encodedTopicName}`), { - 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}, type_info: {schema, default_value}, ...}} - const item = (await response.json()) as DataItemResponse; - const xMedkit = item['x-medkit']; - const ros2 = xMedkit?.ros2; - - const rawTypeInfo = xMedkit?.type_info as { schema?: unknown; default_value?: unknown } | undefined; - const convertedSchema = rawTypeInfo?.schema ? convertJsonSchemaToTopicSchema(rawTypeInfo.schema) : undefined; - - return { - topic: ros2?.topic || cleanTopicName, - timestamp: xMedkit?.timestamp || Date.now() * 1000000, - data: item.data, - status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', - type: ros2?.type, - type_info: convertedSchema - ? { - schema: convertedSchema, - default_value: rawTypeInfo?.default_value as Record, - } - : undefined, - publisher_count: xMedkit?.publisher_count, - subscriber_count: xMedkit?.subscriber_count, - publishers: xMedkit?.publishers, - subscribers: xMedkit?.subscribers, - isPublisher: ros2?.direction === 'publish' || ros2?.direction === 'both', - isSubscriber: ros2?.direction === 'subscribe' || ros2?.direction === 'both', - }; - } - - // =========================================================================== - // 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' || apiStatus === 'prefailed') { - status = 'pending'; - } else if (apiStatus === 'cleared' || apiStatus === 'resolved') { - status = 'cleared'; - } else if (apiStatus === 'healed' || apiStatus === 'prepassed') { - status = 'healed'; - } - - // Extract entity info from reporting_sources - // reporting_sources contains ROS 2 node paths like "/bridge/diagnostic_bridge" - // We need to map this to SOVD entity: node name "diagnostic_bridge" → app ID "diagnostic-bridge" - const source = apiFault.reporting_sources?.[0] || ''; - const nodeName = source.split('/').pop() || 'unknown'; - // Convert underscores to hyphens to match SOVD app ID convention - const entity_id = nodeName.replace(/_/g, '-'); - // Faults are reported by apps - const entity_type = 'app'; - - 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 - * @param status Optional status filter (e.g. 'all' to include healed faults) - */ - async listAllFaults(status?: FaultStatus | 'all'): Promise { - const baseUrl = this.getUrl('faults'); - const url = status ? `${baseUrl}?status=${encodeURIComponent(status)}` : baseUrl; - const response = await fetchWithTimeout(url, { - 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 - * @param entityId Entity identifier - */ - async listEntityFaults(entityType: SovdResourceEntityType, entityId: string): Promise { - return this.getResources(entityType, entityId, 'faults'); - } - - /** - * Get a specific fault by code - * @param entityType Entity type - * @param entityId Entity identifier - * @param faultCode Fault code - */ - async getFault(entityType: SovdResourceEntityType, entityId: string, faultCode: string): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), - { - 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(); - } - - /** - * Clear a specific fault - * @param entityType Entity type - * @param entityId Entity identifier - * @param faultCode Fault code - */ - async clearFault(entityType: SovdResourceEntityType, entityId: string, faultCode: string): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), - { - 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}`); - } - } - - /** - * Clear all faults for an entity - * @param entityType Entity type - * @param entityId Entity identifier - */ - async clearAllFaults(entityType: SovdResourceEntityType, 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 - * @deprecated Use getFaultWithEnvironmentData() instead - snapshots are now inline in fault response - * @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(); - } - - /** - * Get fault snapshots for a specific entity - * @deprecated Use getFaultWithEnvironmentData() instead - snapshots are now inline in fault response - * @param entityType Entity type - * @param entityId Entity identifier - * @param faultCode Fault code - */ - async getEntityFaultSnapshots( - entityType: SovdResourceEntityType, - entityId: string, - faultCode: string - ): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/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(); - } - - // =========================================================================== - // SOVD FAULT WITH ENVIRONMENT DATA (new SOVD-compliant endpoint) - // =========================================================================== - - /** - * Get fault details with environment data (SOVD-compliant response) - * Returns FaultResponse with item, environment_data (snapshots), and x-medkit extensions - * @param entityType Entity type (areas, components, apps, functions) - * @param entityId Entity identifier - * @param faultCode Fault code - */ - async getFaultWithEnvironmentData( - entityType: SovdResourceEntityType, - entityId: string, - faultCode: string - ): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), - { - 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(); - } - - // =========================================================================== - // BULK DATA ENDPOINTS (SOVD-compliant binary data downloads) - // =========================================================================== - - /** - * List bulk-data categories for an entity - * @param entityType Entity type (areas, components, apps, functions) - * @param entityId Entity identifier - * @returns BulkDataCategory with items array (e.g., ['rosbags']) - */ - async listBulkDataCategories(entityType: SovdResourceEntityType, entityId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/bulk-data`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - return { items: [] }; - } - throw new Error(`HTTP ${response.status}`); - } - - return await response.json(); - } - - /** - * List bulk-data items in a category - * @param entityType Entity type - * @param entityId Entity identifier - * @param category Category name (e.g., 'rosbags') - * @returns BulkDataList with descriptor items - */ - async listBulkData(entityType: SovdResourceEntityType, entityId: string, category: string): Promise { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/bulk-data/${encodeURIComponent(category)}`), - { - method: 'GET', - headers: { Accept: 'application/json' }, - } - ); - - if (!response.ok) { - if (response.status === 404) { - return { items: [] }; - } - throw new Error(`HTTP ${response.status}`); - } - - return await response.json(); - } - - /** - * Get download URL for bulk-data item - * Use this URL directly in anchor href or fetch - * @param bulkDataUri Absolute URI from fault response (e.g., '/apps/motor/bulk-data/rosbags/uuid') - * @returns Full URL for download - */ - getBulkDataUrl(bulkDataUri: string): string { - // bulkDataUri is an absolute path like "/apps/motor/bulk-data/rosbags/FAULT_CODE" - // Strip leading slash to make it a relative endpoint for getUrl() - return this.getUrl(bulkDataUri.replace(/^\//, '')); - } - - /** - * Download bulk-data file as Blob - * @param entityType Entity type - * @param entityId Entity identifier - * @param category Category name (e.g., 'rosbags') - * @param id Bulk data item ID (UUID) - * @returns Blob and filename from Content-Disposition header - */ - async downloadBulkData( - entityType: SovdResourceEntityType, - entityId: string, - category: string, - id: string - ): Promise<{ blob: Blob; filename: string }> { - const response = await fetchWithTimeout( - this.getUrl(`${entityType}/${entityId}/bulk-data/${encodeURIComponent(category)}/${id}`), - { - method: 'GET', - }, - 300000 // 5 minute timeout for large file downloads - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const blob = await response.blob(); - - // Extract filename from Content-Disposition header - const disposition = response.headers.get('Content-Disposition'); - let filename = `${id}.mcap`; // Default - - if (disposition) { - // Try RFC 5987 encoded filename first (filename*=UTF-8''encoded) - const utf8Match = disposition.match(/filename\*=(?:UTF-8|utf-8)''(.+?)(?:;|$)/); - if (utf8Match && utf8Match[1]) { - filename = decodeURIComponent(utf8Match[1]); - } else { - // Fall back to standard filename="..." or filename=... - const match = disposition.match(/filename="?([^";]+)"?/); - if (match && match[1]) { - filename = match[1].trim(); - } - } - } - - return { blob, filename }; - } - - /** - * 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( - onFaultConfirmed: (fault: Fault) => void, - onFaultCleared: (fault: Fault) => void, - onError?: (error: Error) => void - ): () => void { - const eventSource = new EventSource(this.getUrl('faults/stream')); - - const parseFault = (event: MessageEvent): Fault | null => { - try { - // API returns: { event_type, fault, timestamp } - const rawData = JSON.parse(event.data); - const faultData = rawData.fault || rawData; - if ('fault_code' in faultData) { - return this.transformFault(faultData); - } - return faultData as Fault; - } catch { - onError?.(new Error('Failed to parse fault event')); - return null; - } - }; - - eventSource.addEventListener('fault_confirmed', (event: MessageEvent) => { - const fault = parseFault(event); - if (fault) onFaultConfirmed(fault); - }); - - eventSource.addEventListener('fault_cleared', (event: MessageEvent) => { - const fault = parseFault(event); - if (fault) onFaultCleared(fault); - }); - - // Fallback for unnamed events - treat as confirmed - eventSource.onmessage = (event: MessageEvent) => { - const fault = parseFault(event); - if (fault) onFaultConfirmed(fault); - }; - - 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(); - } - - // =========================================================================== - // HIERARCHY API (Subareas & Subcomponents) - // =========================================================================== - - /** - * List subareas for an area - * @param areaId Area identifier - */ - async listSubareas(areaId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`areas/${areaId}/subareas`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - return []; - } - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); - const items = Array.isArray(data) ? data : (data.items ?? data.subareas ?? []); - return items.map((item: { id: string; name?: string }) => ({ - id: item.id, - name: item.name || item.id, - type: 'subarea', - href: `/areas/${areaId}/subareas/${item.id}`, - hasChildren: true, - })); - } - - /** - * List subcomponents for a component - * @param componentId Component identifier - */ - async listSubcomponents(componentId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/subcomponents`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - return []; - } - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); - const items = Array.isArray(data) ? data : (data.items ?? data.subcomponents ?? []); - return items.map((item: { id: string; name?: string; fqn?: string }) => ({ - id: item.id, - name: item.fqn || item.name || item.id, - type: 'subcomponent', - href: `/components/${componentId}/subcomponents/${item.id}`, - hasChildren: true, - })); - } -} - -/** - * Create a new SOVD API client instance - */ -export function createSovdClient(serverUrl: string, baseEndpoint: string = ''): SovdApiClient { - return new SovdApiClient(serverUrl, baseEndpoint); -} - -// =========================================================================== -// UTILITY FUNCTIONS -// =========================================================================== - -/** - * Map fault entity_type (may be singular or plural) to SovdResourceEntityType (always plural). - * Shared utility used by FaultsDashboard and FaultsPanel. - */ -export function mapFaultEntityTypeToResourceType(entityType: string): SovdResourceEntityType { - const type = entityType.toLowerCase(); - if (type === 'area' || type === 'areas') return 'areas'; - if (type === 'app' || type === 'apps') return 'apps'; - if (type === 'function' || type === 'functions') return 'functions'; - if (type === 'component' || type === 'components') return 'components'; - - console.warn( - '[mapFaultEntityTypeToResourceType] Unexpected entity_type:', - entityType, - '- defaulting to "components".' - ); - return 'components'; -} - -/** - * Format bytes as human-readable string - * @param bytes Number of bytes - * @returns Formatted string (e.g., '1.5 MB') - */ -export function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); - - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; -} - -/** - * Format duration in seconds as human-readable string - * @param seconds Duration in seconds - * @returns Formatted string (e.g., '1m 30s') - */ -export function formatDuration(seconds: number): string { - if (seconds < 60) { - return `${seconds.toFixed(1)}s`; - } - - const mins = Math.floor(seconds / 60); - const secs = Math.round(seconds % 60); - return `${mins}m ${secs}s`; -} diff --git a/src/lib/store-helpers.test.ts b/src/lib/store-helpers.test.ts new file mode 100644 index 0000000..0995fdf --- /dev/null +++ b/src/lib/store-helpers.test.ts @@ -0,0 +1,410 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'vitest'; +import { toTreeNode, updateNodeInTree, findNode, inferEntityTypeFromDepth, parseTreePath } from './store'; +import type { SovdEntity, EntityTreeNode } from './types'; + +// ============================================================================= +// Helper factories +// ============================================================================= + +function makeEntity(overrides: Partial = {}): SovdEntity { + return { + id: 'test-entity', + name: 'Test Entity', + type: 'component', + href: '/api/v1/components/test-entity', + ...overrides, + }; +} + +function makeTreeNode(overrides: Partial = {}): EntityTreeNode { + return { + id: 'node-a', + name: 'Node A', + type: 'component', + href: '/api/v1/components/node-a', + path: '/node-a', + children: undefined, + isLoading: false, + isExpanded: false, + hasChildren: true, + ...overrides, + }; +} + +// ============================================================================= +// toTreeNode +// ============================================================================= + +describe('toTreeNode', () => { + it('creates a tree node with correct path from entity id', () => { + const entity = makeEntity({ id: 'engine' }); + const node = toTreeNode(entity); + expect(node.path).toBe('/engine'); + }); + + it('prepends parentPath to the node path', () => { + const entity = makeEntity({ id: 'engine' }); + const node = toTreeNode(entity, '/powertrain'); + expect(node.path).toBe('/powertrain/engine'); + }); + + it('sets children to undefined for lazy loading', () => { + const entity = makeEntity(); + const node = toTreeNode(entity); + expect(node.children).toBeUndefined(); + }); + + it('sets isLoading to false', () => { + const entity = makeEntity(); + const node = toTreeNode(entity); + expect(node.isLoading).toBe(false); + }); + + it('sets isExpanded to false', () => { + const entity = makeEntity(); + const node = toTreeNode(entity); + expect(node.isExpanded).toBe(false); + }); + + it('sets hasChildren to false for app type', () => { + const entity = makeEntity({ type: 'app' }); + const node = toTreeNode(entity); + expect(node.hasChildren).toBe(false); + }); + + it('sets hasChildren to true for component type', () => { + const entity = makeEntity({ type: 'component' }); + const node = toTreeNode(entity); + expect(node.hasChildren).toBe(true); + }); + + it('sets hasChildren to true for area type', () => { + const entity = makeEntity({ type: 'area' }); + const node = toTreeNode(entity); + expect(node.hasChildren).toBe(true); + }); + + it('uses explicit hasChildren metadata when provided', () => { + const entity = { ...makeEntity({ type: 'component' }), hasChildren: false } as SovdEntity; + const node = toTreeNode(entity); + expect(node.hasChildren).toBe(false); + }); + + it('uses explicit hasChildren=true metadata even for app type', () => { + const entity = { ...makeEntity({ type: 'app' }), hasChildren: true } as SovdEntity; + const node = toTreeNode(entity); + expect(node.hasChildren).toBe(true); + }); + + it('detects non-empty children array as hasChildren=true', () => { + const entity = { ...makeEntity({ type: 'app' }), children: [{ id: 'child' }] } as unknown as SovdEntity; + const node = toTreeNode(entity); + expect(node.hasChildren).toBe(true); + }); + + it('detects empty children array as hasChildren=false', () => { + const entity = { ...makeEntity({ type: 'component' }), children: [] } as unknown as SovdEntity; + const node = toTreeNode(entity); + expect(node.hasChildren).toBe(false); + }); + + it('preserves entity properties via spread', () => { + const entity = makeEntity({ id: 'sensor', name: 'Sensor', href: '/sensors/sensor' }); + const node = toTreeNode(entity); + expect(node.id).toBe('sensor'); + expect(node.name).toBe('Sensor'); + expect(node.href).toBe('/sensors/sensor'); + }); + + it('handles empty parentPath as root level', () => { + const entity = makeEntity({ id: 'root-item' }); + const node = toTreeNode(entity, ''); + expect(node.path).toBe('/root-item'); + }); + + it('is case-insensitive for type check (App vs app)', () => { + const entity = makeEntity({ type: 'App' }); + const node = toTreeNode(entity); + expect(node.hasChildren).toBe(false); + }); +}); + +// ============================================================================= +// updateNodeInTree +// ============================================================================= + +describe('updateNodeInTree', () => { + it('updates a top-level node matching the target path', () => { + const nodes = [makeTreeNode({ path: '/a' }), makeTreeNode({ path: '/b' })]; + const result = updateNodeInTree(nodes, '/a', (n) => ({ ...n, isExpanded: true })); + expect(result[0]?.isExpanded).toBe(true); + expect(result[1]?.isExpanded).toBe(false); + }); + + it('updates a nested node matching the target path', () => { + const child = makeTreeNode({ id: 'child', path: '/parent/child' }); + const parent = makeTreeNode({ + id: 'parent', + path: '/parent', + children: [child], + }); + const result = updateNodeInTree([parent], '/parent/child', (n) => ({ + ...n, + isLoading: true, + })); + expect(result[0]?.children?.[0]?.isLoading).toBe(true); + }); + + it('updates a deeply nested node', () => { + const grandchild = makeTreeNode({ id: 'gc', path: '/a/b/gc' }); + const child = makeTreeNode({ id: 'b', path: '/a/b', children: [grandchild] }); + const root = makeTreeNode({ id: 'a', path: '/a', children: [child] }); + + const result = updateNodeInTree([root], '/a/b/gc', (n) => ({ + ...n, + name: 'Updated', + })); + expect(result[0]?.children?.[0]?.children?.[0]?.name).toBe('Updated'); + }); + + it('returns nodes unchanged when target path does not exist', () => { + const nodes = [makeTreeNode({ path: '/a' })]; + const result = updateNodeInTree(nodes, '/nonexistent', (n) => ({ + ...n, + isExpanded: true, + })); + expect(result[0]?.isExpanded).toBe(false); + }); + + it('does not modify the original nodes array', () => { + const nodes = [makeTreeNode({ path: '/a' })]; + const result = updateNodeInTree(nodes, '/a', (n) => ({ ...n, isExpanded: true })); + expect(nodes[0]?.isExpanded).toBe(false); + expect(result[0]?.isExpanded).toBe(true); + }); + + it('handles an empty nodes array', () => { + const result = updateNodeInTree([], '/any', (n) => ({ ...n, isExpanded: true })); + expect(result).toEqual([]); + }); + + it('does not traverse into children when node.children is undefined', () => { + const node = makeTreeNode({ path: '/a', children: undefined }); + const result = updateNodeInTree([node], '/a/b', (n) => ({ ...n, isExpanded: true })); + // Should not crash; node with undefined children is returned as-is + expect(result[0]?.children).toBeUndefined(); + }); + + it('only follows path prefix matches for children traversal', () => { + const unrelated = makeTreeNode({ id: 'x', path: '/x', children: [makeTreeNode({ id: 'y', path: '/x/y' })] }); + const target = makeTreeNode({ id: 'a', path: '/a' }); + const result = updateNodeInTree([unrelated, target], '/a', (n) => ({ + ...n, + name: 'Hit', + })); + expect(result[0]?.name).toBe('Node A'); // unrelated unchanged + expect(result[1]?.name).toBe('Hit'); + }); +}); + +// ============================================================================= +// findNode +// ============================================================================= + +describe('findNode', () => { + it('finds a top-level node by path', () => { + const nodes = [makeTreeNode({ path: '/a', id: 'a' }), makeTreeNode({ path: '/b', id: 'b' })]; + const found = findNode(nodes, '/a'); + expect(found?.id).toBe('a'); + }); + + it('finds a nested node by path', () => { + const child = makeTreeNode({ id: 'child', path: '/parent/child' }); + const parent = makeTreeNode({ id: 'parent', path: '/parent', children: [child] }); + const found = findNode([parent], '/parent/child'); + expect(found?.id).toBe('child'); + }); + + it('finds a deeply nested node', () => { + const gc = makeTreeNode({ id: 'gc', path: '/a/b/gc' }); + const child = makeTreeNode({ id: 'b', path: '/a/b', children: [gc] }); + const root = makeTreeNode({ id: 'a', path: '/a', children: [child] }); + const found = findNode([root], '/a/b/gc'); + expect(found?.id).toBe('gc'); + }); + + it('returns null when node does not exist', () => { + const nodes = [makeTreeNode({ path: '/a' })]; + expect(findNode(nodes, '/nonexistent')).toBeNull(); + }); + + it('returns null for an empty nodes array', () => { + expect(findNode([], '/any')).toBeNull(); + }); + + it('does not search into undefined children', () => { + const node = makeTreeNode({ path: '/a', children: undefined }); + expect(findNode([node], '/a/b')).toBeNull(); + }); + + it('returns the first match if paths are duplicated', () => { + const first = makeTreeNode({ path: '/dup', id: 'first' }); + const second = makeTreeNode({ path: '/dup', id: 'second' }); + const found = findNode([first, second], '/dup'); + expect(found?.id).toBe('first'); + }); + + it('finds parent node, not just leaf', () => { + const child = makeTreeNode({ id: 'child', path: '/parent/child' }); + const parent = makeTreeNode({ id: 'parent', path: '/parent', children: [child] }); + const found = findNode([parent], '/parent'); + expect(found?.id).toBe('parent'); + }); +}); + +// ============================================================================= +// inferEntityTypeFromDepth +// ============================================================================= + +describe('inferEntityTypeFromDepth', () => { + it('returns "areas" for depth 0', () => { + expect(inferEntityTypeFromDepth(0)).toBe('areas'); + }); + + it('returns "areas" for depth 1', () => { + expect(inferEntityTypeFromDepth(1)).toBe('areas'); + }); + + it('returns "components" for depth 2', () => { + expect(inferEntityTypeFromDepth(2)).toBe('components'); + }); + + it('returns "apps" for depth 3', () => { + expect(inferEntityTypeFromDepth(3)).toBe('apps'); + }); + + it('returns "apps" for depth greater than 3', () => { + expect(inferEntityTypeFromDepth(5)).toBe('apps'); + expect(inferEntityTypeFromDepth(10)).toBe('apps'); + }); + + it('returns "areas" for negative depth', () => { + expect(inferEntityTypeFromDepth(-1)).toBe('areas'); + }); +}); + +// ============================================================================= +// parseTreePath +// ============================================================================= + +describe('parseTreePath', () => { + it('parses a simple area path', () => { + const result = parseTreePath('/server/powertrain'); + expect(result.entityType).toBe('areas'); + expect(result.entityId).toBe('powertrain'); + expect(result.resource).toBeUndefined(); + expect(result.resourceId).toBeUndefined(); + }); + + it('parses a component path (depth 2)', () => { + const result = parseTreePath('/server/powertrain/engine'); + expect(result.entityType).toBe('components'); + expect(result.entityId).toBe('engine'); + }); + + it('parses an app path (depth 3)', () => { + const result = parseTreePath('/server/powertrain/engine/controller'); + expect(result.entityType).toBe('apps'); + expect(result.entityId).toBe('controller'); + }); + + it('parses a data resource path', () => { + const result = parseTreePath('/server/powertrain/engine/data/temperature'); + expect(result.entityType).toBe('components'); + expect(result.entityId).toBe('engine'); + expect(result.resource).toBe('data'); + expect(result.resourceId).toBe('temperature'); + }); + + it('parses an operations resource path', () => { + const result = parseTreePath('/server/powertrain/engine/operations/calibrate'); + expect(result.entityType).toBe('components'); + expect(result.entityId).toBe('engine'); + expect(result.resource).toBe('operations'); + expect(result.resourceId).toBe('calibrate'); + }); + + it('parses a configurations resource path', () => { + const result = parseTreePath('/server/powertrain/engine/configurations/max_rpm'); + expect(result.entityType).toBe('components'); + expect(result.entityId).toBe('engine'); + expect(result.resource).toBe('configurations'); + expect(result.resourceId).toBe('max_rpm'); + }); + + it('parses a faults resource path', () => { + const result = parseTreePath('/server/powertrain/engine/faults/OVERHEAT'); + expect(result.entityType).toBe('components'); + expect(result.entityId).toBe('engine'); + expect(result.resource).toBe('faults'); + expect(result.resourceId).toBe('OVERHEAT'); + }); + + it('parses a resource collection without specific resource id', () => { + const result = parseTreePath('/server/powertrain/engine/data'); + expect(result.entityType).toBe('components'); + expect(result.entityId).toBe('engine'); + expect(result.resource).toBe('data'); + expect(result.resourceId).toBeUndefined(); + }); + + it('parses app-level data resource path (depth 3)', () => { + const result = parseTreePath('/server/powertrain/engine/controller/data/sensor_reading'); + expect(result.entityType).toBe('apps'); + expect(result.entityId).toBe('controller'); + expect(result.resource).toBe('data'); + expect(result.resourceId).toBe('sensor_reading'); + }); + + it('strips /server prefix before parsing', () => { + const result = parseTreePath('/server/my-area'); + expect(result.entityId).toBe('my-area'); + expect(result.entityType).toBe('areas'); + }); + + it('handles path without /server prefix', () => { + const result = parseTreePath('/powertrain/engine'); + expect(result.entityType).toBe('components'); + expect(result.entityId).toBe('engine'); + }); + + it('decodes URL-encoded resource IDs', () => { + const result = parseTreePath('/server/area/comp/data/%2Ftopic%2Fname'); + expect(result.resourceId).toBe('/topic/name'); + }); + + it('returns empty entityId for empty path', () => { + const result = parseTreePath(''); + expect(result.entityId).toBe(''); + }); + + it('returns areas type for single-segment path after /server', () => { + const result = parseTreePath('/server/chassis'); + expect(result.entityType).toBe('areas'); + expect(result.entityId).toBe('chassis'); + }); +}); diff --git a/src/lib/store.ts b/src/lib/store.ts index fcc21c5..1dff9fc 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -17,7 +17,34 @@ import type { VersionInfo, SovdFunction, } from './types'; -import { createSovdClient, type SovdApiClient, type SovdResourceEntityType } from './sovd-api'; +import { createMedkitClient, normalizeBaseUrl, type MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; +import type { SovdResourceEntityType } from './types'; +import { + transformFaultsResponse, + transformOperationsResponse, + transformDataResponse, + transformConfigurationsResponse, + transformFault, + unwrapItems, +} from './transforms'; +import { + getEntityDetail, + getEntityConfigurations, + getEntityOperations, + getEntityData, + getEntityDataItem, + getEntityFaults, + getEntityFaultDetail, + getEntityExecution, + postEntityExecution, + deleteEntityExecution, + deleteEntityFault, + putEntityConfiguration, + putEntityDataItem, + deleteEntityConfiguration, + deleteEntityConfigurations, + getEntityBulkData, +} from './api-dispatch'; const STORAGE_KEY = 'ros2_medkit_web_ui_server_url'; const EXECUTION_POLL_INTERVAL_MS = 1000; @@ -42,11 +69,10 @@ export interface TrackedExecution extends Execution { export interface AppState { // Connection state serverUrl: string | null; - baseEndpoint: string; isConnected: boolean; isConnecting: boolean; connectionError: string | null; - client: SovdApiClient | null; + client: MedkitClient | null; // Entity tree state treeViewMode: TreeViewMode; @@ -79,7 +105,7 @@ export interface AppState { faultStreamCleanup: (() => void) | null; // Actions - connect: (url: string, baseEndpoint?: string) => Promise; + connect: (url: string) => Promise; disconnect: () => void; setTreeViewMode: (mode: TreeViewMode) => Promise; loadRootEntities: () => Promise; @@ -132,6 +158,38 @@ export interface AppState { clearFault: (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => Promise; subscribeFaultStream: () => void; unsubscribeFaultStream: () => void; + + // Component-facing actions (replace direct client usage in components) + fetchEntityData: (entityType: SovdResourceEntityType, entityId: string) => Promise; + fetchEntityOperations: (entityType: SovdResourceEntityType, entityId: string) => Promise; + listEntityFaults: ( + entityType: SovdResourceEntityType, + entityId: string + ) => Promise<{ items: Fault[]; count: number }>; + getFaultWithEnvironmentData: ( + entityType: SovdResourceEntityType, + entityId: string, + faultCode: string + ) => Promise; + publishToEntityData: ( + entityType: SovdResourceEntityType, + entityId: string, + dataId: string, + request: { value: unknown } + ) => Promise; + getServerCapabilities: () => Promise; + getVersionInfoAction: () => Promise; + downloadBulkData: ( + entityType: SovdResourceEntityType, + entityId: string, + category: string, + fileId: string + ) => Promise<{ blob: Blob; filename: string } | null>; + getFunctionHosts: (functionId: string) => Promise; + prefetchResourceCounts: ( + entityType: SovdResourceEntityType, + entityId: string + ) => Promise<{ data: number; operations: number; configurations: number; faults: number }>; } /** @@ -147,7 +205,7 @@ export interface AppState { * Resources (data, operations, configurations, faults) are shown in the detail panel, * not as tree nodes. */ -function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode { +export function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode { const path = parentPath ? `${parentPath}/${entity.id}` : `/${entity.id}`; const entityType = entity.type.toLowerCase(); @@ -182,7 +240,7 @@ function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode /** * Recursively update a node in the tree */ -function updateNodeInTree( +export function updateNodeInTree( nodes: EntityTreeNode[], targetPath: string, updater: (node: EntityTreeNode) => EntityTreeNode @@ -204,7 +262,7 @@ function updateNodeInTree( /** * Find a node in the tree by path */ -function findNode(nodes: EntityTreeNode[], path: string): EntityTreeNode | null { +export function findNode(nodes: EntityTreeNode[], path: string): EntityTreeNode | null { for (const node of nodes) { if (node.path === path) { return node; @@ -242,7 +300,7 @@ interface SelectionContext { * Handle topic node selection * Distinguished between TopicNodeData (partial) and ComponentTopic (full) */ -async function handleTopicSelection(ctx: SelectionContext, client: SovdApiClient): Promise { +async function handleTopicSelection(ctx: SelectionContext, client: MedkitClient): Promise { const { node, path, rootEntities } = ctx; if (node.type !== 'topic' || !node.data) return null; @@ -250,21 +308,54 @@ async function handleTopicSelection(ctx: SelectionContext, client: SovdApiClient const isTopicNodeData = 'isPublisher' in data && 'isSubscriber' in data && !('type' in data); if (isTopicNodeData) { - // TopicNodeData - need to fetch full details + // TopicNodeData - need to fetch full topic details from the parent entity const { isPublisher, isSubscriber } = data as TopicNodeData; - const apiPath = path.replace(/^\/server/, ''); - const details = await client.getEntityDetails(apiPath); + const topicName = node.id; + + // Find parent entity by walking up the tree path + const parentPath = path.split('/').slice(0, -1).join('/'); + const parentNode = findNode(rootEntities, parentPath); + const parentType = parentNode?.type || 'component'; + const entityType = `${parentType}s` as SovdResourceEntityType; + const entityId = parentNode?.id || ''; + + // Fetch the specific data item and transform to ComponentTopic + const { data: topicDetail } = await getEntityDataItem(client, entityType, entityId, topicName); + const transformed = topicDetail ? transformDataResponse({ items: [topicDetail] }) : []; + const topicData = transformed[0] || null; + + if (topicData) { + // Update tree with full data merged with direction info + const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ + ...n, + data: { ...topicData, isPublisher, isSubscriber }, + })); - // Update tree with full data merged with direction info - const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ - ...n, - data: { ...details.topicData, isPublisher, isSubscriber }, - })); + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: node.name, + href: node.href, + topicData: { ...topicData, isPublisher, isSubscriber }, + rosType: topicData.type, + type: 'topic', + }, + rootEntities: updatedTree, + isLoadingDetails: false, + }; + } + // Fallback if topic fetch fails return { selectedPath: path, - selectedEntity: details, - rootEntities: updatedTree, + selectedEntity: { + id: node.id, + name: node.name, + type: 'topic', + href: node.href, + error: 'Failed to load topic details', + }, isLoadingDetails: false, }; } @@ -462,29 +553,124 @@ function handleOperationSelection(ctx: SelectionContext): SelectionResult | null }; } +/** + * Infer entity type from tree path depth. + * Tree paths: /server/ (depth 1), /server// (depth 2), + * /server/// (depth 3) + */ +export function inferEntityTypeFromDepth(depth: number): SovdResourceEntityType { + if (depth <= 1) return 'areas'; + if (depth === 2) return 'components'; + return 'apps'; +} + +/** + * Parse a tree path to find the parent entity and any resource segment. + * Tree paths: /server////data/ + * Returns: { entityType, entityId, resource?, resourceId? } + */ +export function parseTreePath(path: string): { + entityType: SovdResourceEntityType; + entityId: string; + resource?: 'data' | 'operations' | 'configurations' | 'faults'; + resourceId?: string; +} { + const apiPath = path.replace(/^\/server/, ''); + const segments = apiPath.split('/').filter(Boolean); + + // Check for resource segments: .../data/, .../operations/, etc. + const resourceTypes = ['data', 'operations', 'configurations', 'faults'] as const; + for (const res of resourceTypes) { + const resIndex = segments.indexOf(res); + if (resIndex > 0) { + // Entity is the segment before the resource + const entityId = segments[resIndex - 1] || ''; + const entityType = inferEntityTypeFromDepth(resIndex); + const resourceId = segments[resIndex + 1] ? decodeURIComponent(segments[resIndex + 1]!) : undefined; + return { entityType, entityId, resource: res, resourceId }; + } + } + + // No resource segment - it's an entity path + const entityId = segments[segments.length - 1] || ''; + const entityType = inferEntityTypeFromDepth(segments.length); + return { entityType, entityId }; +} + /** Fallback: fetch entity details from API when not in tree */ async function fetchEntityFromApi( path: string, - client: SovdApiClient, + client: MedkitClient, set: (state: Partial) => void ): Promise { set({ selectedPath: path, isLoadingDetails: true, selectedEntity: null }); try { - const apiPath = path.replace(/^\/server/, ''); - const details = await client.getEntityDetails(apiPath); + const parsed = parseTreePath(path); + + if (parsed.resource === 'data' && parsed.resourceId) { + // Topic detail: fetch specific data item and transform it + const { data: rawItem } = await getEntityDataItem( + client, + parsed.entityType, + parsed.entityId, + parsed.resourceId + ); + // Transform raw API response to ComponentTopic (same as list transform but for single item) + const transformed = rawItem ? transformDataResponse({ items: [rawItem] }) : []; + const topicData = transformed[0] || null; + set({ + selectedEntity: { + id: parsed.resourceId, + name: topicData?.topic || parsed.resourceId, + href: path, + topicData: topicData || undefined, + rosType: topicData?.type, + type: 'topic', + }, + isLoadingDetails: false, + }); + return; + } + + if (parsed.resource === 'operations' && parsed.resourceId) { + // Operation detail + set({ + selectedEntity: { + id: parsed.resourceId, + name: parsed.resourceId, + type: 'service', + href: path, + componentId: parsed.entityId, + entityType: parsed.entityType, + }, + isLoadingDetails: false, + }); + return; + } + + // Entity detail + const { data } = await getEntityDetail(client, parsed.entityType, parsed.entityId); + const details = (data || { + id: parsed.entityId, + name: parsed.entityId, + type: parsed.entityType.slice(0, -1), + href: path, + }) as SovdEntityDetails; set({ selectedEntity: details, isLoadingDetails: false }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to load entity details for ${path}: ${message}`); - - // Infer entity type from path structure - const segments = path.split('/').filter(Boolean); - const id = segments[segments.length - 1] || path; - const inferredType = segments.length === 1 ? 'area' : segments.length === 2 ? 'component' : 'unknown'; + console.error('[fetchEntityFromApi] Error:', message, { path }); + const parsed = parseTreePath(path); set({ - selectedEntity: { id, name: id, type: inferredType, href: path, error: 'Failed to load details' }, + selectedEntity: { + id: parsed.entityId, + name: parsed.entityId, + type: parsed.entityType.slice(0, -1), + href: path, + error: 'Failed to load details', + }, isLoadingDetails: false, }); } @@ -495,7 +681,6 @@ export const useAppStore = create()( (set, get) => ({ // Initial state serverUrl: null, - baseEndpoint: '', isConnected: false, isConnecting: false, connectionError: null, @@ -530,15 +715,23 @@ export const useAppStore = create()( isLoadingFaults: false, faultStreamCleanup: null, - // Connect to SOVD server - connect: async (url: string, baseEndpoint: string = '') => { + // Connect to ros2_medkit gateway + connect: async (url: string) => { set({ isConnecting: true, connectionError: null }); try { - const client = createSovdClient(url, baseEndpoint); - const isOk = await client.ping(); + const client = createMedkitClient({ baseUrl: url, fetch: fetch.bind(globalThis) }); + + // Health check with 5s timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + const { error: healthError } = await client + .GET('/health', { + signal: controller.signal, + }) + .finally(() => clearTimeout(timeoutId)); - if (!isOk) { + if (healthError) { set({ isConnecting: false, connectionError: 'Unable to connect to server. Check the URL and try again.', @@ -548,7 +741,6 @@ export const useAppStore = create()( set({ serverUrl: url, - baseEndpoint, isConnected: true, isConnecting: false, connectionError: null, @@ -563,7 +755,13 @@ export const useAppStore = create()( return true; } catch (error) { - const message = error instanceof Error ? error.message : 'Connection failed'; + const isTimeout = error instanceof DOMException && error.name === 'AbortError'; + const message = isTimeout + ? 'Connection timed out. Check the URL and ensure the gateway is running.' + : error instanceof Error + ? error.message + : 'Connection failed'; + console.error('[store] connect failed:', error); set({ isConnecting: false, connectionError: message, @@ -582,7 +780,6 @@ export const useAppStore = create()( set({ serverUrl: null, - baseEndpoint: '', isConnected: false, isConnecting: false, connectionError: null, @@ -611,18 +808,21 @@ export const useAppStore = create()( try { // Fetch version info - critical for server identification and feature detection - const versionInfo = await client.getVersionInfo().catch((error: unknown) => { - const message = error instanceof Error ? error.message : 'Unknown error'; - toast.warn( - `Failed to fetch server version info: ${message}. ` + - 'Server will be shown with generic name and version info may be incomplete.' - ); - return null as VersionInfo | null; - }); + const versionInfo = await client + .GET('/version-info') + .then(({ data }) => data ?? null) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.warn( + `Failed to fetch server version info: ${message}. ` + + 'Server will be shown with generic name and version info may be incomplete.' + ); + return null as VersionInfo | null; + }); // Extract server info from version-info response (fallback to generic values if unavailable) const sovdInfo = versionInfo?.items?.[0]; - const serverName = sovdInfo?.vendor_info?.name || 'SOVD Server'; + const serverName = sovdInfo?.vendor_info?.name || 'ros2_medkit Gateway'; const serverVersion = sovdInfo?.vendor_info?.version || ''; const sovdVersion = sovdInfo?.version || ''; @@ -630,7 +830,10 @@ export const useAppStore = create()( if (treeViewMode === 'functional') { // Functional view: Functions -> Apps (hosts) - const functions = await client.listFunctions().catch(() => [] as SovdFunction[]); + const functionsRes = await client.GET('/functions').catch(() => null); + const functions = ( + functionsRes?.data ? unwrapItems(functionsRes.data) : [] + ) as SovdFunction[]; children = functions.map((fn: SovdFunction) => { // Validate function data quality if (!fn.id || (typeof fn.id !== 'string' && typeof fn.id !== 'number')) { @@ -658,8 +861,22 @@ export const useAppStore = create()( }); } else { // Logical view: Areas -> Components -> Apps - const entities = await client.getEntities(); - children = entities.map((e: SovdEntity) => toTreeNode(e, '/server')); + // Areas are optional - if none exist, fall back to components + const areasRes = await client.GET('/areas'); + const rawAreas = areasRes.data ? unwrapItems>(areasRes.data) : []; + + if (rawAreas.length > 0) { + const entities = rawAreas.map((e) => ({ ...e, type: 'area' }) as unknown as SovdEntity); + children = entities.map((e: SovdEntity) => toTreeNode(e, '/server')); + } else { + // No areas - load components directly under server + const compsRes = await client.GET('/components'); + const rawComps = compsRes.data ? unwrapItems>(compsRes.data) : []; + const entities = rawComps.map( + (e) => ({ ...e, type: 'component' }) as unknown as SovdEntity + ); + children = entities.map((e: SovdEntity) => toTreeNode(e, '/server')); + } } // Create server root node @@ -685,6 +902,7 @@ export const useAppStore = create()( set({ rootEntities: [serverNode], expandedPaths: ['/server'] }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to load entities: ${message}`); } }, @@ -706,9 +924,8 @@ export const useAppStore = create()( // These load their direct children const nodeType = node?.type?.toLowerCase() || ''; - // Handle server node - children (areas) are already loaded in loadRootEntities + // Handle server node - children (areas or components) are already loaded in loadRootEntities if (nodeType === 'server') { - // Server children (areas) are pre-loaded, nothing to do return; } @@ -729,45 +946,53 @@ export const useAppStore = create()( try { let loadedEntities: EntityTreeNode[] = []; - // Convert tree path to API path (remove /server prefix) - const apiPath = path.replace(/^\/server/, ''); - if (isAreaOrSubarea) { - // Load both subareas and components for this area - // API returns mixed: components come from getEntities, subareas from listSubareas - const [components, subareas] = await Promise.all([ - client.getEntities(apiPath), - client.listSubareas(node.id).catch(() => []), - ]); - - // Components from getEntities - const componentNodes = components.map((e: SovdEntity) => toTreeNode(e, path)); - // Subareas with type 'subarea' - const subareaNodes = subareas.map((subarea) => - toTreeNode({ ...subarea, type: 'subarea', hasChildren: true }, path) + // Load components for this area + const componentsRes = await client.GET('/areas/{area_id}/components', { + params: { path: { area_id: node.id } }, + }); + const rawComponents = componentsRes.data + ? unwrapItems>(componentsRes.data) + : []; + const components = rawComponents.map( + (e) => ({ ...e, type: 'component' }) as unknown as SovdEntity ); - - loadedEntities = [...subareaNodes, ...componentNodes]; + loadedEntities = components.map((e: SovdEntity) => toTreeNode(e, path)); } else if (isComponentOrSubcomponent) { - // Load both subcomponents and apps for this component - const [apps, subcomponents] = await Promise.all([ - client.listComponentApps(node.id), - client.listSubcomponents(node.id).catch(() => []), + // Load subcomponents and apps (hosts) in parallel + const [subcompsRes, appsRes] = await Promise.all([ + client + .GET('/components/{component_id}/subcomponents', { + params: { path: { component_id: node.id } }, + }) + .catch(() => ({ data: undefined })), + client.GET('/components/{component_id}/hosts', { + params: { path: { component_id: node.id } }, + }), ]); - // Apps - leaf nodes (no children in tree, resources shown in panel) - const appNodes = apps.map((app) => - toTreeNode({ ...app, type: 'app', hasChildren: false }, path) + const rawSubcomps = subcompsRes.data + ? unwrapItems>(subcompsRes.data) + : []; + const subcomponents = rawSubcomps.map( + (e) => ({ ...e, type: 'subcomponent' }) as unknown as SovdEntity ); - // Subcomponents with type 'subcomponent' - const subcompNodes = subcomponents.map((subcomp) => - toTreeNode({ ...subcomp, type: 'subcomponent', hasChildren: true }, path) + const subcompNodes = subcomponents.map((e: SovdEntity) => toTreeNode(e, path)); + + const rawApps = appsRes.data ? unwrapItems>(appsRes.data) : []; + const apps = rawApps.map((e) => ({ ...e, type: 'app' }) as unknown as SovdEntity); + const appNodes = apps.map((app: SovdEntity) => + toTreeNode({ ...app, type: 'app', hasChildren: false }, path) ); + // Subcomponents first, then apps loadedEntities = [...subcompNodes, ...appNodes]; } else if (isFunction) { // Load hosts (apps) for this function - const hosts = await client.getFunctionHosts(node.id).catch(() => []); + const hostsRes = await client + .GET('/functions/{function_id}/hosts', { params: { path: { function_id: node.id } } }) + .catch(() => null); + const hosts = hostsRes?.data ? unwrapItems>(hostsRes.data) : []; // Hosts response contains objects with {id, name, href} loadedEntities = hosts.map((host: unknown) => { @@ -801,6 +1026,7 @@ export const useAppStore = create()( } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; if (!message.includes('not found') && !message.includes('404')) { + console.error('[store]', error); toast.error(`Failed to load children for ${path}: ${message}`); } set({ loadingPaths: get().loadingPaths.filter((p) => p !== path) }); @@ -826,8 +1052,32 @@ export const useAppStore = create()( try { // Convert tree path to API path (remove /server prefix) + // Parse entity type from path to dispatch to correct endpoint const apiPath = path.replace(/^\/server/, ''); - const entities = await client.getEntities(apiPath); + const segments = apiPath.split('/').filter(Boolean); + + // Fallback: try to load children based on path depth + const depth = segments.length; + let entities: SovdEntity[] = []; + if (depth === 0) { + const res = await client.GET('/areas'); + const raw = res.data ? unwrapItems>(res.data) : []; + entities = raw.map((e) => ({ ...e, type: 'area' }) as unknown as SovdEntity); + } else if (depth === 1) { + const areaId = segments[0]!; + const res = await client.GET('/areas/{area_id}/components', { + params: { path: { area_id: areaId } }, + }); + const raw = res.data ? unwrapItems>(res.data) : []; + entities = raw.map((e) => ({ ...e, type: 'component' }) as unknown as SovdEntity); + } else if (depth === 2) { + const componentId = segments[1]!; + const res = await client.GET('/components/{component_id}/hosts', { + params: { path: { component_id: componentId } }, + }); + const raw = res.data ? unwrapItems>(res.data) : []; + entities = raw.map((e) => ({ ...e, type: 'app' }) as unknown as SovdEntity); + } const children = entities.map((e: SovdEntity) => toTreeNode(e, path)); // Update tree with children @@ -844,6 +1094,7 @@ export const useAppStore = create()( }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to load children for ${path}: ${message}`); set({ loadingPaths: get().loadingPaths.filter((p) => p !== path) }); } @@ -911,6 +1162,7 @@ export const useAppStore = create()( } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to load topic details: ${message}`); set({ selectedEntity: { @@ -957,19 +1209,37 @@ export const useAppStore = create()( // Refresh the currently selected entity (re-fetch from server) refreshSelectedEntity: async () => { - const { selectedPath, client } = get(); - if (!selectedPath || !client) { + const { selectedPath, selectedEntity, client } = get(); + if (!selectedPath || !client || !selectedEntity) { return; } set({ isRefreshing: true }); try { - // Convert tree path to API path (remove /server prefix) - const apiPath = selectedPath.replace(/^\/server/, ''); - const details = await client.getEntityDetails(apiPath); - set({ selectedEntity: details, isRefreshing: false }); - } catch { + const typeMap: Record = { + area: 'areas', + component: 'components', + app: 'apps', + function: 'functions', + }; + const entityType = typeMap[selectedEntity.type]; + const entityId = selectedEntity.id; + + if (!entityType) { + // For non-entity nodes (topic, fault, parameter), just clear refreshing + set({ isRefreshing: false }); + return; + } + + const { data } = await getEntityDetail(client, entityType, entityId); + if (data) { + set({ selectedEntity: data as unknown as SovdEntityDetails, isRefreshing: false }); + } else { + set({ isRefreshing: false }); + } + } catch (error) { + console.error('[store] refreshSelectedEntity', error); toast.error('Failed to refresh data'); set({ isRefreshing: false }); } @@ -994,12 +1264,15 @@ export const useAppStore = create()( set({ isLoadingConfigurations: true }); try { - const result = await client.listConfigurations(entityId, entityType); + const { data, error: fetchError } = await getEntityConfigurations(client, entityType, entityId); + if (fetchError) throw new Error(fetchError.message || 'Failed to load configurations'); + const result = transformConfigurationsResponse(data, entityId); const newConfigs = new Map(configurations); newConfigs.set(entityId, result.parameters); set({ configurations: newConfigs, isLoadingConfigurations: false }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to load configurations: ${message}`); set({ isLoadingConfigurations: false }); } @@ -1015,7 +1288,14 @@ export const useAppStore = create()( if (!client) return false; try { - const result = await client.setConfiguration(entityId, paramName, { value }, entityType); + const { data: result, error: setError } = await putEntityConfiguration( + client, + entityType, + entityId, + paramName, + { value } + ); + if (setError) throw new Error(setError.message || 'Failed to set parameter'); // API returns {data: ..., id: ..., x-medkit: {parameter: {...}}} // Success is indicated by presence of x-medkit.parameter (no status field) @@ -1048,6 +1328,7 @@ export const useAppStore = create()( toast.success(`Parameter ${paramName} updated`); return true; } else { + console.error('[store] setParameter: unexpected response', result); toast.error( `Failed to set parameter: ${(result as { error?: string }).error || 'Unknown error'}` ); @@ -1055,6 +1336,7 @@ export const useAppStore = create()( } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to set parameter: ${message}`); return false; } @@ -1069,7 +1351,13 @@ export const useAppStore = create()( if (!client) return false; try { - await client.resetConfiguration(entityId, paramName, entityType); + const { error: resetError } = await deleteEntityConfiguration( + client, + entityType, + entityId, + paramName + ); + if (resetError) throw new Error(resetError.message || 'Failed to reset parameter'); // Refetch configurations to get updated value after reset await fetchConfigurations(entityId, entityType); @@ -1077,6 +1365,7 @@ export const useAppStore = create()( return true; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to reset parameter: ${message}`); return false; } @@ -1087,20 +1376,30 @@ export const useAppStore = create()( if (!client) return { reset_count: 0, failed_count: 0 }; try { - const result = await client.resetAllConfigurations(entityId, entityType); + const { data: result, error: resetError } = await deleteEntityConfigurations( + client, + entityType, + entityId + ); + if (resetError) throw new Error(resetError.message || 'Failed to reset configurations'); + + const resetResult = result as unknown as { reset_count: number; failed_count: number } | undefined; + const resetCount = resetResult?.reset_count ?? 0; + const failedCount = resetResult?.failed_count ?? 0; - if (result.failed_count === 0) { - toast.success(`Reset ${result.reset_count} parameters to defaults`); + if (failedCount === 0) { + toast.success(`Reset ${resetCount} parameters to defaults`); } else { - toast.warning(`Reset ${result.reset_count} parameters, ${result.failed_count} failed`); + toast.warning(`Reset ${resetCount} parameters, ${failedCount} failed`); } // Refresh configurations to get updated values await fetchConfigurations(entityId, entityType); - return { reset_count: result.reset_count, failed_count: result.failed_count }; + return { reset_count: resetCount, failed_count: failedCount }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to reset configurations: ${message}`); return { reset_count: 0, failed_count: 0 }; } @@ -1117,12 +1416,15 @@ export const useAppStore = create()( set({ isLoadingOperations: true }); try { - const result = await client.listOperations(entityId, entityType); + const { data, error: fetchError } = await getEntityOperations(client, entityType, entityId); + if (fetchError) throw new Error(fetchError.message || 'Failed to load operations'); + const result = transformOperationsResponse(data); const newOps = new Map(operations); newOps.set(entityId, result); set({ operations: newOps, isLoadingOperations: false }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to load operations: ${message}`); set({ isLoadingOperations: false }); } @@ -1138,7 +1440,15 @@ export const useAppStore = create()( if (!client) return null; try { - const result = await client.createExecution(entityId, operationName, request, entityType); + const { data, error: execError } = await postEntityExecution( + client, + entityType, + entityId, + operationName, + { input: request.input } + ); + if (execError) throw new Error(execError.message || 'Operation failed'); + const result = (data || {}) as CreateExecutionResponse; // Track all executions with an ID (both running and completed/failed) // Actions always get an ID, services may or may not depending on backend @@ -1166,17 +1476,20 @@ export const useAppStore = create()( if (isRunning) { toast.success(`Action execution ${result.id.slice(0, 8)}... started`); } else if (result.status === 'failed') { + console.error('[store] createExecution: failed', result); toast.error(`Action execution ${result.id.slice(0, 8)}... failed`); } else if (result.status === 'completed' || result.status === 'succeeded') { toast.success(`Action execution ${result.id.slice(0, 8)}... completed`); } } else if (result.error) { + console.error('[store] createExecution: error in result', result); toast.error(`Operation failed: ${result.error}`); } return result; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Operation failed: ${message}`); return null; } @@ -1192,7 +1505,15 @@ export const useAppStore = create()( if (!client) return; try { - const execution = await client.getExecution(entityId, operationName, executionId, entityType); + const { data, error: fetchError } = await getEntityExecution( + client, + entityType, + entityId, + operationName, + executionId + ); + if (fetchError) throw new Error(fetchError.message || 'Failed to get execution'); + const execution = data as unknown as Execution; // Preserve metadata when updating execution const trackedExecution: TrackedExecution = { ...execution, @@ -1211,6 +1532,7 @@ export const useAppStore = create()( executionId, entityType, }); + console.error('[store]', error); toast.error(`Failed to refresh execution status: ${message}`); } }, @@ -1225,7 +1547,15 @@ export const useAppStore = create()( if (!client) return false; try { - const execution = await client.cancelExecution(entityId, operationName, executionId, entityType); + const { data, error: cancelError } = await deleteEntityExecution( + client, + entityType, + entityId, + operationName, + executionId + ); + if (cancelError) throw new Error(cancelError.message || 'Failed to cancel execution'); + const execution = data as unknown as Execution; // Preserve metadata when updating execution const trackedExecution: TrackedExecution = { ...execution, @@ -1240,6 +1570,7 @@ export const useAppStore = create()( return true; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to cancel execution: ${message}`); return false; } @@ -1302,12 +1633,15 @@ export const useAppStore = create()( const results = await Promise.all( runningExecutions.map(async (exec) => { try { - const updated = await currentClient.getExecution( + const { data: execData } = await getEntityExecution( + currentClient, + exec.entityType, exec.entityId, exec.operationName, - exec.id, - exec.entityType + exec.id ); + if (!execData) throw new Error('No data'); + const updated = execData as unknown as Execution; const isTerminal = ['succeeded', 'failed', 'canceled', 'completed'].includes( updated.status ); @@ -1367,10 +1701,14 @@ export const useAppStore = create()( } try { - const result = await client.listAllFaults('all'); + const { data: faultsData, error: faultsError } = await client.GET('/faults', { + params: { query: { status: 'all' } }, + }); + if (faultsError) throw new Error(faultsError.message || 'Failed to load faults'); + const result = transformFaultsResponse(faultsData); // Skip state update if faults haven't changed to avoid unnecessary re-renders. // Compare by serializing fault codes + statuses (cheap and covers all meaningful changes). - const newKey = result.items.map((f) => `${f.code}:${f.status}:${f.severity}`).join('|'); + const newKey = result.items.map((f: Fault) => `${f.code}:${f.status}:${f.severity}`).join('|'); const oldKey = currentFaults.map((f) => `${f.code}:${f.status}:${f.severity}`).join('|'); if (newKey !== oldKey) { set({ faults: result.items, isLoadingFaults: false }); @@ -1379,6 +1717,7 @@ export const useAppStore = create()( } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to load faults: ${message}`); set({ isLoadingFaults: false }); } @@ -1389,13 +1728,15 @@ export const useAppStore = create()( if (!client) return false; try { - await client.clearFault(entityType, entityId, faultCode); + const { error: clearError } = await deleteEntityFault(client, entityType, entityId, faultCode); + if (clearError) throw new Error(clearError.message || 'Failed to clear fault'); toast.success(`Fault ${faultCode} cleared`); // Refresh faults list await fetchFaults(); return true; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[store]', error); toast.error(`Failed to clear fault: ${message}`); return false; } @@ -1410,54 +1751,235 @@ export const useAppStore = create()( faultStreamCleanup(); } - const cleanup = client.subscribeFaultStream( - // onFaultConfirmed - (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) { - // Skip update if fault data hasn't changed (avoids re-render flicker) - const existing = faults[existingIndex]!; + const stream = client.streams.faults(); + let running = true; + + const cleanup = () => { + running = false; + stream.close(); + }; + + const consume = async () => { + try { + for await (const event of stream) { + if (!running) break; + + const rawData = event.data as Record; + const faultPayload = (rawData.fault ?? rawData) as unknown; + if ( - existing.status === fault.status && - existing.severity === fault.severity && - existing.message === fault.message && - existing.timestamp === fault.timestamp + typeof faultPayload !== 'object' || + faultPayload === null || + !('fault_code' in faultPayload) ) { - return; + continue; + } + const faultData = faultPayload as Parameters[0]; + const fault = transformFault(faultData); + + if (event.event === 'fault_cleared') { + // onFaultCleared - no toast, clearFault() already shows one for UI-triggered clears + const { faults } = get(); + const newFaults = faults.filter( + (f) => !(f.code === fault.code && f.entity_id === fault.entity_id) + ); + if (newFaults.length !== faults.length) { + set({ faults: newFaults }); + } + } else { + // fault_confirmed or default message + const { faults } = get(); + const existingIndex = faults.findIndex( + (f) => f.code === fault.code && f.entity_id === fault.entity_id + ); + if (existingIndex >= 0) { + const existing = faults[existingIndex]!; + if ( + existing.status === fault.status && + existing.severity === fault.severity && + existing.message === fault.message && + existing.timestamp === fault.timestamp + ) { + continue; + } + const newFaults = [...faults]; + newFaults[existingIndex] = fault; + set({ faults: newFaults }); + } else { + set({ faults: [...faults, fault] }); + } + toast.warning(`Fault: ${fault.message}`, { autoClose: 5000 }); } - const newFaults = [...faults]; - newFaults[existingIndex] = fault; - set({ faults: newFaults }); - } else { - set({ faults: [...faults, fault] }); } - toast.warning(`Fault: ${fault.message}`, { autoClose: 5000 }); - }, - // onFaultCleared - no toast here, clearFault() already shows one for UI-triggered clears - (fault) => { - const { faults } = get(); - const newFaults = faults.filter( - (f) => !(f.code === fault.code && f.entity_id === fault.entity_id) - ); - // Skip update if no fault was actually removed - if (newFaults.length === faults.length) { - return; + } catch (error) { + console.error('[store] subscribeFaultStream: error in consume loop', error); + if (running) { + const message = error instanceof Error ? error.message : 'Fault stream error'; + toast.error(`Fault stream error: ${message}`); + // Clear stream state so polling fallback can activate + cleanup(); + set({ faultStreamCleanup: null }); } - set({ faults: newFaults }); - }, - // onError - (error) => { - toast.error(`Fault stream error: ${error.message}`); } - ); + }; + consume(); set({ faultStreamCleanup: cleanup }); }, + // ================================================================= + // COMPONENT-FACING ACTIONS (replace direct client usage) + // ================================================================= + + fetchEntityData: async (entityType: SovdResourceEntityType, entityId: string) => { + const { client } = get(); + if (!client) return []; + const { data, error: fetchError } = await getEntityData(client, entityType, entityId); + if (fetchError) return []; + return transformDataResponse(data); + }, + + fetchEntityOperations: async (entityType: SovdResourceEntityType, entityId: string) => { + const { client } = get(); + if (!client) return []; + const { data, error: fetchError } = await getEntityOperations(client, entityType, entityId); + if (fetchError) return []; + return transformOperationsResponse(data); + }, + + listEntityFaults: async (entityType: SovdResourceEntityType, entityId: string) => { + const { client } = get(); + if (!client) return { items: [], count: 0 }; + const { data, error: fetchError } = await getEntityFaults(client, entityType, entityId); + if (fetchError) return { items: [], count: 0 }; + return transformFaultsResponse(data); + }, + + getFaultWithEnvironmentData: async ( + entityType: SovdResourceEntityType, + entityId: string, + faultCode: string + ) => { + const { client } = get(); + if (!client) return null; + // Try entity-scoped fault detail - if 404, fault may not be scoped to this entity + const { data, error: fetchError } = await getEntityFaultDetail(client, entityType, entityId, faultCode); + if (!fetchError) return data; + // Fault not found on this entity - this is expected for faults reported by + // a different entity than the one shown in the UI (e.g., anomaly_detector reports + // about imu_sim). Log at debug level, not error. + console.debug('[store] getFaultWithEnvironmentData: not found on entity, skipping detail', { + entityType, + entityId, + faultCode, + }); + return null; + }, + + publishToEntityData: async ( + entityType: SovdResourceEntityType, + entityId: string, + dataId: string, + request: { value: unknown } + ) => { + const { client } = get(); + if (!client) return; + try { + const { error } = await putEntityDataItem(client, entityType, entityId, dataId, request); + if (error) { + toast.error(`Failed to publish: ${(error as { message?: string }).message || 'Unknown error'}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to publish: ${message}`); + } + }, + + getServerCapabilities: async () => { + const { client } = get(); + if (!client) return null; + const { data } = await client.GET('/'); + return data ?? null; + }, + + getVersionInfoAction: async () => { + const { client } = get(); + if (!client) return null; + const { data } = await client.GET('/version-info'); + return (data as VersionInfo) ?? null; + }, + + downloadBulkData: async ( + entityType: SovdResourceEntityType, + entityId: string, + category: string, + fileId: string + ) => { + const { client, serverUrl } = get(); + if (!client || !serverUrl) return null; + + // Fetch file list to get filename + const { data } = await getEntityBulkData(client, entityType, entityId, category); + if (!data) return null; + const items = (data as unknown as { items?: Array<{ id: string; name?: string }> })?.items || []; + const fileDesc = items.find((item) => item.id === fileId); + const filename = fileDesc?.name || fileId; + + // Download binary via fetch (openapi-fetch doesn't support blob responses) + const baseUrl = normalizeBaseUrl(serverUrl); + const downloadUrl = `${baseUrl}/${entityType}/${encodeURIComponent(entityId)}/bulk-data/${encodeURIComponent(category)}/${encodeURIComponent(fileId)}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 300_000); + try { + const response = await fetch(downloadUrl, { signal: controller.signal }); + clearTimeout(timer); + if (!response.ok) return null; + const blob = await response.blob(); + return { blob, filename }; + } catch { + clearTimeout(timer); + return null; + } + }, + + getFunctionHosts: async (functionId: string) => { + const { client } = get(); + if (!client) return []; + const { data } = await client.GET('/functions/{function_id}/hosts', { + params: { path: { function_id: functionId } }, + }); + return data ? unwrapItems(data) : []; + }, + + prefetchResourceCounts: async (entityType: SovdResourceEntityType, entityId: string) => { + const { client } = get(); + if (!client) return { data: 0, operations: 0, configurations: 0, faults: 0 }; + + // Note: data count is NOT fetched here to avoid a duplicate request. + // The caller (EntityDetailPanel) already fetches entity data via fetchEntityData + // and overrides counts.data with the result length. + const [opsRes, configRes, faultsRes] = await Promise.all([ + getEntityOperations(client, entityType, entityId).catch(() => ({ + data: undefined, + error: undefined, + })), + getEntityConfigurations(client, entityType, entityId).catch(() => ({ + data: undefined, + error: undefined, + })), + getEntityFaults(client, entityType, entityId).catch(() => ({ data: undefined, error: undefined })), + ]); + + return { + data: 0, + operations: opsRes.data ? unwrapItems(opsRes.data).length : 0, + configurations: configRes.data + ? transformConfigurationsResponse(configRes.data, entityId).parameters.length + : 0, + faults: faultsRes.data ? transformFaultsResponse(faultsRes.data).items.length : 0, + }; + }, + unsubscribeFaultStream: () => { const { faultStreamCleanup } = get(); if (faultStreamCleanup) { @@ -1470,7 +1992,7 @@ export const useAppStore = create()( name: STORAGE_KEY, partialize: (state: AppState) => ({ serverUrl: state.serverUrl, - baseEndpoint: state.baseEndpoint, + treeViewMode: state.treeViewMode, }), } ) diff --git a/src/lib/transforms.test.ts b/src/lib/transforms.test.ts new file mode 100644 index 0000000..364fe4f --- /dev/null +++ b/src/lib/transforms.test.ts @@ -0,0 +1,563 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'vitest'; +import { + unwrapItems, + transformFault, + transformFaultsResponse, + transformOperationsResponse, + transformDataResponse, + transformConfigurationsResponse, + transformBulkDataDescriptor, +} from './transforms'; + +// ============================================================================= +// unwrapItems +// ============================================================================= + +describe('unwrapItems', () => { + it('passes through an array directly', () => { + const arr = [1, 2, 3]; + expect(unwrapItems(arr)).toEqual([1, 2, 3]); + }); + + it('unwraps {items: [...]} wrapper object', () => { + const wrapped = { items: ['a', 'b'] }; + expect(unwrapItems(wrapped)).toEqual(['a', 'b']); + }); + + it('returns empty array when items key is missing', () => { + expect(unwrapItems({})).toEqual([]); + }); + + it('returns empty array for null input', () => { + expect(unwrapItems(null)).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + expect(unwrapItems(undefined)).toEqual([]); + }); + + it('returns empty array when items is undefined', () => { + const wrapped = { items: undefined }; + expect(unwrapItems(wrapped)).toEqual([]); + }); +}); + +// ============================================================================= +// transformFault +// ============================================================================= + +describe('transformFault', () => { + const makeFaultInput = (overrides: Record = {}) => ({ + fault_code: 'ENGINE_OVERHEAT', + description: 'Engine temperature exceeded threshold', + severity: 2, + severity_label: 'error', + status: 'CONFIRMED', + first_occurred: 1700000000, + last_occurred: 1700001000, + occurrence_count: 3, + reporting_sources: ['/powertrain/engine_monitor'], + ...overrides, + }); + + it('maps fault_code to code', () => { + const result = transformFault(makeFaultInput()); + expect(result.code).toBe('ENGINE_OVERHEAT'); + }); + + it('maps description to message', () => { + const result = transformFault(makeFaultInput()); + expect(result.message).toBe('Engine temperature exceeded threshold'); + }); + + it('maps first_occurred unix timestamp to ISO 8601 string', () => { + const result = transformFault(makeFaultInput({ first_occurred: 1700000000 })); + expect(result.timestamp).toBe(new Date(1700000000 * 1000).toISOString()); + }); + + it('defaults entity_type to "app" when not in raw data', () => { + const result = transformFault(makeFaultInput()); + expect(result.entity_type).toBe('app'); + }); + + it('uses entity_type from raw data when provided', () => { + const result = transformFault(makeFaultInput({ entity_type: 'component' })); + expect(result.entity_type).toBe('component'); + }); + + it('uses entity_type "area" from raw data', () => { + const result = transformFault(makeFaultInput({ entity_type: 'area' })); + expect(result.entity_type).toBe('area'); + }); + + it('falls back to "app" when entity_type is empty string', () => { + const result = transformFault(makeFaultInput({ entity_type: '' })); + expect(result.entity_type).toBe('app'); + }); + + describe('entity_id extraction from reporting_sources', () => { + it('extracts last segment of node path', () => { + const result = transformFault(makeFaultInput({ reporting_sources: ['/powertrain/engine_monitor'] })); + expect(result.entity_id).toBe('engine-monitor'); + }); + + it('converts underscores to hyphens in entity_id', () => { + const result = transformFault(makeFaultInput({ reporting_sources: ['/ns/my_node_name'] })); + expect(result.entity_id).toBe('my-node-name'); + }); + + it('uses "unknown" when reporting_sources is empty', () => { + const result = transformFault(makeFaultInput({ reporting_sources: [] })); + expect(result.entity_id).toBe('unknown'); + }); + + it('uses "unknown" when reporting_sources is missing', () => { + const result = transformFault(makeFaultInput({ reporting_sources: undefined })); + expect(result.entity_id).toBe('unknown'); + }); + }); + + describe('severity mapping', () => { + it('maps severity_label "critical" to critical', () => { + const result = transformFault(makeFaultInput({ severity: 0, severity_label: 'critical' })); + expect(result.severity).toBe('critical'); + }); + + it('maps severity_label "error" to error', () => { + const result = transformFault(makeFaultInput({ severity: 0, severity_label: 'error' })); + expect(result.severity).toBe('error'); + }); + + it('maps severity_label "warning" to warning', () => { + const result = transformFault(makeFaultInput({ severity: 0, severity_label: 'warning' })); + expect(result.severity).toBe('warning'); + }); + + it('maps severity_label "warn" to warning', () => { + const result = transformFault(makeFaultInput({ severity: 0, severity_label: 'warn' })); + expect(result.severity).toBe('warning'); + }); + + it('maps severity >= 3 to critical regardless of label', () => { + const result = transformFault(makeFaultInput({ severity: 3, severity_label: '' })); + expect(result.severity).toBe('critical'); + }); + + it('maps severity === 2 to error when label is absent', () => { + const result = transformFault(makeFaultInput({ severity: 2, severity_label: '' })); + expect(result.severity).toBe('error'); + }); + + it('maps severity === 1 to warning when label is absent', () => { + const result = transformFault(makeFaultInput({ severity: 1, severity_label: '' })); + expect(result.severity).toBe('warning'); + }); + + it('defaults to info when severity is 0 and label is empty', () => { + const result = transformFault(makeFaultInput({ severity: 0, severity_label: '' })); + expect(result.severity).toBe('info'); + }); + }); + + describe('status mapping', () => { + it('maps CONFIRMED to active', () => { + const result = transformFault(makeFaultInput({ status: 'CONFIRMED' })); + expect(result.status).toBe('active'); + }); + + it('maps ACTIVE to active', () => { + const result = transformFault(makeFaultInput({ status: 'ACTIVE' })); + expect(result.status).toBe('active'); + }); + + it('maps PENDING to pending', () => { + const result = transformFault(makeFaultInput({ status: 'PENDING' })); + expect(result.status).toBe('pending'); + }); + + it('maps PREFAILED to pending', () => { + const result = transformFault(makeFaultInput({ status: 'PREFAILED' })); + expect(result.status).toBe('pending'); + }); + + it('maps CLEARED to cleared', () => { + const result = transformFault(makeFaultInput({ status: 'CLEARED' })); + expect(result.status).toBe('cleared'); + }); + + it('maps RESOLVED to cleared', () => { + const result = transformFault(makeFaultInput({ status: 'RESOLVED' })); + expect(result.status).toBe('cleared'); + }); + + it('maps HEALED to healed', () => { + const result = transformFault(makeFaultInput({ status: 'HEALED' })); + expect(result.status).toBe('healed'); + }); + + it('maps PREPASSED to healed', () => { + const result = transformFault(makeFaultInput({ status: 'PREPASSED' })); + expect(result.status).toBe('healed'); + }); + + it('defaults to active for unknown status', () => { + const result = transformFault(makeFaultInput({ status: 'UNKNOWN_STATUS' })); + expect(result.status).toBe('active'); + }); + }); + + it('includes occurrence metadata in parameters', () => { + const result = transformFault(makeFaultInput({ occurrence_count: 5, last_occurred: 1700002000 })); + expect(result.parameters?.occurrence_count).toBe(5); + expect(result.parameters?.last_occurred).toBe(1700002000); + expect(result.parameters?.reporting_sources).toEqual(['/powertrain/engine_monitor']); + }); +}); + +// ============================================================================= +// transformFaultsResponse +// ============================================================================= + +describe('transformFaultsResponse', () => { + const makeFaultItem = (overrides: Record = {}) => ({ + fault_code: 'TEST_FAULT', + description: 'A test fault', + severity: 2, + severity_label: 'error', + status: 'CONFIRMED', + first_occurred: 1700000000, + reporting_sources: ['/test/node'], + ...overrides, + }); + + it('returns an empty items array for empty response', () => { + const result = transformFaultsResponse({ items: [] }); + expect(result.items).toEqual([]); + expect(result.count).toBe(0); + }); + + it('transforms each fault item', () => { + const result = transformFaultsResponse({ items: [makeFaultItem()] }); + expect(result.items).toHaveLength(1); + expect(result.items[0]?.code).toBe('TEST_FAULT'); + expect(result.items[0]?.message).toBe('A test fault'); + }); + + it('uses x-medkit count when provided', () => { + const result = transformFaultsResponse({ items: [makeFaultItem()], 'x-medkit': { count: 42 } }); + expect(result.count).toBe(42); + }); + + it('falls back to items.length when x-medkit count is absent', () => { + const result = transformFaultsResponse({ items: [makeFaultItem(), makeFaultItem()] }); + expect(result.count).toBe(2); + }); + + it('handles missing items array gracefully', () => { + const result = transformFaultsResponse({}); + expect(result.items).toEqual([]); + expect(result.count).toBe(0); + }); +}); + +// ============================================================================= +// transformOperationsResponse +// ============================================================================= + +describe('transformOperationsResponse', () => { + const makeRawOp = (overrides: Record = {}) => ({ + id: 'calibrate', + name: 'calibrate', + 'x-medkit': { + ros2: { + kind: 'service' as const, + service: '/engine/calibrate', + type: 'std_srvs/srv/Trigger', + }, + }, + ...overrides, + }); + + it('extracts kind from x-medkit.ros2.kind', () => { + const result = transformOperationsResponse({ items: [makeRawOp()] }); + expect(result[0]?.kind).toBe('service'); + }); + + it('uses action kind when x-medkit.ros2.kind is action', () => { + const raw = makeRawOp({ 'x-medkit': { ros2: { kind: 'action', action: '/engine/drive', type: 'pkg/Drive' } } }); + const result = transformOperationsResponse({ items: [raw] }); + expect(result[0]?.kind).toBe('action'); + }); + + it('infers action kind from asynchronous_execution flag when x-medkit kind absent', () => { + const raw = { id: 'op', name: 'op', asynchronous_execution: true }; + const result = transformOperationsResponse({ items: [raw] }); + expect(result[0]?.kind).toBe('action'); + }); + + it('defaults to service kind when no explicit kind info', () => { + const raw = { id: 'op', name: 'op' }; + const result = transformOperationsResponse({ items: [raw] }); + expect(result[0]?.kind).toBe('service'); + }); + + it('extracts path from ros2.service for service ops', () => { + const result = transformOperationsResponse({ items: [makeRawOp()] }); + expect(result[0]?.path).toBe('/engine/calibrate'); + }); + + it('extracts path from ros2.action for action ops', () => { + const raw = makeRawOp({ 'x-medkit': { ros2: { kind: 'action', action: '/engine/drive', type: 'pkg/Drive' } } }); + const result = transformOperationsResponse({ items: [raw] }); + expect(result[0]?.path).toBe('/engine/drive'); + }); + + it('falls back path to /name when ros2 path absent', () => { + const raw = { id: 'op', name: 'my_op' }; + const result = transformOperationsResponse({ items: [raw] }); + expect(result[0]?.path).toBe('/my_op'); + }); + + it('extracts type from x-medkit.ros2.type', () => { + const result = transformOperationsResponse({ items: [makeRawOp()] }); + expect(result[0]?.type).toBe('std_srvs/srv/Trigger'); + }); + + it('returns empty string for type when absent', () => { + const raw = { id: 'op', name: 'op' }; + const result = transformOperationsResponse({ items: [raw] }); + expect(result[0]?.type).toBe(''); + }); + + it('returns empty array for empty response', () => { + const result = transformOperationsResponse({ items: [] }); + expect(result).toEqual([]); + }); + + it('uses name as fallback when id is the only identifier', () => { + const raw = { id: 'my_op', name: 'my_op' }; + const result = transformOperationsResponse({ items: [raw] }); + expect(result[0]?.name).toBe('my_op'); + }); +}); + +// ============================================================================= +// transformDataResponse +// ============================================================================= + +describe('transformDataResponse', () => { + const makeDataItem = (overrides: Record = {}) => ({ + id: '/engine/temperature', + name: '/engine/temperature', + 'x-medkit': { + ros2: { + topic: '/engine/temperature', + type: 'sensor_msgs/msg/Temperature', + direction: 'publish', + }, + }, + ...overrides, + }); + + it('extracts topic name from item name', () => { + const result = transformDataResponse({ items: [makeDataItem()] }); + expect(result[0]?.topic).toBe('/engine/temperature'); + }); + + it('falls back topic name to x-medkit.ros2.topic', () => { + const raw = { id: 'tmp', 'x-medkit': { ros2: { topic: '/fallback/topic', type: 'std_msgs/msg/Float32' } } }; + const result = transformDataResponse({ items: [raw] }); + expect(result[0]?.topic).toBe('/fallback/topic'); + }); + + it('falls back topic name to id when name and x-medkit topic absent', () => { + const raw = { id: 'my_topic_id' }; + const result = transformDataResponse({ items: [raw] }); + expect(result[0]?.topic).toBe('my_topic_id'); + }); + + it('extracts type from x-medkit.ros2.type', () => { + const result = transformDataResponse({ items: [makeDataItem()] }); + expect(result[0]?.type).toBe('sensor_msgs/msg/Temperature'); + }); + + it('sets status to metadata_only', () => { + const result = transformDataResponse({ items: [makeDataItem()] }); + expect(result[0]?.status).toBe('metadata_only'); + }); + + it('maps direction "publish" to isPublisher=true, isSubscriber=false', () => { + const result = transformDataResponse({ + items: [makeDataItem({ 'x-medkit': { ros2: { direction: 'publish' } } })], + }); + expect(result[0]?.isPublisher).toBe(true); + expect(result[0]?.isSubscriber).toBe(false); + }); + + it('maps direction "subscribe" to isPublisher=false, isSubscriber=true', () => { + const raw = makeDataItem({ 'x-medkit': { ros2: { direction: 'subscribe' } } }); + const result = transformDataResponse({ items: [raw] }); + expect(result[0]?.isPublisher).toBe(false); + expect(result[0]?.isSubscriber).toBe(true); + }); + + it('maps direction "both" to isPublisher=true, isSubscriber=true', () => { + const raw = makeDataItem({ 'x-medkit': { ros2: { direction: 'both' } } }); + const result = transformDataResponse({ items: [raw] }); + expect(result[0]?.isPublisher).toBe(true); + expect(result[0]?.isSubscriber).toBe(true); + }); + + it('uses topic:direction as uniqueKey when direction present', () => { + const result = transformDataResponse({ items: [makeDataItem()] }); + expect(result[0]?.uniqueKey).toBe('/engine/temperature:publish'); + }); + + it('uses topic as uniqueKey when direction absent', () => { + const raw = { id: 'my_topic', name: 'my_topic' }; + const result = transformDataResponse({ items: [raw] }); + expect(result[0]?.uniqueKey).toBe('my_topic'); + }); + + it('returns empty array for empty response', () => { + const result = transformDataResponse({ items: [] }); + expect(result).toEqual([]); + }); +}); + +// ============================================================================= +// transformConfigurationsResponse +// ============================================================================= + +describe('transformConfigurationsResponse', () => { + const makeConfigResponse = (overrides: Record = {}) => ({ + 'x-medkit': { + entity_id: 'engine-controller', + ros2: { node: '/powertrain/engine_controller' }, + parameters: [ + { name: 'max_rpm', value: 8000, type: 'int' }, + { name: 'enabled', value: true, type: 'bool' }, + ], + }, + ...overrides, + }); + + it('extracts component_id from x-medkit.entity_id', () => { + const result = transformConfigurationsResponse(makeConfigResponse(), 'fallback-id'); + expect(result.component_id).toBe('engine-controller'); + }); + + it('falls back component_id to entityId parameter', () => { + const result = transformConfigurationsResponse({ 'x-medkit': {} }, 'fallback-id'); + expect(result.component_id).toBe('fallback-id'); + }); + + it('extracts node_name from x-medkit.ros2.node', () => { + const result = transformConfigurationsResponse(makeConfigResponse(), 'fallback-id'); + expect(result.node_name).toBe('/powertrain/engine_controller'); + }); + + it('falls back node_name to entityId when ros2.node absent', () => { + const result = transformConfigurationsResponse({ 'x-medkit': {} }, 'fallback-id'); + expect(result.node_name).toBe('fallback-id'); + }); + + it('extracts parameters array', () => { + const result = transformConfigurationsResponse(makeConfigResponse(), 'fallback-id'); + expect(result.parameters).toHaveLength(2); + expect(result.parameters[0]?.name).toBe('max_rpm'); + }); + + it('returns empty parameters array when absent', () => { + const result = transformConfigurationsResponse({ 'x-medkit': {} }, 'fallback-id'); + expect(result.parameters).toEqual([]); + }); + + it('handles missing x-medkit entirely', () => { + const result = transformConfigurationsResponse({}, 'fallback-id'); + expect(result.component_id).toBe('fallback-id'); + expect(result.node_name).toBe('fallback-id'); + expect(result.parameters).toEqual([]); + }); +}); + +// ============================================================================= +// transformBulkDataDescriptor +// ============================================================================= + +describe('transformBulkDataDescriptor', () => { + const makeRawDescriptor = (overrides: Record = {}) => ({ + id: 'abc-123', + name: 'rosbag_2026_03_22.mcap', + content_type: 'application/x-mcap', + size: 1024 * 1024, + created_at: '2026-03-22T10:00:00Z', + 'x-medkit': { + fault_code: 'ENGINE_OVERHEAT', + duration_sec: 5.0, + format: 'mcap', + }, + ...overrides, + }); + + it('renames content_type to mimetype', () => { + const result = transformBulkDataDescriptor(makeRawDescriptor()); + expect(result.mimetype).toBe('application/x-mcap'); + expect(result).not.toHaveProperty('content_type'); + }); + + it('renames created_at to creation_date', () => { + const result = transformBulkDataDescriptor(makeRawDescriptor()); + expect(result.creation_date).toBe('2026-03-22T10:00:00Z'); + expect(result).not.toHaveProperty('created_at'); + }); + + it('preserves id, name, and size', () => { + const result = transformBulkDataDescriptor(makeRawDescriptor()); + expect(result.id).toBe('abc-123'); + expect(result.name).toBe('rosbag_2026_03_22.mcap'); + expect(result.size).toBe(1024 * 1024); + }); + + it('preserves x-medkit extension fields', () => { + const result = transformBulkDataDescriptor(makeRawDescriptor()); + expect(result['x-medkit']?.fault_code).toBe('ENGINE_OVERHEAT'); + expect(result['x-medkit']?.duration_sec).toBe(5.0); + expect(result['x-medkit']?.format).toBe('mcap'); + }); + + it('handles missing x-medkit gracefully', () => { + const raw = makeRawDescriptor(); + delete (raw as Record)['x-medkit']; + const result = transformBulkDataDescriptor(raw); + expect(result['x-medkit']).toBeUndefined(); + }); + + it('handles missing content_type gracefully', () => { + const raw = makeRawDescriptor(); + delete (raw as Record)['content_type']; + const result = transformBulkDataDescriptor(raw); + expect(result.mimetype).toBe(''); + }); + + it('handles missing created_at gracefully', () => { + const raw = makeRawDescriptor(); + delete (raw as Record)['created_at']; + const result = transformBulkDataDescriptor(raw); + expect(result.creation_date).toBe(''); + }); +}); diff --git a/src/lib/transforms.ts b/src/lib/transforms.ts new file mode 100644 index 0000000..34f8109 --- /dev/null +++ b/src/lib/transforms.ts @@ -0,0 +1,379 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Response transformation functions for the SOVD gateway API. + * + * These are standalone pure functions extracted from SovdApiClient so that + * they can be reused with any HTTP client (hand-written or generated). + */ + +import type { + BulkDataDescriptor, + ComponentConfigurations, + ComponentTopic, + Fault, + FaultSeverity, + FaultStatusValue, + ListFaultsResponse, + Operation, + OperationKind, + Parameter, +} from './types'; +import { convertJsonSchemaToTopicSchema } from './schema-utils'; + +// ============================================================================= +// unwrapItems +// ============================================================================= + +/** + * Unwrap SOVD list responses. + * + * The gateway returns either a bare array (legacy) or an `{items: [...]}` wrapper. + * Returns an empty array for any falsy or missing input. + */ +export function unwrapItems(response: unknown): T[] { + if (Array.isArray(response)) { + return response as T[]; + } + const wrapped = response as { items?: T[] } | null | undefined; + return wrapped?.items ?? []; +} + +// ============================================================================= +// transformFault +// ============================================================================= + +/** + * Raw fault item shape returned by the gateway faults endpoints. + */ +export interface RawFaultItem { + fault_code: string; + description: string; + severity: number; + severity_label: string; + status: string; + first_occurred: number; + last_occurred?: number; + occurrence_count?: number; + reporting_sources?: string[]; + /** Entity type if provided by the gateway (not currently included in + * FaultManager::fault_to_json, but accepted for forward compatibility). + * Falls back to 'app' since faults are reported by ROS 2 nodes (apps). */ + entity_type?: string; +} + +/** + * Transform a single raw gateway fault item into the frontend `Fault` type. + * + * Field renames: + * - `fault_code` → `code` + * - `description` → `message` + * - `severity` (number) + `severity_label` → `severity` (string) + * - `status` (CONFIRMED / PREFAILED / ...) → `status` (active / pending / cleared / healed) + * - `first_occurred` (unix seconds) → `timestamp` (ISO 8601) + * - `reporting_sources[0]` last path segment → `entity_id` (underscores replaced by hyphens) + */ +export function transformFault(apiFault: RawFaultItem): Fault { + // Map severity number/label to FaultSeverity. + // Label check takes priority over numeric value; critical is checked first. + 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 API status string to FaultStatusValue. + let status: FaultStatusValue = 'active'; + const apiStatus = apiFault.status?.toLowerCase() || ''; + if (apiStatus === 'confirmed' || apiStatus === 'active') { + status = 'active'; + } else if (apiStatus === 'pending' || apiStatus === 'prefailed') { + status = 'pending'; + } else if (apiStatus === 'cleared' || apiStatus === 'resolved') { + status = 'cleared'; + } else if (apiStatus === 'healed' || apiStatus === 'prepassed') { + status = 'healed'; + } + + // Extract entity info from reporting_sources. + // reporting_sources contains ROS 2 node paths like "/bridge/diagnostic_bridge". + // We take the last segment and convert underscores to hyphens to match + // the SOVD app ID convention (e.g., "diagnostic_bridge" → "diagnostic-bridge"). + const source = apiFault.reporting_sources?.[0] || ''; + const nodeName = source.split('/').pop() || 'unknown'; + const entity_id = nodeName.replace(/_/g, '-'); + + // Use entity_type from raw data if provided, otherwise default to 'app'. + // The gateway's fault_to_json does not currently include entity_type, but + // faults are always reported by ROS 2 nodes which map to apps. + const entity_type = apiFault.entity_type || 'app'; + + 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, + }, + }; +} + +// ============================================================================= +// transformFaultsResponse +// ============================================================================= + +/** + * Raw shape of the faults list endpoint response. + */ +interface RawFaultsResponse { + items?: unknown[]; + 'x-medkit'?: { count?: number }; +} + +/** + * Transform the raw gateway faults list response into `ListFaultsResponse`. + */ +export function transformFaultsResponse(rawData: unknown): ListFaultsResponse { + if (!rawData || typeof rawData !== 'object') return { items: [], count: 0 }; + const data = rawData as RawFaultsResponse; + const items = (data.items || []).map((f) => transformFault(f as RawFaultItem)); + return { items, count: data['x-medkit']?.count ?? items.length }; +} + +// ============================================================================= +// transformOperationsResponse +// ============================================================================= + +/** + * Raw operation item shape from the gateway operations endpoint. + */ +interface RawOperation { + id: string; + name: string; + asynchronous_execution?: boolean; + 'x-medkit'?: { + entity_id?: string; + ros2?: { + kind?: 'service' | 'action'; + service?: string; + action?: string; + type?: string; + }; + type_info?: { + request?: unknown; + response?: unknown; + goal?: unknown; + result?: unknown; + feedback?: unknown; + }; + }; +} + +/** + * Transform the raw operations list response into `Operation[]`. + * + * Extracts `kind`, `path`, and `type` from the `x-medkit` vendor extension. + */ +export function transformOperationsResponse(rawData: unknown): Operation[] { + if (!rawData || typeof rawData !== 'object') return []; + const rawOps = unwrapItems(rawData); + return rawOps.map((op) => { + const xMedkit = op['x-medkit']; + const ros2Info = xMedkit?.ros2; + const rawTypeInfo = xMedkit?.type_info; + + // Determine kind from x-medkit.ros2.kind or from asynchronous_execution flag. + let kind: OperationKind = 'service'; + if (ros2Info?.kind) { + kind = ros2Info.kind; + } else if (op.asynchronous_execution) { + kind = 'action'; + } + + // Build type_info with the appropriate schema structure for the kind. + let typeInfo: Operation['type_info'] | undefined; + if (rawTypeInfo) { + if (kind === 'service' && (rawTypeInfo.request || rawTypeInfo.response)) { + typeInfo = { + schema: { + request: + (rawTypeInfo.request ? convertJsonSchemaToTopicSchema(rawTypeInfo.request) : undefined) ?? + {}, + response: + (rawTypeInfo.response ? convertJsonSchemaToTopicSchema(rawTypeInfo.response) : undefined) ?? + {}, + }, + }; + } else if (kind === 'action' && (rawTypeInfo.goal || rawTypeInfo.result)) { + typeInfo = { + schema: { + goal: (rawTypeInfo.goal ? convertJsonSchemaToTopicSchema(rawTypeInfo.goal) : undefined) ?? {}, + result: + (rawTypeInfo.result ? convertJsonSchemaToTopicSchema(rawTypeInfo.result) : undefined) ?? {}, + feedback: + (rawTypeInfo.feedback ? convertJsonSchemaToTopicSchema(rawTypeInfo.feedback) : undefined) ?? + {}, + }, + }; + } + } + + return { + name: op.name || op.id, + path: ros2Info?.service || ros2Info?.action || `/${op.name}`, + type: ros2Info?.type || '', + kind, + type_info: typeInfo, + }; + }); +} + +// ============================================================================= +// transformDataResponse +// ============================================================================= + +/** + * Raw data item shape from the gateway data endpoint. + */ +interface RawDataItem { + id: string; + name?: string; + category?: string; + 'x-medkit'?: { + ros2?: { topic?: string; type?: string; direction?: string }; + type_info?: { schema?: unknown; default_value?: unknown }; + }; +} + +/** + * Transform the raw data list response into `ComponentTopic[]`. + * + * Extracts topic metadata (type, direction, schema) from the `x-medkit` extension. + */ +export function transformDataResponse(rawData: unknown): ComponentTopic[] { + if (!rawData || typeof rawData !== 'object') return []; + const dataItems = unwrapItems(rawData); + return dataItems.map((item) => { + const rawTypeInfo = item['x-medkit']?.type_info; + const convertedSchema = rawTypeInfo?.schema ? convertJsonSchemaToTopicSchema(rawTypeInfo.schema) : undefined; + const direction = item['x-medkit']?.ros2?.direction; + const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id; + return { + topic: topicName, + timestamp: Date.now(), + data: null, + status: 'metadata_only' as const, + type: item['x-medkit']?.ros2?.type, + type_info: convertedSchema + ? { + schema: convertedSchema, + default_value: rawTypeInfo?.default_value as Record, + } + : undefined, + // Direction-based fields for apps/functions. + isPublisher: direction === 'publish' || direction === 'both', + isSubscriber: direction === 'subscribe' || direction === 'both', + uniqueKey: direction ? `${topicName}:${direction}` : topicName, + }; + }); +} + +// ============================================================================= +// transformConfigurationsResponse +// ============================================================================= + +/** + * Raw configurations response shape from the gateway. + */ +interface RawConfigurationsResponse { + 'x-medkit'?: { + entity_id?: string; + ros2?: { node?: string }; + parameters?: Parameter[]; + }; +} + +/** + * Transform the raw configurations response into `ComponentConfigurations`. + * + * All meaningful data lives in the `x-medkit` extension. The `entityId` parameter + * is used as a fallback when `x-medkit` fields are absent. + */ +export function transformConfigurationsResponse(rawData: unknown, entityId: string): ComponentConfigurations { + if (!rawData || typeof rawData !== 'object') { + return { component_id: entityId, node_name: entityId, parameters: [] }; + } + const data = rawData as RawConfigurationsResponse; + const xMedkit = data['x-medkit'] || {}; + return { + component_id: xMedkit.entity_id || entityId, + node_name: xMedkit.ros2?.node || entityId, + parameters: xMedkit.parameters || [], + }; +} + +// ============================================================================= +// transformBulkDataDescriptor +// ============================================================================= + +/** + * Raw bulk-data descriptor shape as returned by the gateway. + * The SOVD spec uses `content_type` and `created_at`; the frontend type uses + * `mimetype` and `creation_date`. + */ +interface RawBulkDataDescriptor { + id: string; + name: string; + content_type?: string; + size: number; + created_at?: string; + 'x-medkit'?: { + fault_code: string; + duration_sec: number; + format: string; + }; +} + +/** + * Transform a raw gateway bulk-data descriptor into `BulkDataDescriptor`. + * + * Field renames: + * - `content_type` → `mimetype` + * - `created_at` → `creation_date` + */ +export function transformBulkDataDescriptor(raw: unknown): BulkDataDescriptor { + if (!raw || typeof raw !== 'object') { + return { id: '', name: '', mimetype: '', size: 0, creation_date: '' }; + } + const r = raw as RawBulkDataDescriptor; + return { + id: r.id, + name: r.name, + mimetype: r.content_type ?? '', + size: r.size, + creation_date: r.created_at ?? '', + 'x-medkit': r['x-medkit'], + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index a487817..b118119 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,48 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// ============================================================================= +// Section 1: Re-exports from generated client +// Types whose shapes match the generated OpenAPI schema are re-exported here. +// Most UI types differ significantly (field renames, x-medkit extensions), so +// they stay as manual definitions. transforms.ts handles the runtime mapping. +// ============================================================================= + +import type { components } from '@selfpatch/ros2-medkit-client-ts'; + /** - * SOVD Entity types for discovery endpoints + * SOVD version info from GET /version-info + * Shape matches generated client schema. */ +export type VersionInfo = components['schemas']['VersionInfo']; + +/** + * SOVD-compliant generic error response + * Shape matches generated client schema. + */ +export type GenericError = components['schemas']['GenericError']; + +/** + * Alias for GenericError for backwards compatibility with existing code. + */ +export type SovdError = GenericError; + +// ============================================================================= +// Section 2 & 3: Manual type definitions +// API types with significant differences from the generated schema, and +// UI-only types that are never part of the API. +// ============================================================================= /** * SOVD Resource Entity Type for API endpoints @@ -827,14 +869,6 @@ export interface SovdInfoEntry { vendor_info?: VendorInfo; } -/** - * Version info from GET /version-info - */ -export interface VersionInfo { - /** Array of SOVD version info entries */ - items: SovdInfoEntry[]; -} - // ============================================================================= // AUTHENTICATION (Optional SOVD Auth) // ============================================================================= @@ -884,19 +918,3 @@ export interface TokenRevokeRequest { /** 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; -} diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 624e4c5..77e11c2 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { cn } from './utils'; +import { cn, formatBytes, formatDuration, mapFaultEntityTypeToResourceType } from './utils'; describe('cn utility', () => { it('merges class names', () => { @@ -23,3 +23,26 @@ describe('cn utility', () => { expect(cn(['foo', 'bar'])).toBe('foo bar'); }); }); + +describe('formatBytes', () => { + it('formats zero bytes', () => expect(formatBytes(0)).toBe('0 B')); + it('formats kilobytes', () => expect(formatBytes(1536)).toBe('1.5 KB')); + it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB')); +}); + +describe('formatDuration', () => { + it('formats seconds under a minute', () => expect(formatDuration(30)).toBe('30.0s')); + it('formats minutes and seconds', () => expect(formatDuration(90)).toBe('1m 30s')); +}); + +describe('mapFaultEntityTypeToResourceType', () => { + it('maps singular to plural', () => { + expect(mapFaultEntityTypeToResourceType('app')).toBe('apps'); + expect(mapFaultEntityTypeToResourceType('component')).toBe('components'); + expect(mapFaultEntityTypeToResourceType('area')).toBe('areas'); + expect(mapFaultEntityTypeToResourceType('function')).toBe('functions'); + }); + it('passes through plural forms', () => expect(mapFaultEntityTypeToResourceType('apps')).toBe('apps')); + it('defaults to components for unknown', () => + expect(mapFaultEntityTypeToResourceType('unknown')).toBe('components')); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3877c89..7bf3517 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,52 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import type { SovdResourceEntityType } from './types'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +/** + * Map fault entity_type (may be singular or plural) to SovdResourceEntityType (always plural). + * Shared utility used by FaultsDashboard and FaultsPanel. + */ +export function mapFaultEntityTypeToResourceType(entityType: string): SovdResourceEntityType { + const type = entityType.toLowerCase(); + if (type === 'area' || type === 'areas') return 'areas'; + if (type === 'app' || type === 'apps') return 'apps'; + if (type === 'function' || type === 'functions') return 'functions'; + if (type === 'component' || type === 'components') return 'components'; + + console.warn( + '[mapFaultEntityTypeToResourceType] Unexpected entity_type:', + entityType, + '- defaulting to "components".' + ); + return 'components'; +} + +/** + * Format bytes as human-readable string + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +/** + * Format duration in seconds as human-readable string + */ +export function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${mins}m ${secs}s`; +}