From 0776d6452f5a1937a25fa679d5e0f1d22aa42387 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 17:30:33 +0000 Subject: [PATCH 01/26] feat: add entity-specific panels and improve visual hierarchy - Add AreasPanel for area entity details with related components - Add AppsPanel for ROS 2 node details with tabs for data/operations/configs/faults - Add FunctionsPanel for capability grouping with aggregated resources - Refactor EntityTreeNode with distinct icons and colors per entity type - Add breadcrumb navigation in EntityDetailPanel - Update EntityDetailPanel to route to appropriate panels by entity type Entity types now have visual distinction: - Area: cyan (Layers icon) - Component: indigo (Box icon) - App: emerald (Cpu icon) - Function: violet (GitBranch icon) - Resources: blue/amber/purple/red for data/ops/configs/faults --- src/components/AppsPanel.tsx | 457 +++++++++++++++++++++++++++ src/components/AreasPanel.tsx | 135 ++++++++ src/components/EntityDetailPanel.tsx | 224 +++++++++---- src/components/EntityTreeNode.tsx | 105 +++++- src/components/FunctionsPanel.tsx | 332 +++++++++++++++++++ 5 files changed, 1181 insertions(+), 72 deletions(-) create mode 100644 src/components/AppsPanel.tsx create mode 100644 src/components/AreasPanel.tsx create mode 100644 src/components/FunctionsPanel.tsx diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx new file mode 100644 index 0000000..0cbd800 --- /dev/null +++ b/src/components/AppsPanel.tsx @@ -0,0 +1,457 @@ +import { useState, useEffect } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Cpu, Database, Zap, Settings, AlertTriangle, ChevronRight, Box, Network, FileCode } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { useAppStore } from '@/lib/store'; +import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; + +type AppTab = 'overview' | 'data' | 'operations' | 'configurations' | 'faults'; + +interface TabConfig { + id: AppTab; + label: string; + icon: typeof Database; +} + +const APP_TABS: TabConfig[] = [ + { id: 'overview', label: 'Overview', icon: Cpu }, + { id: 'data', label: 'Data', icon: Database }, + { id: 'operations', label: 'Operations', icon: Zap }, + { id: 'configurations', label: 'Config', icon: Settings }, + { id: 'faults', label: 'Faults', icon: AlertTriangle }, +]; + +interface AppsPanelProps { + appId: string; + appName?: string; + fqn?: string; + nodeName?: string; + namespace?: string; + componentId?: string; + path: string; + onNavigate?: (path: string) => void; +} + +/** + * Apps Panel - displays app (ROS 2 node) entity details + * + * Apps are individual ROS 2 nodes in SOVD. They have: + * - Data (topics they publish/subscribe to) + * - Operations (services/actions they provide) + * - Configurations (parameters) + * - Faults (diagnostic trouble codes) + */ +export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentId, path, onNavigate }: AppsPanelProps) { + const [activeTab, setActiveTab] = useState('overview'); + const [topics, setTopics] = useState([]); + const [operations, setOperations] = useState([]); + const [parameters, setParameters] = useState([]); + const [faults, setFaults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const { client, selectEntity } = useAppStore( + useShallow((state) => ({ + client: state.client, + selectEntity: state.selectEntity, + })) + ); + + // Load app resources on mount + useEffect(() => { + const loadAppData = async () => { + if (!client) return; + setIsLoading(true); + + try { + // Load all resources in parallel + const [topicsData, opsData, configData, faultsData] = await Promise.all([ + client.getAppData(appId).catch(() => []), + client.listOperations(appId, 'apps').catch(() => []), + client.listConfigurations(appId, 'apps').catch(() => ({ parameters: [] })), + client.listEntityFaults('apps', appId).catch(() => ({ items: [] })), + ]); + + setTopics(topicsData); + setOperations(opsData); + setParameters(configData.parameters); + setFaults(faultsData.items); + } catch (error) { + console.error('Failed to load app data:', error); + } finally { + setIsLoading(false); + } + }; + + loadAppData(); + }, [client, appId]); + + const handleResourceClick = (resourcePath: string) => { + if (onNavigate) { + onNavigate(resourcePath); + } else { + selectEntity(resourcePath); + } + }; + + // Count resources for badges + const publishTopics = topics.filter((t) => t.isPublisher); + const subscribeTopics = topics.filter((t) => t.isSubscriber); + const services = operations.filter((o) => o.kind === 'service'); + const actions = operations.filter((o) => o.kind === 'action'); + const activeFaults = faults.filter((f) => f.status === 'active'); + + return ( +
+ {/* App Header */} + + +
+
+ +
+
+ {appName || nodeName || appId} + + + app + + {componentId && ( + <> + + + + )} + +
+
+
+ + {/* Tab Navigation */} +
+
+ {APP_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + let count = 0; + if (tab.id === 'data') count = topics.length; + if (tab.id === 'operations') count = operations.length; + if (tab.id === 'configurations') count = parameters.length; + if (tab.id === 'faults') count = activeFaults.length; + + return ( + + ); + })} +
+
+
+ + {/* Tab Content */} + {activeTab === 'overview' && ( + + + Node Information + + +
+
+
+ + Node Name +
+

{nodeName || appId}

+
+
+
+ + Namespace +
+

{namespace || '/'}

+
+
+
+ + Fully Qualified Name +
+

+ {fqn || `${namespace || '/'}${nodeName || appId}`} +

+
+
+ + {/* Resource Summary */} +
+ + + + +
+
+
+ )} + + {activeTab === 'data' && ( + + + + + Topics + + + {publishTopics.length} published, {subscribeTopics.length} subscribed + + + + {topics.length === 0 ? ( +
+ No topics available for this app. +
+ ) : ( +
+ {topics.map((topic, idx) => { + const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; + const encodedName = encodeURIComponent(topic.uniqueKey || cleanName); + const topicPath = `${path}/data/${encodedName}`; + + return ( +
handleResourceClick(topicPath)} + > + + {topic.isPublisher ? 'pub' : 'sub'} + + {topic.topic} + {topic.type && ( + + {topic.type} + + )} + +
+ ); + })} +
+ )} +
+
+ )} + + {activeTab === 'operations' && ( + + + + + Operations + + + {services.length} services, {actions.length} actions + + + + {operations.length === 0 ? ( +
+ No operations available for this app. +
+ ) : ( +
+ {operations.map((op) => { + const opPath = `${path}/operations/${encodeURIComponent(op.name)}`; + return ( +
handleResourceClick(opPath)} + > + + {op.kind} + + {op.name} + + {op.type} + + +
+ ); + })} +
+ )} +
+
+ )} + + {activeTab === 'configurations' && ( + + + + + Parameters + + {parameters.length} parameters configured + + + {parameters.length === 0 ? ( +
+ No parameters available for this app. +
+ ) : ( +
+ {parameters.map((param) => { + const paramPath = `${path}/configurations/${encodeURIComponent(param.name)}`; + return ( +
handleResourceClick(paramPath)} + > + + {param.type} + + {param.name} + + {JSON.stringify(param.value)} + + +
+ ); + })} +
+ )} +
+
+ )} + + {activeTab === 'faults' && ( + + + + 0 ? 'text-red-500' : 'text-muted-foreground'}`} + /> + Faults + + + {activeFaults.length} active, {faults.length - activeFaults.length} cleared + + + + {faults.length === 0 ? ( +
+ +

No faults detected for this app.

+
+ ) : ( +
+ {faults.map((fault) => ( +
+ + {fault.severity} + +
+ {fault.code} +

{fault.message}

+
+ {fault.status} +
+ ))} +
+ )} +
+
+ )} + + {isLoading &&
Loading app resources...
} +
+ ); +} diff --git a/src/components/AreasPanel.tsx b/src/components/AreasPanel.tsx new file mode 100644 index 0000000..61b4cd0 --- /dev/null +++ b/src/components/AreasPanel.tsx @@ -0,0 +1,135 @@ +import { useShallow } from 'zustand/shallow'; +import { Layers, Box, ChevronRight, MapPin } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useAppStore } from '@/lib/store'; + +interface AreasPanelProps { + areaId: string; + areaName?: string; + path: string; +} + +/** + * Areas Panel - displays area entity details with related components + * + * Areas are namespace groupings in SOVD. They can have: + * - Subareas (child areas) + * - Related components (components in this area) + */ +export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) { + const { rootEntities, selectEntity, expandedPaths, toggleExpanded } = useAppStore( + useShallow((state) => ({ + rootEntities: state.rootEntities, + selectEntity: state.selectEntity, + expandedPaths: state.expandedPaths, + toggleExpanded: state.toggleExpanded, + })) + ); + + // Find the area node in the tree to get its children (components) + const areaNode = rootEntities.find((n) => n.id === areaId || n.path === path); + const components = areaNode?.children?.filter((c) => c.type === 'component') || []; + + const handleComponentClick = (componentPath: string) => { + selectEntity(componentPath); + // Auto-expand the component + if (!expandedPaths.includes(componentPath)) { + toggleExpanded(componentPath); + } + }; + + return ( +
+ {/* Area Overview */} + + +
+
+ +
+
+ {areaName || areaId} + + + area + + + {path} + +
+
+
+ +
+
+
+ + Namespace +
+

/{areaId}

+
+
+
+ + Components +
+

{components.length}

+
+
+
+
+ + {/* Related Components */} + {components.length > 0 && ( + + +
+ + Components in this Area + {components.length} +
+ + Components are logical groupings of ROS 2 nodes (apps) within this namespace. + +
+ +
+ {components.map((component) => ( +
handleComponentClick(component.path)} + > +
+ +
+
+
{component.name}
+
+ {component.id} +
+
+ +
+ ))} +
+
+
+ )} + + {/* Empty state when no components */} + {components.length === 0 && ( + + +
+ +

No components found in this area.

+

Components will appear here when ROS 2 nodes are discovered.

+
+
+
+ )} +
+ ); +} diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 3dc45d3..4f2523c 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -12,6 +12,10 @@ import { Settings, RefreshCw, Box, + Layers, + Cpu, + GitBranch, + Home, } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -22,6 +26,9 @@ import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; import { DataFolderPanel } from '@/components/DataFolderPanel'; import { FaultsPanel } from '@/components/FaultsPanel'; +import { AreasPanel } from '@/components/AreasPanel'; +import { AppsPanel } from '@/components/AppsPanel'; +import { FunctionsPanel } from '@/components/FunctionsPanel'; import { useAppStore, type AppState } from '@/lib/store'; import type { ComponentTopic, Parameter } from '@/lib/types'; @@ -382,6 +389,9 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { if (selectedEntity) { const isTopic = selectedEntity.type === 'topic'; const isComponent = selectedEntity.type === 'component'; + const isArea = selectedEntity.type === 'area'; + const isApp = selectedEntity.type === 'app'; + const isFunction = selectedEntity.type === 'function'; const hasTopicData = isTopic && selectedEntity.topicData; // Prefer full topics array (with QoS, type info) over topicsInfo (names only) const hasTopicsArray = isComponent && selectedEntity.topics && selectedEntity.topics.length > 0; @@ -397,70 +407,167 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { const pathParts = selectedPath.split('/').filter(Boolean); const componentId = (pathParts.length >= 2 ? pathParts[1] : pathParts[0]) ?? selectedEntity.id; + // Get icon for entity type + const getEntityTypeIcon = () => { + switch (selectedEntity.type) { + case 'area': + return ; + case 'component': + return ; + case 'app': + return ; + case 'function': + return ; + default: + return ; + } + }; + + // Get background color for entity type + const getEntityBgColor = () => { + switch (selectedEntity.type) { + case 'area': + return 'bg-cyan-100 dark:bg-cyan-900'; + case 'component': + return 'bg-indigo-100 dark:bg-indigo-900'; + case 'app': + return 'bg-emerald-100 dark:bg-emerald-900'; + case 'function': + return 'bg-violet-100 dark:bg-violet-900'; + default: + return 'bg-primary/10'; + } + }; + + // Build breadcrumb from path + const breadcrumbs = pathParts.map((part, index) => ({ + label: part, + path: '/' + pathParts.slice(0, index + 1).join('/'), + })); + return (
- {/* Component Header with Dashboard style */} - - -
-
-
- -
-
- {selectedEntity.name} - - {selectedEntity.type} - - {selectedPath} - -
-
-
- + {breadcrumbs.map((crumb, index) => ( +
+ + - + {crumb.label} +
-
- + ))} + + )} + + {/* Area Entity View */} + {isArea && !hasError && ( + + )} + + {/* App Entity View */} + {isApp && !hasError && ( + + )} + + {/* Function Entity View */} + {isFunction && !hasError && ( + + )} - {/* Tab Navigation for Components */} - {isComponent && ( -
-
- {COMPONENT_TABS.map((tab) => { - const TabIcon = tab.icon; - const isActive = activeTab === tab.id; - return ( - - ); - })} + {/* Component/Generic Header */} + {!isArea && !isApp && !isFunction && ( + + +
+
+
+ {getEntityTypeIcon()} +
+
+ {selectedEntity.name} + + {selectedEntity.type} + + {selectedPath} + +
+
+
+ + +
-
- )} - + + + {/* Tab Navigation for Components */} + {isComponent && ( +
+
+ {COMPONENT_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+
+ )} + + )} {/* Content based on entity type and active tab */} {hasError ? ( @@ -500,7 +607,8 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { hasTopicsInfo={hasTopicsInfo ?? false} selectEntity={selectEntity} /> - ) : selectedEntity.type === 'service' || selectedEntity.type === 'action' ? ( + ) : isArea || isApp || isFunction ? // Already handled above with specialized panels + null : selectedEntity.type === 'service' || selectedEntity.type === 'action' ? ( // Service/Action detail view ) : selectedEntity.type === 'parameter' ? ( diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index 9a4e617..f5874a9 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -5,6 +5,7 @@ import { Loader2, Server, Folder, + FolderOpen, FileJson, Box, MessageSquare, @@ -18,6 +19,9 @@ import { AlertTriangle, Cpu, Users, + Layers, + GitBranch, + Package, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -31,10 +35,23 @@ interface EntityTreeNodeProps { } /** - * Get icon for entity type + * Get icon for entity type with visual distinction between entities and resources + * + * Entity types (structural): + * - Area: Layers (namespace grouping) + * - Component: Box (logical grouping) + * - App: Cpu (ROS 2 node) + * - Function: GitBranch (capability grouping) + * + * Resource types (data collections): + * - Data: Database + * - Operations: Zap + * - Configurations: Settings + * - Faults: AlertTriangle + * - Apps folder: Users */ -function getEntityIcon(type: string, data?: unknown) { - // Check for virtual folder types +function getEntityIcon(type: string, data?: unknown, isExpanded?: boolean) { + // Check for virtual folder types (resource collections) if (isVirtualFolderData(data)) { const folderData = data as VirtualFolderData; switch (folderData.folderType) { @@ -52,15 +69,23 @@ function getEntityIcon(type: string, data?: unknown) { } switch (type.toLowerCase()) { - case 'device': - case 'server': - return Server; + // Entity types + case 'area': + return Layers; case 'component': case 'ecu': return Box; + case 'app': + return Cpu; + case 'function': + return GitBranch; + // Collection/folder types case 'folder': - case 'area': - return Folder; + return isExpanded ? FolderOpen : Folder; + case 'device': + case 'server': + return Server; + // Resource item types case 'topic': return MessageSquare; case 'service': @@ -69,15 +94,61 @@ function getEntityIcon(type: string, data?: unknown) { return Clock; case 'parameter': return Sliders; - case 'app': - return Cpu; case 'fault': return AlertTriangle; + case 'package': + return Package; default: return FileJson; } } +/** + * Get color class for entity type + */ +function getEntityColor(type: string, data?: unknown, isSelected?: boolean): string { + if (isSelected) return 'text-primary'; + + // Check for virtual folder types (resource collections) + if (isVirtualFolderData(data)) { + const folderData = data as VirtualFolderData; + switch (folderData.folderType) { + case 'data': + return 'text-blue-500'; + case 'operations': + return 'text-amber-500'; + case 'configurations': + return 'text-purple-500'; + case 'faults': + return 'text-red-500'; + case 'apps': + return 'text-green-500'; + } + } + + switch (type.toLowerCase()) { + case 'area': + return 'text-cyan-500'; + case 'component': + case 'ecu': + return 'text-indigo-500'; + case 'app': + return 'text-emerald-500'; + case 'function': + return 'text-violet-500'; + case 'topic': + return 'text-blue-400'; + case 'service': + return 'text-amber-400'; + case 'action': + return 'text-orange-400'; + case 'fault': + return 'text-red-400'; + default: + return 'text-muted-foreground'; + } +} + /** * Check if node data is TopicNodeData (from topicsInfo) */ @@ -108,7 +179,8 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { const isLoading = loadingPaths.includes(node.path); const isSelected = selectedPath === node.path; const hasChildren = node.hasChildren !== false; // Default to true if not specified - const Icon = getEntityIcon(node.type, node.data); + const Icon = getEntityIcon(node.type, node.data, isExpanded); + const iconColorClass = getEntityColor(node.type, node.data, isSelected); // Get topic direction info if available const topicData = isTopicNodeData(node.data) ? node.data : null; @@ -163,7 +235,7 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { - + {node.name} @@ -188,8 +260,13 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { )} - {/* Type label */} - + {/* Type label badge */} + {node.type}
diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx new file mode 100644 index 0000000..4eb6fdc --- /dev/null +++ b/src/components/FunctionsPanel.tsx @@ -0,0 +1,332 @@ +import { useState, useEffect } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { GitBranch, Cpu, Database, Zap, ChevronRight, Users, Info } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useAppStore } from '@/lib/store'; +import type { ComponentTopic, Operation } from '@/lib/types'; + +type FunctionTab = 'overview' | 'hosts' | 'data' | 'operations'; + +interface TabConfig { + id: FunctionTab; + label: string; + icon: typeof Database; +} + +const FUNCTION_TABS: TabConfig[] = [ + { id: 'overview', label: 'Overview', icon: Info }, + { id: 'hosts', label: 'Hosts', icon: Cpu }, + { id: 'data', label: 'Data', icon: Database }, + { id: 'operations', label: 'Operations', icon: Zap }, +]; + +interface FunctionsPanelProps { + functionId: string; + functionName?: string; + description?: string; + path: string; + onNavigate?: (path: string) => void; +} + +/** + * Functions Panel - displays function (capability grouping) entity details + * + * Functions are capability groupings in SOVD. They can have: + * - Hosts (apps that implement this function) + * - Data (aggregated from all hosts) + * - Operations (aggregated from all hosts) + */ +export function FunctionsPanel({ functionId, functionName, description, path, onNavigate }: FunctionsPanelProps) { + const [activeTab, setActiveTab] = useState('overview'); + const [hosts, setHosts] = useState([]); + const [topics, setTopics] = useState([]); + const [operations, setOperations] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const { client, selectEntity } = useAppStore( + useShallow((state) => ({ + client: state.client, + selectEntity: state.selectEntity, + })) + ); + + // Load function resources on mount + useEffect(() => { + const loadFunctionData = async () => { + if (!client) return; + setIsLoading(true); + + try { + // Load hosts, data, and operations in parallel + const [hostsData, topicsData, opsData] = await Promise.all([ + client.getFunctionHosts(functionId).catch(() => []), + client.getFunctionData(functionId).catch(() => []), + client.getFunctionOperations(functionId).catch(() => []), + ]); + + setHosts(hostsData); + setTopics(topicsData); + setOperations(opsData); + } catch (error) { + console.error('Failed to load function data:', error); + } finally { + setIsLoading(false); + } + }; + + loadFunctionData(); + }, [client, functionId]); + + const handleResourceClick = (resourcePath: string) => { + if (onNavigate) { + onNavigate(resourcePath); + } else { + selectEntity(resourcePath); + } + }; + + // Count resources for badges + const services = operations.filter((o) => o.kind === 'service'); + const actions = operations.filter((o) => o.kind === 'action'); + + return ( +
+ {/* Function Header */} + + +
+
+ +
+
+ {functionName || functionId} + + + function + + + {path} + +
+
+
+ + {/* Tab Navigation */} +
+
+ {FUNCTION_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + let count = 0; + if (tab.id === 'hosts') count = hosts.length; + if (tab.id === 'data') count = topics.length; + if (tab.id === 'operations') count = operations.length; + + return ( + + ); + })} +
+
+
+ + {/* Tab Content */} + {activeTab === 'overview' && ( + + + Function Information + + + {description && ( +
+
+ + Description +
+

{description}

+
+ )} + + {/* Resource Summary */} +
+ + + +
+ + {hosts.length === 0 && !isLoading && ( +
+ +

No host apps are implementing this function yet.

+
+ )} +
+
+ )} + + {activeTab === 'hosts' && ( + + + + + Host Apps + + Apps implementing this function + + + {hosts.length === 0 ? ( +
+ +

No host apps found for this function.

+
+ ) : ( +
+ {hosts.map((hostId) => ( +
handleResourceClick(`/apps/${hostId}`)} + > +
+ +
+ {hostId} + + app + + +
+ ))} +
+ )} +
+
+ )} + + {activeTab === 'data' && ( + + + + + Aggregated Data + + Data items from all host apps + + + {topics.length === 0 ? ( +
+ +

No data items available.

+
+ ) : ( +
+ {topics.map((topic, idx) => ( +
+ + topic + + {topic.topic} + {topic.type && ( + + {topic.type} + + )} +
+ ))} +
+ )} +
+
+ )} + + {activeTab === 'operations' && ( + + + + + Aggregated Operations + + + {services.length} services, {actions.length} actions from all hosts + + + + {operations.length === 0 ? ( +
+ +

No operations available.

+
+ ) : ( +
+ {operations.map((op) => ( +
+ + {op.kind} + + {op.name} + + {op.type} + +
+ ))} +
+ )} +
+
+ )} + + {isLoading &&
Loading function resources...
} +
+ ); +} From 5871c4beb18eb6030a13ca2f81643043b38ad8fe Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 17:33:28 +0000 Subject: [PATCH 02/26] feat: add skeleton loading states and theme toggle - Add EntityTreeSkeleton for tree loading state - Add EntityDetailSkeleton for detail panel loading states - Add ThemeToggle component with light/dark/system modes - Add shadcn skeleton, switch, dropdown-menu components - Update EntityTreeSidebar to use skeleton and theme toggle - Update EntityDetailPanel to use skeleton loading - Add refresh button spinner animation --- package-lock.json | 358 ++++++++++++++++++++++++ package.json | 2 + src/components/EntityDetailPanel.tsx | 12 +- src/components/EntityDetailSkeleton.tsx | 102 +++++++ src/components/EntityTreeSidebar.tsx | 20 +- src/components/EntityTreeSkeleton.tsx | 54 ++++ src/components/ThemeToggle.tsx | 103 +++++++ src/components/ui/dropdown-menu.tsx | 219 +++++++++++++++ src/components/ui/skeleton.tsx | 7 + src/components/ui/switch.tsx | 33 +++ 10 files changed, 901 insertions(+), 9 deletions(-) create mode 100644 src/components/EntityDetailSkeleton.tsx create mode 100644 src/components/EntityTreeSkeleton.tsx create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/switch.tsx diff --git a/package-lock.json b/package-lock.json index 507df14..5783ec6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -1166,6 +1168,44 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1276,6 +1316,29 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", @@ -1306,6 +1369,50 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1390,6 +1497,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", @@ -1417,6 +1539,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -1475,6 +1626,96 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", @@ -1564,6 +1805,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -1582,6 +1854,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1667,6 +1968,63 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", diff --git a/package.json b/package.json index efdec0e..f55f02f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 4f2523c..5631a3f 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useShallow } from 'zustand/shallow'; import { Copy, - Loader2, Radio, ChevronRight, ArrowUp, @@ -21,6 +20,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { EmptyState } from '@/components/EmptyState'; +import { EntityDetailSkeleton } from '@/components/EntityDetailSkeleton'; import { TopicDiagnosticsPanel } from '@/components/TopicDiagnosticsPanel'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; @@ -379,8 +379,10 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { // Loading if (isLoadingDetails) { return ( -
- +
+
+ +
); } @@ -607,8 +609,8 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { hasTopicsInfo={hasTopicsInfo ?? false} selectEntity={selectEntity} /> - ) : isArea || isApp || isFunction ? // Already handled above with specialized panels - null : selectedEntity.type === 'service' || selectedEntity.type === 'action' ? ( + ) : isArea || isApp || isFunction ? null : selectedEntity.type === 'service' || // Already handled above with specialized panels + selectedEntity.type === 'action' ? ( // Service/Action detail view ) : selectedEntity.type === 'parameter' ? ( diff --git a/src/components/EntityDetailSkeleton.tsx b/src/components/EntityDetailSkeleton.tsx new file mode 100644 index 0000000..457d929 --- /dev/null +++ b/src/components/EntityDetailSkeleton.tsx @@ -0,0 +1,102 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardHeader, CardContent } from '@/components/ui/card'; + +/** + * Skeleton loading state for entity detail panels + */ +export function EntityDetailSkeleton() { + return ( +
+ {/* Breadcrumb skeleton */} +
+ + + + + +
+ + {/* Header card skeleton */} + + +
+
+ +
+ +
+ + + +
+
+
+
+ + +
+
+
+
+ + {/* Content card skeleton */} + + +
+ + +
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+
+ ); +} + +/** + * Skeleton for statistics cards grid + */ +export function StatCardsSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ ); +} + +/** + * Skeleton for resource list items + */ +export function ResourceListSkeleton({ count = 5 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} diff --git a/src/components/EntityTreeSidebar.tsx b/src/components/EntityTreeSidebar.tsx index 4335f77..6a0f05c 100644 --- a/src/components/EntityTreeSidebar.tsx +++ b/src/components/EntityTreeSidebar.tsx @@ -4,6 +4,8 @@ import { Server, Settings, RefreshCw, Search, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { EntityTreeNode } from '@/components/EntityTreeNode'; +import { EntityTreeSkeleton } from '@/components/EntityTreeSkeleton'; +import { ThemeToggle } from '@/components/ThemeToggle'; import { EmptyState } from '@/components/EmptyState'; import { useAppStore } from '@/lib/store'; import type { EntityTreeNode as EntityTreeNodeType } from '@/lib/types'; @@ -39,10 +41,12 @@ function filterTree(nodes: EntityTreeNodeType[], query: string): EntityTreeNodeT export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) { const [searchQuery, setSearchQuery] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); - const { isConnected, serverUrl, rootEntities, loadRootEntities } = useAppStore( + const { isConnected, isConnecting, serverUrl, rootEntities, loadRootEntities } = useAppStore( useShallow((state) => ({ isConnected: state.isConnected, + isConnecting: state.isConnecting, serverUrl: state.serverUrl, rootEntities: state.rootEntities, loadRootEntities: state.loadRootEntities, @@ -56,14 +60,18 @@ export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) { return filterTree(rootEntities, searchQuery.trim()); }, [rootEntities, searchQuery]); - const handleRefresh = () => { - loadRootEntities(); + const handleRefresh = async () => { + setIsRefreshing(true); + await loadRootEntities(); + setIsRefreshing(false); }; const handleClearSearch = () => { setSearchQuery(''); }; + const isLoading = isConnecting || (isConnected && rootEntities.length === 0 && !searchQuery); + return (
+ {isConnected ? ( <> + + + setTheme('light')}> + + Light + {theme === 'light' && } + + setTheme('dark')}> + + Dark + {theme === 'dark' && } + + setTheme('system')}> + + System + {theme === 'system' && } + + + + ); +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bb2e398 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,219 @@ +'use client'; + +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..2c690c8 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '@/lib/utils'; + +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +export { Skeleton }; diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..2a92100 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import * as SwitchPrimitive from '@radix-ui/react-switch'; + +import { cn } from '@/lib/utils'; + +function Switch({ + className, + size = 'default', + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default'; +}) { + return ( + + + + ); +} + +export { Switch }; From 7e21a5a3f55bd0a28e59b12ad242fcabd1a2c9ce Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 17:36:17 +0000 Subject: [PATCH 03/26] feat: add server info panel showing SOVD capabilities - Display server connection status, name, and version - Show SOVD specification version - List supported features as badges - Show API entry points when available - Replace empty state with server info when connected but nothing selected - Add loading skeleton and error states --- src/components/EntityDetailPanel.tsx | 17 ++- src/components/ServerInfoPanel.tsx | 208 +++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 src/components/ServerInfoPanel.tsx diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 5631a3f..ace7d80 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -29,6 +29,7 @@ import { FaultsPanel } from '@/components/FaultsPanel'; import { AreasPanel } from '@/components/AreasPanel'; import { AppsPanel } from '@/components/AppsPanel'; import { FunctionsPanel } from '@/components/FunctionsPanel'; +import { ServerInfoPanel } from '@/components/ServerInfoPanel'; import { useAppStore, type AppState } from '@/lib/store'; import type { ComponentTopic, Parameter } from '@/lib/types'; @@ -367,11 +368,13 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { ); } - // No selection + // No selection - show server info if (!selectedPath) { return ( -
- +
+
+ +
); } @@ -651,10 +654,12 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { ); } - // Fallback - no data loaded yet + // Fallback - show server info while loading return ( -
- +
+
+ +
); } diff --git a/src/components/ServerInfoPanel.tsx b/src/components/ServerInfoPanel.tsx new file mode 100644 index 0000000..6fe42b3 --- /dev/null +++ b/src/components/ServerInfoPanel.tsx @@ -0,0 +1,208 @@ +import { useState, useEffect } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Server, Info, CheckCircle2, ExternalLink } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAppStore } from '@/lib/store'; +import type { ServerCapabilities, VersionInfo } from '@/lib/types'; + +/** + * Server Info Panel - displays SOVD server capabilities and version info + * + * Shows: + * - SOVD specification version + * - Server implementation name/version + * - Supported features list + * - Available entry points (API collections) + */ +export function ServerInfoPanel() { + const [capabilities, setCapabilities] = useState(null); + const [versionInfo, setVersionInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const { client, isConnected, serverUrl } = useAppStore( + useShallow((state) => ({ + client: state.client, + isConnected: state.isConnected, + serverUrl: state.serverUrl, + })) + ); + + useEffect(() => { + const loadServerInfo = async () => { + if (!client || !isConnected) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const [caps, version] = await Promise.all([ + client.getServerCapabilities().catch(() => null), + client.getVersionInfo().catch(() => null), + ]); + + setCapabilities(caps); + setVersionInfo(version); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load server info'); + } finally { + setIsLoading(false); + } + }; + + loadServerInfo(); + }, [client, isConnected]); + + if (!isConnected) { + return ( + + +
+ +

Connect to a server to view its information.

+
+
+
+ ); + } + + if (isLoading) { + return ( + + +
+ +
+ + +
+
+
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ ); + } + + if (error) { + return ( + + +
+ +

Could not load server information.

+

{error}

+
+
+
+ ); + } + + return ( +
+ {/* Server Overview */} + + +
+
+ +
+
+ {capabilities?.server_name || 'SOVD Server'} + + + + Connected + + + {serverUrl} + +
+
+
+ +
+
+
SOVD Version
+

+ {capabilities?.sovd_version || versionInfo?.sovd_version || 'Unknown'} +

+
+ {capabilities?.server_version && ( +
+
Server Version
+

{capabilities.server_version}

+
+ )} + {versionInfo?.implementation_version && ( +
+
Implementation
+

{versionInfo.implementation_version}

+
+ )} +
+
+
+ + {/* Supported Features */} + {capabilities?.supported_features && capabilities.supported_features.length > 0 && ( + + + + + Supported Features + + Capabilities available on this server + + +
+ {capabilities.supported_features.map((feature) => ( + + {feature} + + ))} +
+
+
+ )} + + {/* Entry Points / Collections */} + {capabilities?.entry_points && Object.keys(capabilities.entry_points).length > 0 && ( + + + + + API Entry Points + + Available resource collections + + +
+ {Object.entries(capabilities.entry_points).map(([name, url]) => ( +
+ {name} + + {url} + +
+ ))} +
+
+
+ )} +
+ ); +} From 857748f128d8c312d8e8eb3ed6368a7f278cf868 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 17:40:13 +0000 Subject: [PATCH 04/26] feat: add Ctrl+K keyboard shortcut for entity search - Add SearchCommand component with command palette UI - Support quick navigation to any entity by name, ID, or path - Group search results by entity type (Areas, Components, Apps, Functions) - Add useSearchShortcut hook for Ctrl+K / Cmd+K binding - Update sidebar placeholder with keyboard hint --- package-lock.json | 17 +++ package.json | 1 + src/App.tsx | 10 +- src/components/EntityTreeSidebar.tsx | 2 +- src/components/SearchCommand.tsx | 176 +++++++++++++++++++++++++++ src/components/ui/command.tsx | 137 +++++++++++++++++++++ src/hooks/useSearchShortcut.ts | 18 +++ 7 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 src/components/SearchCommand.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/hooks/useSearchShortcut.ts diff --git a/package-lock.json b/package-lock.json index 5783ec6..fd1debe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -3646,6 +3647,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/package.json b/package.json index f55f02f..8ca4e50 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/src/App.tsx b/src/App.tsx index e0e85f9..6d25801 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useShallow } from 'zustand/shallow'; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { EntityTreeSidebar } from '@/components/EntityTreeSidebar'; import { EntityDetailPanel } from '@/components/EntityDetailPanel'; import { ServerConnectionDialog } from '@/components/ServerConnectionDialog'; +import { SearchCommand } from '@/components/SearchCommand'; +import { useSearchShortcut } from '@/hooks/useSearchShortcut'; import { useAppStore } from '@/lib/store'; function App() { @@ -18,8 +20,13 @@ function App() { ); const [showConnectionDialog, setShowConnectionDialog] = useState(false); + const [showSearch, setShowSearch] = useState(false); const autoConnectAttempted = useRef(false); + // Keyboard shortcut: Ctrl+K / Cmd+K to open search + const openSearch = useCallback(() => setShowSearch(true), []); + useSearchShortcut(openSearch); + // Auto-connect on mount if we have a stored URL useEffect(() => { if (serverUrl && !isConnected && !autoConnectAttempted.current) { @@ -39,6 +46,7 @@ function App() { setShowConnectionDialog(true)} /> setShowConnectionDialog(true)} /> + setSearchQuery(e.target.value)} className="pl-8 pr-8 h-8 text-sm" diff --git a/src/components/SearchCommand.tsx b/src/components/SearchCommand.tsx new file mode 100644 index 0000000..7152226 --- /dev/null +++ b/src/components/SearchCommand.tsx @@ -0,0 +1,176 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Layers, Box, Cpu, GitBranch, Search } from 'lucide-react'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { useAppStore } from '@/lib/store'; +import type { EntityTreeNode } from '@/lib/types'; + +/** + * Flatten tree nodes for search indexing + */ +function flattenTree(nodes: EntityTreeNode[]): EntityTreeNode[] { + const result: EntityTreeNode[] = []; + + for (const node of nodes) { + // Skip virtual folders (data, operations, configurations, faults) + if (node.type === 'virtual-folder') continue; + + result.push(node); + + if (node.children && node.children.length > 0) { + result.push(...flattenTree(node.children)); + } + } + + return result; +} + +/** + * Get icon for entity type + */ +function getEntityIcon(type: string) { + switch (type) { + case 'area': + return Layers; + case 'component': + return Box; + case 'app': + return Cpu; + case 'function': + return GitBranch; + default: + return Search; + } +} + +/** + * Get color class for entity type + */ +function getEntityColorClass(type: string): string { + switch (type) { + case 'area': + return 'text-cyan-500'; + case 'component': + return 'text-indigo-500'; + case 'app': + return 'text-emerald-500'; + case 'function': + return 'text-violet-500'; + default: + return 'text-muted-foreground'; + } +} + +interface SearchCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Search Command Palette - Ctrl+K to open + * + * Allows quick navigation to any entity in the tree. + */ +export function SearchCommand({ open, onOpenChange }: SearchCommandProps) { + const [search, setSearch] = useState(''); + + const { rootEntities, selectEntity, isConnected } = useAppStore( + useShallow((state) => ({ + rootEntities: state.rootEntities, + selectEntity: state.selectEntity, + isConnected: state.isConnected, + })) + ); + + // Flatten tree for searching + const allEntities = flattenTree(rootEntities); + + // Filter entities based on search + const filteredEntities = search + ? allEntities.filter( + (entity) => + entity.name.toLowerCase().includes(search.toLowerCase()) || + entity.id.toLowerCase().includes(search.toLowerCase()) || + entity.path.toLowerCase().includes(search.toLowerCase()) + ) + : allEntities.slice(0, 20); // Show first 20 when no search + + const handleSelect = useCallback( + (path: string) => { + selectEntity(path); + onOpenChange(false); + setSearch(''); + }, + [selectEntity, onOpenChange] + ); + + // Clear search when closing + useEffect(() => { + if (!open) { + setSearch(''); + } + }, [open]); + + if (!isConnected) { + return ( + + + + Connect to a server first. + + + ); + } + + return ( + + + + No entities found. + + {/* Group by entity type */} + {['area', 'component', 'app', 'function'].map((type) => { + const typeEntities = filteredEntities.filter((e) => e.type === type); + if (typeEntities.length === 0) return null; + + const label = type.charAt(0).toUpperCase() + type.slice(1) + 's'; + + return ( + + {typeEntities.map((entity) => { + const EntityIcon = getEntityIcon(entity.type); + return ( + handleSelect(entity.path)} + className="cursor-pointer" + > + +
+ {entity.name} + + {entity.path} + +
+
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..5d2eea5 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,137 @@ +'use client'; + +import * as React from 'react'; +import { Command as CommandPrimitive } from 'cmdk'; +import { SearchIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; + +function Command({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = 'Command Palette', + description = 'Search for a command to run...', + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} + +function CommandInput({ className, ...props }: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ ...props }: React.ComponentProps) { + return ; +} + +function CommandGroup({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/hooks/useSearchShortcut.ts b/src/hooks/useSearchShortcut.ts new file mode 100644 index 0000000..2440184 --- /dev/null +++ b/src/hooks/useSearchShortcut.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +/** + * Hook for keyboard shortcut (Ctrl+K / Cmd+K) to open search + */ +export function useSearchShortcut(onOpen: () => void) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + onOpen(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onOpen]); +} From dab1abe0787f75a6d8dad15504a2b1378a570f1b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 17:47:10 +0000 Subject: [PATCH 05/26] feat: add FaultsDashboard for system-wide fault monitoring - Create FaultsDashboard component with polling-based updates - Add severity and status filtering with dropdown menus - Support grouping faults by entity (component/app) - Add FaultsCountBadge for sidebar showing critical/error count - Add quick access button in sidebar footer - Support auto-refresh with toggle control - Clear fault actions with loading state --- src/App.tsx | 28 +- src/components/EntityDetailPanel.tsx | 25 +- src/components/EntityTreeSidebar.tsx | 22 +- src/components/FaultsDashboard.tsx | 710 +++++++++++++++++++++++++++ 4 files changed, 778 insertions(+), 7 deletions(-) create mode 100644 src/components/FaultsDashboard.tsx diff --git a/src/App.tsx b/src/App.tsx index 6d25801..d4c1a7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,24 +9,39 @@ import { SearchCommand } from '@/components/SearchCommand'; import { useSearchShortcut } from '@/hooks/useSearchShortcut'; import { useAppStore } from '@/lib/store'; +type ViewMode = 'entity' | 'faults-dashboard'; + function App() { - const { isConnected, serverUrl, baseEndpoint, connect } = useAppStore( + const { isConnected, serverUrl, baseEndpoint, connect, clearSelection } = useAppStore( useShallow((state) => ({ isConnected: state.isConnected, serverUrl: state.serverUrl, baseEndpoint: state.baseEndpoint, connect: state.connect, + clearSelection: state.clearSelection, })) ); const [showConnectionDialog, setShowConnectionDialog] = useState(false); const [showSearch, setShowSearch] = useState(false); + const [viewMode, setViewMode] = useState('entity'); const autoConnectAttempted = useRef(false); // Keyboard shortcut: Ctrl+K / Cmd+K to open search const openSearch = useCallback(() => setShowSearch(true), []); useSearchShortcut(openSearch); + // Handle faults dashboard navigation + const handleFaultsDashboardClick = useCallback(() => { + clearSelection(); + setViewMode('faults-dashboard'); + }, [clearSelection]); + + // When entity is selected, switch back to entity view + const handleEntitySelect = useCallback(() => { + setViewMode('entity'); + }, []); + // Auto-connect on mount if we have a stored URL useEffect(() => { if (serverUrl && !isConnected && !autoConnectAttempted.current) { @@ -43,8 +58,15 @@ function App() { return (
- setShowConnectionDialog(true)} /> - setShowConnectionDialog(true)} /> + setShowConnectionDialog(true)} + onFaultsDashboardClick={handleFaultsDashboardClick} + /> + setShowConnectionDialog(true)} + viewMode={viewMode} + onEntitySelect={handleEntitySelect} + /> void; + viewMode?: 'entity' | 'faults-dashboard'; + onEntitySelect?: () => void; } -export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { +export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntitySelect }: EntityDetailPanelProps) { const [activeTab, setActiveTab] = useState('data'); const { @@ -353,6 +356,13 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { })) ); + // Notify parent when entity is selected + useEffect(() => { + if (selectedPath && onEntitySelect) { + onEntitySelect(); + } + }, [selectedPath, onEntitySelect]); + const handleCopyEntity = async () => { if (selectedEntity) { await navigator.clipboard.writeText(JSON.stringify(selectedEntity, null, 2)); @@ -368,6 +378,17 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { ); } + // Faults Dashboard view + if (viewMode === 'faults-dashboard' && !selectedPath) { + return ( +
+
+ +
+
+ ); + } + // No selection - show server info if (!selectedPath) { return ( diff --git a/src/components/EntityTreeSidebar.tsx b/src/components/EntityTreeSidebar.tsx index 99ebe14..ed7ddcc 100644 --- a/src/components/EntityTreeSidebar.tsx +++ b/src/components/EntityTreeSidebar.tsx @@ -1,17 +1,19 @@ import { useState, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Server, Settings, RefreshCw, Search, X } from 'lucide-react'; +import { Server, Settings, RefreshCw, Search, X, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { EntityTreeNode } from '@/components/EntityTreeNode'; import { EntityTreeSkeleton } from '@/components/EntityTreeSkeleton'; import { ThemeToggle } from '@/components/ThemeToggle'; import { EmptyState } from '@/components/EmptyState'; +import { FaultsCountBadge } from '@/components/FaultsDashboard'; import { useAppStore } from '@/lib/store'; import type { EntityTreeNode as EntityTreeNodeType } from '@/lib/types'; interface EntityTreeSidebarProps { onSettingsClick: () => void; + onFaultsDashboardClick?: () => void; } /** @@ -39,7 +41,7 @@ function filterTree(nodes: EntityTreeNodeType[], query: string): EntityTreeNodeT return result; } -export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) { +export function EntityTreeSidebar({ onSettingsClick, onFaultsDashboardClick }: EntityTreeSidebarProps) { const [searchQuery, setSearchQuery] = useState(''); const [isRefreshing, setIsRefreshing] = useState(false); @@ -168,6 +170,22 @@ export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) {
)}
+ + {/* Quick Actions - Faults Dashboard */} + {isConnected && ( +
+ +
+ )} ); } diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx new file mode 100644 index 0000000..0a3c5b5 --- /dev/null +++ b/src/components/FaultsDashboard.tsx @@ -0,0 +1,710 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { + AlertTriangle, + AlertCircle, + AlertOctagon, + Info, + CheckCircle, + RefreshCw, + Filter, + Trash2, + Loader2, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAppStore } from '@/lib/store'; +import type { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; + +/** + * Default polling interval in milliseconds + */ +const DEFAULT_POLL_INTERVAL = 5000; + +/** + * Get badge variant for fault severity + */ +function getSeverityBadgeVariant(severity: FaultSeverity): 'default' | 'secondary' | 'destructive' | 'outline' { + switch (severity) { + case 'critical': + case 'error': + return 'destructive'; + case 'warning': + return 'default'; + case 'info': + return 'secondary'; + default: + return 'outline'; + } +} + +/** + * Get icon for fault severity + */ +function getSeverityIcon(severity: FaultSeverity) { + switch (severity) { + case 'critical': + return ; + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + default: + return ; + } +} + +/** + * Get color class for severity + */ +function getSeverityColorClass(severity: FaultSeverity): string { + switch (severity) { + case 'critical': + return 'text-red-600 dark:text-red-400'; + case 'error': + return 'text-orange-600 dark:text-orange-400'; + case 'warning': + return 'text-yellow-600 dark:text-yellow-400'; + case 'info': + return 'text-blue-600 dark:text-blue-400'; + default: + return 'text-muted-foreground'; + } +} + +/** + * Format timestamp for display + */ +function formatTimestamp(timestamp: string): string { + try { + const date = new Date(timestamp); + return date.toLocaleString(); + } catch { + return timestamp; + } +} + +/** + * Single fault row component + */ +function FaultRow({ + fault, + onClear, + isClearing, +}: { + fault: Fault; + onClear: (code: string) => void; + isClearing: boolean; +}) { + const canClear = fault.status === 'active' || fault.status === 'pending'; + + return ( +
+ {/* Severity Icon */} +
+ {getSeverityIcon(fault.severity)} +
+ + {/* Fault details */} +
+
+ {fault.code} + + {fault.severity} + + + {fault.status} + +
+

{fault.message}

+
+ {formatTimestamp(fault.timestamp)} + + {fault.entity_type}: {fault.entity_id} + +
+
+ + {/* Clear button */} + {canClear && ( + + )} +
+ ); +} + +/** + * Group of faults by entity + */ +function FaultGroup({ + entityId, + entityType, + faults, + onClear, + clearingCodes, +}: { + entityId: string; + entityType: string; + faults: Fault[]; + onClear: (code: string) => void; + clearingCodes: Set; +}) { + const [isOpen, setIsOpen] = useState(true); + + const criticalCount = faults.filter((f) => f.severity === 'critical' || f.severity === 'error').length; + const warningCount = faults.filter((f) => f.severity === 'warning').length; + + return ( + + +
+ {isOpen ? : } + {entityId} + + {entityType} + +
+ {criticalCount > 0 && ( + + {criticalCount} + + )} + {warningCount > 0 && ( + + {warningCount} + + )} + + {faults.length} total + +
+ + + {faults.map((fault) => ( + + ))} + + + ); +} + +/** + * Loading skeleton for dashboard + */ +function DashboardSkeleton() { + return ( +
+
+ + + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + + +
+ + +
+ ))} +
+
+ ); +} + +/** + * Faults Dashboard - displays all faults across the system + * + * Features: + * - Real-time updates via polling (SSE support planned) + * - Filtering by severity and status + * - Grouping by entity + * - Clear fault actions + */ +export function FaultsDashboard() { + const [faults, setFaults] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); + const [clearingCodes, setClearingCodes] = useState>(new Set()); + const [error, setError] = useState(null); + + // Filters + const [severityFilters, setSeverityFilters] = useState>( + new Set(['critical', 'error', 'warning', 'info']) + ); + const [statusFilters, setStatusFilters] = useState>(new Set(['active', 'pending'])); + const [groupByEntity, setGroupByEntity] = useState(true); + + const { client, isConnected } = useAppStore( + useShallow((state) => ({ + client: state.client, + isConnected: state.isConnected, + })) + ); + + // Load faults + const loadFaults = useCallback( + async (showRefreshIndicator = false) => { + if (!client || !isConnected) return; + + if (showRefreshIndicator) { + setIsRefreshing(true); + } + setError(null); + + try { + const response = await client.listAllFaults(); + setFaults(response.items || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load faults'); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, + [client, isConnected] + ); + + // Initial load + useEffect(() => { + loadFaults(); + }, [loadFaults]); + + // Auto-refresh polling + useEffect(() => { + if (!autoRefresh || !isConnected) return; + + const interval = setInterval(() => { + loadFaults(false); + }, DEFAULT_POLL_INTERVAL); + + return () => clearInterval(interval); + }, [autoRefresh, isConnected, loadFaults]); + + // Clear fault handler + const handleClear = useCallback( + async (code: string) => { + if (!client) return; + + setClearingCodes((prev) => new Set([...prev, code])); + + try { + // Find the fault to get entity info + const fault = faults.find((f) => f.code === code); + if (fault) { + // Note: This assumes faults are from components. May need adjustment. + await client.clearFault('components', fault.entity_id, code); + } + // Reload faults after clearing + await loadFaults(true); + } catch { + // Error handled by toast in store + } finally { + setClearingCodes((prev) => { + const next = new Set(prev); + next.delete(code); + return next; + }); + } + }, + [client, faults, loadFaults] + ); + + // Filter faults + const filteredFaults = useMemo(() => { + return faults.filter((f) => severityFilters.has(f.severity) && statusFilters.has(f.status)); + }, [faults, severityFilters, statusFilters]); + + // Group faults by entity + const groupedFaults = useMemo(() => { + const groups = new Map(); + + for (const fault of filteredFaults) { + const key = fault.entity_id; + if (!groups.has(key)) { + groups.set(key, { entityType: fault.entity_type, faults: [] }); + } + groups.get(key)!.faults.push(fault); + } + + // Sort groups by number of critical/error faults + return Array.from(groups.entries()).sort((a, b) => { + const aCritical = a[1].faults.filter((f) => f.severity === 'critical' || f.severity === 'error').length; + const bCritical = b[1].faults.filter((f) => f.severity === 'critical' || f.severity === 'error').length; + return bCritical - aCritical; + }); + }, [filteredFaults]); + + // Count by severity + const counts = useMemo(() => { + return { + critical: faults.filter((f) => f.severity === 'critical').length, + error: faults.filter((f) => f.severity === 'error').length, + warning: faults.filter((f) => f.severity === 'warning').length, + info: faults.filter((f) => f.severity === 'info').length, + total: faults.length, + }; + }, [faults]); + + // Toggle severity filter + const toggleSeverity = (severity: FaultSeverity) => { + setSeverityFilters((prev) => { + const next = new Set(prev); + if (next.has(severity)) { + next.delete(severity); + } else { + next.add(severity); + } + return next; + }); + }; + + // Toggle status filter + const toggleStatus = (status: FaultStatus) => { + setStatusFilters((prev) => { + const next = new Set(prev); + if (next.has(status)) { + next.delete(status); + } else { + next.add(status); + } + return next; + }); + }; + + if (!isConnected) { + return ( + + +
+ +

Connect to a server to view faults.

+
+
+
+ ); + } + + if (isLoading) { + return ( +
+ + + + + Faults Dashboard + + + + + + +
+ ); + } + + return ( +
+ {/* Header Card */} + + +
+
+ + + Faults Dashboard + + + {counts.total === 0 + ? 'No faults detected' + : `${counts.total} fault${counts.total !== 1 ? 's' : ''} detected`} + +
+
+ {/* Auto-refresh toggle */} +
+ + +
+ {/* Manual refresh */} + +
+
+
+ + {/* Summary badges */} +
+ {counts.critical > 0 && ( + + + {counts.critical} Critical + + )} + {counts.error > 0 && ( + + + {counts.error} Error + + )} + {counts.warning > 0 && ( + + + {counts.warning} Warning + + )} + {counts.info > 0 && ( + + + {counts.info} Info + + )} + {counts.total === 0 && ( + + + All Clear + + )} +
+ + {/* Filters */} +
+ {/* Severity filter */} + + + + + + Filter by Severity + + toggleSeverity('critical')} + > + + Critical + + toggleSeverity('error')} + > + + Error + + toggleSeverity('warning')} + > + + Warning + + toggleSeverity('info')} + > + + Info + + + + + {/* Status filter */} + + + + + + Filter by Status + + toggleStatus('active')} + > + Active + + toggleStatus('pending')} + > + Pending + + toggleStatus('cleared')} + > + Cleared + + + + + {/* Group by toggle */} +
+ + +
+
+
+
+ + {/* Faults List */} + {error ? ( + + +
+ +

{error}

+ +
+
+
+ ) : filteredFaults.length === 0 ? ( + + +
+ +

No faults to display

+

+ {faults.length > 0 + ? 'Adjust filters to see more faults' + : 'System is operating normally'} +

+
+
+
+ ) : groupByEntity ? ( + + + {groupedFaults.map(([entityId, { entityType, faults: entityFaults }]) => ( + + ))} + + + ) : ( + + + {filteredFaults.map((fault) => ( + + ))} + + + )} +
+ ); +} + +/** + * Faults count badge for sidebar + */ +export function FaultsCountBadge() { + const [count, setCount] = useState(0); + + const { client, isConnected } = useAppStore( + useShallow((state) => ({ + client: state.client, + isConnected: state.isConnected, + })) + ); + + useEffect(() => { + const loadCount = async () => { + if (!client || !isConnected) { + setCount(0); + return; + } + + try { + const response = await client.listAllFaults(); + const activeCount = (response.items || []).filter( + (f) => f.status === 'active' && (f.severity === 'critical' || f.severity === 'error') + ).length; + setCount(activeCount); + } catch { + setCount(0); + } + }; + + loadCount(); + const interval = setInterval(loadCount, DEFAULT_POLL_INTERVAL); + return () => clearInterval(interval); + }, [client, isConnected]); + + if (count === 0) return null; + + return ( + + {count} + + ); +} From bb3601fb082e5218ab6c4ca5d2f4cd2b6556579e Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 17:48:47 +0000 Subject: [PATCH 06/26] feat: add ErrorBoundary component for graceful error handling - Create ErrorBoundary class component with retry option - Show error details in collapsible section - Wrap main panel with error boundary for isolated error handling - Add ErrorBoundaryWrapper for convenient functional component usage - Report errors via toast notification --- src/App.tsx | 59 +++++---- src/components/ErrorBoundary.tsx | 214 +++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 25 deletions(-) create mode 100644 src/components/ErrorBoundary.tsx diff --git a/src/App.tsx b/src/App.tsx index d4c1a7d..8b99395 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { EntityTreeSidebar } from '@/components/EntityTreeSidebar'; import { EntityDetailPanel } from '@/components/EntityDetailPanel'; import { ServerConnectionDialog } from '@/components/ServerConnectionDialog'; import { SearchCommand } from '@/components/SearchCommand'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; import { useSearchShortcut } from '@/hooks/useSearchShortcut'; import { useAppStore } from '@/lib/store'; @@ -57,31 +58,39 @@ function App() { }, []); return ( -
- setShowConnectionDialog(true)} - onFaultsDashboardClick={handleFaultsDashboardClick} - /> - setShowConnectionDialog(true)} - viewMode={viewMode} - onEntitySelect={handleEntitySelect} - /> - - - -
+ { + toast.error(`Application error: ${error.message}`); + }} + > +
+ setShowConnectionDialog(true)} + onFaultsDashboardClick={handleFaultsDashboardClick} + /> + + setShowConnectionDialog(true)} + viewMode={viewMode} + onEntitySelect={handleEntitySelect} + /> + + + + +
+
); } diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..507dae6 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,214 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertCircle, RefreshCw, Home, Bug } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + /** Called when error is caught */ + onError?: (error: Error, errorInfo: ErrorInfo) => void; + /** Called when user clicks retry */ + onRetry?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; + showDetails: boolean; +} + +/** + * Error Boundary component for catching React errors + * + * Features: + * - Catches render errors in child components + * - Provides retry functionality + * - Shows error details in collapsible section + * - Reports errors via onError callback + */ +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + this.setState({ errorInfo }); + + // Report error + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // Log to console in development + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + handleRetry = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }); + + if (this.props.onRetry) { + this.props.onRetry(); + } + }; + + handleReload = (): void => { + window.location.reload(); + }; + + toggleDetails = (): void => { + this.setState((prev) => ({ showDetails: !prev.showDetails })); + }; + + render(): ReactNode { + if (this.state.hasError) { + // Custom fallback provided + if (this.props.fallback) { + return this.props.fallback; + } + + // Default error UI + return ( +
+ + +
+
+ +
+
+ Something went wrong + + An unexpected error occurred while rendering this component. + +
+
+
+ + {/* Error message */} +
+

+ {this.state.error?.message || 'Unknown error'} +

+
+ + {/* Error details (collapsible) */} + + + + + +
+

Error Stack:

+
+                                            {this.state.error?.stack || 'No stack trace available'}
+                                        
+ {this.state.errorInfo?.componentStack && ( + <> +

Component Stack:

+
+                                                    {this.state.errorInfo.componentStack}
+                                                
+ + )} +
+
+
+
+ + + + +
+
+ ); + } + + return this.props.children; + } +} + +/** + * Hook-based error boundary wrapper for functional components + * Useful for wrapping specific sections of the UI + */ +interface ErrorBoundaryWrapperProps { + children: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; + onRetry?: () => void; + title?: string; + description?: string; +} + +export function ErrorBoundaryWrapper({ + children, + onError, + onRetry, + title, + description, +}: ErrorBoundaryWrapperProps): JSX.Element { + return ( + + ) : undefined + } + > + {children} + + ); +} + +/** + * Simple error fallback component + */ +interface ErrorFallbackProps { + title?: string; + description?: string; + onRetry?: () => void; +} + +function ErrorFallback({ title, description, onRetry }: ErrorFallbackProps): JSX.Element { + return ( +
+ +

{title || 'Error loading content'}

+ {description &&

{description}

} + {onRetry && ( + + )} +
+ ); +} From aa048410d44596a040b37a19615d9dee490b22f0 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 17:51:31 +0000 Subject: [PATCH 07/26] feat(a11y): add ARIA labels to tree navigation - Add role='tree' and aria-label to EntityTreeSidebar container - Add role='group' to entity list container - Add role='treeitem', aria-expanded, aria-selected to EntityTreeNode - Add keyboard navigation (Enter, Space, ArrowLeft/Right) - Add aria-label to search input and clear button - Add focus ring styling for keyboard users --- src/components/EntityTreeNode.tsx | 19 +++++++++++++++++++ src/components/EntityTreeSidebar.tsx | 11 ++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index f5874a9..03ce53a 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -207,15 +207,34 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelect(e as unknown as React.MouseEvent); + } else if (e.key === 'ArrowRight' && hasChildren && !isExpanded) { + e.preventDefault(); + handleToggle(); + } else if (e.key === 'ArrowLeft' && hasChildren && isExpanded) { + e.preventDefault(); + handleToggle(); + } + }} > e.stopPropagation()}> @@ -151,7 +156,7 @@ export function EntityTreeSidebar({ onSettingsClick, onFaultsDashboardClick }: E )} {/* Tree content */} -
+
{!isConnected ? ( ) : isLoading ? ( @@ -163,7 +168,7 @@ export function EntityTreeSidebar({ onSettingsClick, onFaultsDashboardClick }: E {searchQuery &&

Try a different search term

}
) : ( -
+
{filteredEntities.map((entity) => ( ))} From 2bdaef941f4dd0d57424101ba9e10e741ad30a21 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 17:53:06 +0000 Subject: [PATCH 08/26] feat(responsive): add mobile-first responsive layout - Add collapsible sidebar with hamburger menu toggle on mobile (<768px) - Add overlay backdrop when sidebar is open on mobile - Auto-close sidebar when selecting entity or navigating on mobile - Add responsive padding to sidebar header for menu button - Use Tailwind md: breakpoint for tablet/desktop layout --- src/App.tsx | 77 +++++++++++++++++++++++----- src/components/EntityTreeSidebar.tsx | 4 +- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8b99395..7243858 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useShallow } from 'zustand/shallow'; import { ToastContainer, toast } from 'react-toastify'; +import { Menu, X } from 'lucide-react'; import 'react-toastify/dist/ReactToastify.css'; +import { Button } from '@/components/ui/button'; import { EntityTreeSidebar } from '@/components/EntityTreeSidebar'; import { EntityDetailPanel } from '@/components/EntityDetailPanel'; import { ServerConnectionDialog } from '@/components/ServerConnectionDialog'; @@ -13,19 +15,21 @@ import { useAppStore } from '@/lib/store'; type ViewMode = 'entity' | 'faults-dashboard'; function App() { - const { isConnected, serverUrl, baseEndpoint, connect, clearSelection } = useAppStore( + const { isConnected, serverUrl, baseEndpoint, connect, clearSelection, selectedPath } = useAppStore( useShallow((state) => ({ isConnected: state.isConnected, serverUrl: state.serverUrl, baseEndpoint: state.baseEndpoint, connect: state.connect, clearSelection: state.clearSelection, + selectedPath: state.selectedPath, })) ); const [showConnectionDialog, setShowConnectionDialog] = useState(false); const [showSearch, setShowSearch] = useState(false); const [viewMode, setViewMode] = useState('entity'); + const [sidebarOpen, setSidebarOpen] = useState(true); const autoConnectAttempted = useRef(false); // Keyboard shortcut: Ctrl+K / Cmd+K to open search @@ -36,13 +40,28 @@ function App() { const handleFaultsDashboardClick = useCallback(() => { clearSelection(); setViewMode('faults-dashboard'); + // Close sidebar on mobile when navigating + if (window.innerWidth < 768) { + setSidebarOpen(false); + } }, [clearSelection]); // When entity is selected, switch back to entity view const handleEntitySelect = useCallback(() => { setViewMode('entity'); + // Close sidebar on mobile when selecting entity + if (window.innerWidth < 768) { + setSidebarOpen(false); + } }, []); + // Close sidebar on mobile when entity is selected from search + useEffect(() => { + if (selectedPath && window.innerWidth < 768) { + setSidebarOpen(false); + } + }, [selectedPath]); + // Auto-connect on mount if we have a stored URL useEffect(() => { if (serverUrl && !isConnected && !autoConnectAttempted.current) { @@ -63,18 +82,52 @@ function App() { toast.error(`Application error: ${error.message}`); }} > -
- setShowConnectionDialog(true)} - onFaultsDashboardClick={handleFaultsDashboardClick} - /> - - setShowConnectionDialog(true)} - viewMode={viewMode} - onEntitySelect={handleEntitySelect} +
+ {/* Mobile menu toggle */} + + + {/* Sidebar with responsive behavior */} +
+ setShowConnectionDialog(true)} + onFaultsDashboardClick={handleFaultsDashboardClick} + /> +
+ + {/* Overlay for mobile when sidebar is open */} + {sidebarOpen && ( +
setSidebarOpen(false)} + aria-hidden="true" /> - + )} + + {/* Main content */} +
+ + setShowConnectionDialog(true)} + viewMode={viewMode} + onEntitySelect={handleEntitySelect} + /> + +
+ - {/* Header */} -
+ {/* Header - with top padding on mobile for menu button */} +

Entity Tree

From 778694ef8c13a12b818d22fbef01c81f95f7e9f6 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 19:37:46 +0000 Subject: [PATCH 09/26] refactor: show entity resources in detail panel instead of tree subfolders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove resources folder from tree structure completely - Area → Component → App hierarchy is now flat (no virtual folders) - Resources (data, operations, configurations, faults) displayed in tabbed panels - AreasPanel: Overview + Components tabs (tip explains resources are at component level) - ComponentPanel: Data, Operations, Config, Faults tabs - AppsPanel: Overview, Data, Operations, Config, Faults tabs - Simplified EntityTreeNode icon/color functions - Fixed TypeScript JSX.Element namespace errors --- src/components/AreasPanel.tsx | 169 +++++++++++----- src/components/EntityDetailPanel.tsx | 63 +++++- src/components/EntityTreeNode.tsx | 63 ++---- src/components/ErrorBoundary.tsx | 4 +- src/lib/sovd-api.ts | 60 ++++++ src/lib/store.ts | 286 +++++++++++++-------------- src/lib/types.ts | 22 ++- 7 files changed, 403 insertions(+), 264 deletions(-) diff --git a/src/components/AreasPanel.tsx b/src/components/AreasPanel.tsx index 61b4cd0..1f9b4ce 100644 --- a/src/components/AreasPanel.tsx +++ b/src/components/AreasPanel.tsx @@ -1,9 +1,23 @@ +import { useState } from 'react'; import { useShallow } from 'zustand/shallow'; import { Layers, Box, ChevronRight, MapPin } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; +type AreaTab = 'overview' | 'components'; + +interface TabConfig { + id: AreaTab; + label: string; + icon: typeof Layers; +} + +const AREA_TABS: TabConfig[] = [ + { id: 'overview', label: 'Overview', icon: Layers }, + { id: 'components', label: 'Components', icon: Box }, +]; + interface AreasPanelProps { areaId: string; areaName?: string; @@ -16,8 +30,13 @@ interface AreasPanelProps { * Areas are namespace groupings in SOVD. They can have: * - Subareas (child areas) * - Related components (components in this area) + * + * Note: Areas don't have direct data/operations/configurations/faults. + * Those resources belong to components and apps within the area. */ export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) { + const [activeTab, setActiveTab] = useState('overview'); + const { rootEntities, selectEntity, expandedPaths, toggleExpanded } = useAppStore( useShallow((state) => ({ rootEntities: state.rootEntities, @@ -30,6 +49,7 @@ export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) { // Find the area node in the tree to get its children (components) const areaNode = rootEntities.find((n) => n.id === areaId || n.path === path); const components = areaNode?.children?.filter((c) => c.type === 'component') || []; + const subareas = areaNode?.children?.filter((c) => c.type === 'subarea') || []; const handleComponentClick = (componentPath: string) => { selectEntity(componentPath); @@ -41,7 +61,7 @@ export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) { return (
- {/* Area Overview */} + {/* Area Header */}
@@ -60,28 +80,80 @@ export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) {
- -
-
-
- - Namespace + + {/* Tab Navigation */} +
+
+ {AREA_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + const count = tab.id === 'components' ? components.length : 0; + + return ( + + ); + })} +
+
+ + + {/* Tab Content */} + {activeTab === 'overview' && ( + + +
+
+
+ + Namespace +
+

/{areaId}

-

/{areaId}

+ + {subareas.length > 0 && ( +
+ +
{subareas.length}
+
Subareas
+
+ )}
-
-
- - Components -
-

{components.length}

+ + {/* Tip about resources */} +
+

+ Tip: Areas contain components. To view data, operations, + configurations, or faults, select a component or app from this area. +

-
-
-
+ + + )} - {/* Related Components */} - {components.length > 0 && ( + {activeTab === 'components' && (
@@ -94,39 +166,36 @@ export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) { -
- {components.map((component) => ( -
handleComponentClick(component.path)} - > -
- -
-
-
{component.name}
-
- {component.id} + {components.length === 0 ? ( +
+ +

No components found in this area.

+

+ Components will appear here when ROS 2 nodes are discovered. +

+
+ ) : ( +
+ {components.map((component) => ( +
handleComponentClick(component.path)} + > +
+
+
+
{component.name}
+
+ {component.id} +
+
+
- -
- ))} -
- - - )} - - {/* Empty state when no components */} - {components.length === 0 && ( - - -
- -

No components found in this area.

-

Components will appear here when ROS 2 nodes are discovered.

-
+ ))} +
+ )} )} diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 41db84c..aeb7580 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -15,6 +15,7 @@ import { Cpu, GitBranch, Home, + AlertTriangle, } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -34,7 +35,7 @@ import { FaultsDashboard } from '@/components/FaultsDashboard'; import { useAppStore, type AppState } from '@/lib/store'; import type { ComponentTopic, Parameter } from '@/lib/types'; -type ComponentTab = 'data' | 'operations' | 'configurations'; +type ComponentTab = 'data' | 'operations' | 'configurations' | 'faults'; interface TabConfig { id: ComponentTab; @@ -46,7 +47,8 @@ interface TabConfig { const COMPONENT_TABS: TabConfig[] = [ { id: 'data', label: 'Data', icon: Database, description: 'Topics & messages' }, { id: 'operations', label: 'Operations', icon: Zap, description: 'Services & actions' }, - { id: 'configurations', label: 'Configurations', icon: Settings, description: 'Parameters' }, + { id: 'configurations', label: 'Config', icon: Settings, description: 'Parameters' }, + { id: 'faults', label: 'Faults', icon: AlertTriangle, description: 'Diagnostic trouble codes' }, ]; /** @@ -86,6 +88,8 @@ function ComponentTabContent({ return ; case 'configurations': return ; + case 'faults': + return ; default: return null; } @@ -299,7 +303,7 @@ function ParameterDetailCard({ entity, componentId }: ParameterDetailCardProps) * Virtual folder content - redirect to appropriate panel */ interface VirtualFolderContentProps { - folderType: 'data' | 'operations' | 'configurations' | 'faults'; + folderType: 'resources' | 'data' | 'operations' | 'configurations' | 'faults' | 'subareas' | 'subcomponents'; componentId: string; basePath: string; entityType?: 'components' | 'apps'; @@ -312,6 +316,33 @@ function VirtualFolderContent({ entityType = 'components', }: VirtualFolderContentProps) { switch (folderType) { + case 'resources': + return ( + + +
+

Resources for {componentId}

+

+ Expand this folder to view data, operations, configurations, and faults. +

+
+
+
+ ); + case 'subareas': + case 'subcomponents': + return ( + + +
+

+ {folderType === 'subareas' ? 'Subareas' : 'Subcomponents'} for {componentId} +

+

Expand this folder to view child {folderType}.

+
+
+
+ ); case 'data': return ; case 'operations': @@ -644,18 +675,36 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // Virtual folder selected - show appropriate panel (() => { // Extract base path (component path) from folder path - // e.g., /root/route_server/data -> /root/route_server + // e.g., /root/route_server/resources/data -> /root/route_server const folderPathParts = selectedPath.split('/'); - folderPathParts.pop(); // Remove folder name (data/operations/configurations/faults) + // Remove folder names (e.g., data/operations/configurations/faults and resources) + while ( + folderPathParts.length > 0 && + ['data', 'operations', 'configurations', 'faults', 'resources'].includes( + folderPathParts[folderPathParts.length - 1] || '' + ) + ) { + folderPathParts.pop(); + } const basePath = folderPathParts.join('/'); // Determine entity type from folder data const entityType = selectedEntity.entityType === 'app' ? 'apps' : 'components'; + // Use entityId (new) or componentId (legacy) for backward compatibility + const componentId = + (selectedEntity.entityId as string) || (selectedEntity.componentId as string); return ( diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index 03ce53a..d82a23d 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -11,14 +11,11 @@ import { MessageSquare, ArrowUp, ArrowDown, - Database, Zap, Clock, - Settings, Sliders, AlertTriangle, Cpu, - Users, Layers, GitBranch, Package, @@ -26,8 +23,7 @@ import { import { cn } from '@/lib/utils'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useAppStore } from '@/lib/store'; -import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData, VirtualFolderData, Parameter } from '@/lib/types'; -import { isVirtualFolderData } from '@/lib/types'; +import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData, Parameter } from '@/lib/types'; interface EntityTreeNodeProps { node: EntityTreeNodeType; @@ -35,44 +31,24 @@ interface EntityTreeNodeProps { } /** - * Get icon for entity type with visual distinction between entities and resources + * Get icon for entity type * * Entity types (structural): * - Area: Layers (namespace grouping) + * - Subarea: Layers (nested namespace) * - Component: Box (logical grouping) + * - Subcomponent: Box (nested component) * - App: Cpu (ROS 2 node) * - Function: GitBranch (capability grouping) - * - * Resource types (data collections): - * - Data: Database - * - Operations: Zap - * - Configurations: Settings - * - Faults: AlertTriangle - * - Apps folder: Users */ -function getEntityIcon(type: string, data?: unknown, isExpanded?: boolean) { - // Check for virtual folder types (resource collections) - if (isVirtualFolderData(data)) { - const folderData = data as VirtualFolderData; - switch (folderData.folderType) { - case 'data': - return Database; - case 'operations': - return Zap; - case 'configurations': - return Settings; - case 'faults': - return AlertTriangle; - case 'apps': - return Users; - } - } - +function getEntityIcon(type: string, _data?: unknown, isExpanded?: boolean) { switch (type.toLowerCase()) { // Entity types case 'area': + case 'subarea': return Layers; case 'component': + case 'subcomponent': case 'ecu': return Box; case 'app': @@ -106,32 +82,19 @@ function getEntityIcon(type: string, data?: unknown, isExpanded?: boolean) { /** * Get color class for entity type */ -function getEntityColor(type: string, data?: unknown, isSelected?: boolean): string { +function getEntityColor(type: string, _data?: unknown, isSelected?: boolean): string { if (isSelected) return 'text-primary'; - // Check for virtual folder types (resource collections) - if (isVirtualFolderData(data)) { - const folderData = data as VirtualFolderData; - switch (folderData.folderType) { - case 'data': - return 'text-blue-500'; - case 'operations': - return 'text-amber-500'; - case 'configurations': - return 'text-purple-500'; - case 'faults': - return 'text-red-500'; - case 'apps': - return 'text-green-500'; - } - } - switch (type.toLowerCase()) { case 'area': return 'text-cyan-500'; + case 'subarea': + return 'text-cyan-400'; case 'component': case 'ecu': return 'text-indigo-500'; + case 'subcomponent': + return 'text-indigo-400'; case 'app': return 'text-emerald-500'; case 'function': @@ -186,7 +149,7 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { const topicData = isTopicNodeData(node.data) ? node.data : null; const parameterData = isParameterData(node.data) ? node.data : null; - // Load children when expanded and no children loaded yet + // Load children when expanded useEffect(() => { if (isExpanded && !node.children && !isLoading && hasChildren) { loadChildren(node.path); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 507dae6..646c346 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -172,7 +172,7 @@ export function ErrorBoundaryWrapper({ onRetry, title, description, -}: ErrorBoundaryWrapperProps): JSX.Element { +}: ErrorBoundaryWrapperProps): React.JSX.Element { return ( void; } -function ErrorFallback({ title, description, onRetry }: ErrorFallbackProps): JSX.Element { +function ErrorFallback({ title, description, onRetry }: ErrorFallbackProps): React.JSX.Element { return (
diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index 0bd36bc..50ddd6c 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -1484,6 +1484,66 @@ export class SovdApiClient { 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, + })); + } } /** diff --git a/src/lib/store.ts b/src/lib/store.ts index 0dbf7d4..e18bb5d 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -113,133 +113,32 @@ export interface AppState { /** * Convert SovdEntity to EntityTreeNode + * + * Structure - flat hierarchy with type tags: + * - Area: subareas and components loaded as direct children on expand + * - Subarea: same as Area + * - Component: subcomponents and apps loaded as direct children on expand + * - Subcomponent: same as Component + * - App: leaf node (no children in tree) + * + * Resources (data, operations, configurations, faults) are shown in the detail panel, + * not as tree nodes. */ function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode { const path = parentPath ? `${parentPath}/${entity.id}` : `/${entity.id}`; + const entityType = entity.type.toLowerCase(); - // If this is a component, create virtual subfolders: data/, operations/, configurations/, faults/, apps/ - let children: EntityTreeNode[] | undefined; - if (entity.type === 'component') { - // Create virtual subfolder nodes for component - children = [ - { - id: 'data', - name: 'data', - type: 'folder', - href: `${path}/data`, - path: `${path}/data`, - hasChildren: true, // Topics will be loaded here - isLoading: false, - isExpanded: false, - data: { - folderType: 'data', - componentId: entity.id, - entityType: 'component', - topicsInfo: entity.topicsInfo, - }, - }, - { - id: 'operations', - name: 'operations', - type: 'folder', - href: `${path}/operations`, - path: `${path}/operations`, - hasChildren: true, // Services/actions loaded on demand - isLoading: false, - isExpanded: false, - data: { folderType: 'operations', componentId: entity.id, entityType: 'component' }, - }, - { - id: 'configurations', - name: 'configurations', - type: 'folder', - href: `${path}/configurations`, - path: `${path}/configurations`, - hasChildren: true, // Parameters loaded on demand - isLoading: false, - isExpanded: false, - data: { folderType: 'configurations', componentId: entity.id, entityType: 'component' }, - }, - { - id: 'faults', - name: 'faults', - type: 'folder', - href: `${path}/faults`, - path: `${path}/faults`, - hasChildren: true, // Faults loaded on demand - isLoading: false, - isExpanded: false, - data: { folderType: 'faults', componentId: entity.id, entityType: 'component' }, - }, - { - id: 'apps', - name: 'apps', - type: 'folder', - href: `${path}/apps`, - path: `${path}/apps`, - hasChildren: true, // Apps (ROS 2 nodes) loaded on demand - isLoading: false, - isExpanded: false, - data: { folderType: 'apps', componentId: entity.id, entityType: 'component' }, - }, - ]; - } - // If this is an app, create virtual subfolders: data/, operations/, configurations/, faults/ - else if (entity.type === 'app') { - children = [ - { - id: 'data', - name: 'data', - type: 'folder', - href: `${path}/data`, - path: `${path}/data`, - hasChildren: true, - isLoading: false, - isExpanded: false, - data: { folderType: 'data', componentId: entity.id, entityType: 'app' }, - }, - { - id: 'operations', - name: 'operations', - type: 'folder', - href: `${path}/operations`, - path: `${path}/operations`, - hasChildren: true, - isLoading: false, - isExpanded: false, - data: { folderType: 'operations', componentId: entity.id, entityType: 'app' }, - }, - { - id: 'configurations', - name: 'configurations', - type: 'folder', - href: `${path}/configurations`, - path: `${path}/configurations`, - hasChildren: true, - isLoading: false, - isExpanded: false, - data: { folderType: 'configurations', componentId: entity.id, entityType: 'app' }, - }, - { - id: 'faults', - name: 'faults', - type: 'folder', - href: `${path}/faults`, - path: `${path}/faults`, - hasChildren: true, - isLoading: false, - isExpanded: false, - data: { folderType: 'faults', componentId: entity.id, entityType: 'app' }, - }, - ]; - } + // Areas and components have children (loaded on expand) + // Apps are leaf nodes - their resources are shown in the detail panel + const hasChildren = entityType !== 'app'; return { ...entity, path, - children, + children: undefined, // Children loaded lazily on expand isLoading: false, isExpanded: false, + hasChildren, }; } @@ -402,11 +301,18 @@ export const useAppStore = create()( // Check if we already have this data in the tree const node = findNode(rootEntities, path); - // Handle virtual folders (data/, operations/, configurations/) + // Handle virtual folders (resources/, data/, operations/, configurations/, etc.) if (node && isVirtualFolderData(node.data)) { const folderData = node.data as VirtualFolderData; - // Skip if already has loaded children + // Resources folder - children are already defined, just expand + if (folderData.folderType === 'resources') { + // Resources folder has static children (data/operations/configurations/faults) + // They are created in toTreeNode, nothing to load + return; + } + + // Skip if already has loaded children (for folders that need API calls) if (node.children && node.children.length > 0) { return; } @@ -416,13 +322,14 @@ export const useAppStore = create()( try { let children: EntityTreeNode[] = []; + // Map entityType to API collection name + const apiEntityType = folderData.entityType === 'app' ? 'apps' : 'components'; + if (folderData.folderType === 'data') { // Load topics for data folder - // For apps, use apps API; for components, use getEntities if (folderData.entityType === 'app') { - const topics = await client.getAppData(folderData.componentId); + const topics = await client.getAppData(folderData.entityId); children = topics.map((topic) => { - // Use uniqueKey if available (includes direction), otherwise just topic name const uniqueId = topic.uniqueKey || topic.topic; const cleanName = uniqueId.startsWith('/') ? uniqueId.slice(1) : uniqueId; const encodedName = encodeURIComponent(cleanName); @@ -443,7 +350,9 @@ export const useAppStore = create()( }; }); } else { - const topics = await client.getEntities(path.replace('/data', '')); + // For areas and components - use entity path without /resources/data + const entityPath = path.replace('/resources/data', ''); + const topics = await client.getEntities(entityPath); children = topics.map((topic: SovdEntity & { data?: ComponentTopic }) => { const cleanName = topic.name.startsWith('/') ? topic.name.slice(1) : topic.name; const encodedName = encodeURIComponent(cleanName); @@ -468,8 +377,7 @@ export const useAppStore = create()( } } else if (folderData.folderType === 'operations') { // Load operations for operations folder - const entityType = folderData.entityType === 'app' ? 'apps' : 'components'; - const ops = await client.listOperations(folderData.componentId, entityType); + const ops = await client.listOperations(folderData.entityId, apiEntityType); children = ops.map((op) => ({ id: op.name, name: op.name, @@ -483,7 +391,7 @@ export const useAppStore = create()( })); } else if (folderData.folderType === 'configurations') { // Load parameters for configurations folder - const config = await client.listConfigurations(folderData.componentId); + const config = await client.listConfigurations(folderData.entityId, apiEntityType); children = config.parameters.map((param) => ({ id: param.name, name: param.name, @@ -497,8 +405,7 @@ export const useAppStore = create()( })); } else if (folderData.folderType === 'faults') { // Load faults for this entity - const entityType = folderData.entityType === 'app' ? 'apps' : 'components'; - const faultsResponse = await client.listEntityFaults(entityType, folderData.componentId); + const faultsResponse = await client.listEntityFaults(apiEntityType, folderData.entityId); children = faultsResponse.items.map((fault) => ({ id: fault.code, name: `${fault.code}: ${fault.message}`, @@ -510,13 +417,9 @@ export const useAppStore = create()( isExpanded: false, data: fault, })); - } else if (folderData.folderType === 'apps') { - // Load apps belonging to this component using efficient server-side filtering - const componentApps = await client.listComponentApps(folderData.componentId); - children = componentApps.map((app) => - toTreeNode({ ...app, type: 'app', hasChildren: true }, path) - ); } + // Note: subareas and subcomponents are no longer loaded via folder + // They are loaded as direct children of area/component entities const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ ...n, @@ -531,8 +434,8 @@ export const useAppStore = create()( }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - // Don't show error for empty results - some components may not have operations/configs - if (!message.includes('not found')) { + // Don't show error for empty results - some entities may not have resources + if (!message.includes('not found') && !message.includes('404')) { toast.error(`Failed to load ${folderData.folderType}: ${message}`); } // Still update tree to show empty folder @@ -550,11 +453,85 @@ export const useAppStore = create()( return; } - // Regular node loading (areas, components) + // Regular node loading for entities (areas, subareas, components, subcomponents) + // These load their direct children: areas load subareas+components, components load subcomponents+apps + const nodeType = node?.type?.toLowerCase() || ''; + + // Check if this is a loadable entity type + const isAreaOrSubarea = nodeType === 'area' || nodeType === 'subarea'; + const isComponentOrSubcomponent = nodeType === 'component' || nodeType === 'subcomponent'; + + if (node && (isAreaOrSubarea || isComponentOrSubcomponent)) { + // Check if we already loaded children + if (node.children && node.children.length > 0) { + // Already loaded children, skip fetch + return; + } + + set({ loadingPaths: [...loadingPaths, path] }); + + try { + let loadedEntities: EntityTreeNode[] = []; + + 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(path), + 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) + ); + + loadedEntities = [...subareaNodes, ...componentNodes]; + } 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(() => []), + ]); + + // Apps - leaf nodes (no children in tree, resources shown in panel) + const appNodes = apps.map((app) => + toTreeNode({ ...app, type: 'app', hasChildren: false }, path) + ); + // Subcomponents with type 'subcomponent' + const subcompNodes = subcomponents.map((subcomp) => + toTreeNode({ ...subcomp, type: 'subcomponent', hasChildren: true }, path) + ); + + loadedEntities = [...subcompNodes, ...appNodes]; + } + + const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ + ...n, + children: loadedEntities, + hasChildren: loadedEntities.length > 0, + isLoading: false, + })); + + set({ + rootEntities: updatedTree, + loadingPaths: get().loadingPaths.filter((p) => p !== path), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + if (!message.includes('not found') && !message.includes('404')) { + toast.error(`Failed to load children for ${path}: ${message}`); + } + set({ loadingPaths: get().loadingPaths.filter((p) => p !== path) }); + } + return; + } + + // For non-entity nodes, use regular loading if (node && Array.isArray(node.children) && node.children.length > 0) { // Check if children have full data or just TopicNodeData - // TopicNodeData has isPublisher/isSubscriber but no 'type' field in data - // Full ComponentTopic has 'type' field (e.g., "sensor_msgs/msg/Temperature") const firstChild = node.children[0]; const hasFullData = firstChild?.data && typeof firstChild.data === 'object' && 'type' in firstChild.data; @@ -563,7 +540,6 @@ export const useAppStore = create()( // Already have full data, skip fetch return; } - // Have only TopicNodeData - need to fetch full data } // Mark as loading @@ -703,10 +679,10 @@ export const useAppStore = create()( return; } - // Optimization for Component - just select it and auto-expand - // Don't modify children - virtual folders (data/, operations/, configurations/, faults/, apps/) are already there - if (node && node.type === 'component') { - // Auto-expand component to show virtual folders + // Optimization for Component/Subcomponent - just select it and auto-expand + // Don't modify children - virtual folders (resources/, subcomponents/) are already there + if (node && (node.type === 'component' || node.type === 'subcomponent')) { + // Auto-expand to show virtual folders const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; set({ @@ -725,6 +701,24 @@ export const useAppStore = create()( return; } + // Handle Area/Subarea entity selection - auto-expand to show virtual folders + if (node && (node.type === 'area' || node.type === 'subarea')) { + const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; + + set({ + selectedPath: path, + expandedPaths: newExpandedPaths, + isLoadingDetails: false, + selectedEntity: { + id: node.id, + name: node.name, + type: node.type, + href: node.href, + }, + }); + return; + } + // Handle App entity selection - auto-expand to show virtual folders if (node && node.type === 'app') { const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; @@ -779,12 +773,12 @@ export const useAppStore = create()( isLoadingDetails: false, selectedEntity: { id: node.id, - name: `${folderData.componentId} / ${node.name}`, + name: `${folderData.entityId} / ${node.name}`, type: 'folder', href: node.href, // Pass folder info so detail panel knows what to show folderType: folderData.folderType, - componentId: folderData.componentId, + entityId: folderData.entityId, entityType: folderData.entityType, }, }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 54e4263..1c9699e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -123,16 +123,20 @@ export interface TopicNodeData { } /** - * Virtual folder data for component/app subfolders + * Virtual folder data for entity subfolders */ export interface VirtualFolderData { - /** Type of virtual folder: data, operations, configurations, faults, or apps */ - folderType: 'data' | 'operations' | 'configurations' | 'faults' | 'apps'; - /** Parent entity ID (component or app) */ - componentId: string; - /** Parent entity type */ - entityType: 'component' | 'app'; - /** Topics info (for data folder) */ + /** + * Type of virtual folder: + * - resources: container for data/operations/configurations/faults + * - data, operations, configurations, faults: resource collections inside resources/ + */ + folderType: 'resources' | 'data' | 'operations' | 'configurations' | 'faults'; + /** Parent entity ID */ + entityId: string; + /** Parent entity type (area, subarea, component, subcomponent, app) */ + entityType: 'area' | 'subarea' | 'component' | 'subcomponent' | 'app'; + /** Topics info (for data folder) - legacy support */ topicsInfo?: ComponentTopicsInfo; } @@ -140,7 +144,7 @@ export interface VirtualFolderData { * Type guard for VirtualFolderData */ export function isVirtualFolderData(data: unknown): data is VirtualFolderData { - return !!data && typeof data === 'object' && 'folderType' in data && 'componentId' in data; + return !!data && typeof data === 'object' && 'folderType' in data && 'entityId' in data; } /** From 0c0e5c2d60f6fd06c744345ac7300562d097ac2f Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 26 Jan 2026 19:53:06 +0000 Subject: [PATCH 10/26] feat: add SOVD Server as root entity in tree - Create server root node with areas as children - Update VersionInfo type to match actual /version-info API format (sovd_info array with base_uri, version, vendor_info) - Add ServerPanel component showing version information - Handle server selection in detail panel - Fix API path conversion: strip /server prefix from tree paths - Add server color styling in EntityTreeNode --- src/components/EntityDetailPanel.tsx | 108 ++++++++++++++++++++++++++- src/components/EntityTreeNode.tsx | 2 + src/components/ServerInfoPanel.tsx | 20 +++-- src/lib/store.ts | 99 ++++++++++++++++++++---- src/lib/types.ts | 30 ++++++-- 5 files changed, 233 insertions(+), 26 deletions(-) diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index aeb7580..e92eba0 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -16,6 +16,7 @@ import { GitBranch, Home, AlertTriangle, + Server, } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -33,7 +34,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 { ComponentTopic, Parameter, VersionInfo } from '@/lib/types'; type ComponentTab = 'data' | 'operations' | 'configurations' | 'faults'; @@ -51,6 +52,95 @@ const COMPONENT_TABS: TabConfig[] = [ { id: 'faults', label: 'Faults', icon: AlertTriangle, description: 'Diagnostic trouble codes' }, ]; +/** + * Server Panel - displays SOVD server version information + */ +interface ServerPanelProps { + serverName: string; + versionInfo?: VersionInfo; + serverUrl?: string; +} + +function ServerPanel({ serverName, versionInfo, serverUrl }: ServerPanelProps) { + const sovdInfo = versionInfo?.sovd_info?.[0]; + const vendorInfo = sovdInfo?.vendor_info; + + return ( +
+ {/* Server Header */} + + +
+
+ +
+
+ {serverName} + + + server + + {serverUrl && ( + <> + + {serverUrl} + + )} + +
+
+
+ +
+ {/* SOVD Version */} +
+
SOVD Version
+

{sovdInfo?.version || 'Unknown'}

+
+ + {/* Vendor Name */} + {vendorInfo?.name && ( +
+
Implementation
+

{vendorInfo.name}

+
+ )} + + {/* Vendor Version */} + {vendorInfo?.version && ( +
+
Version
+

{vendorInfo.version}

+
+ )} + + {/* Base URI */} + {sovdInfo?.base_uri && ( +
+
Base URI
+

{sovdInfo.base_uri}

+
+ )} +
+
+
+ + {/* Areas info */} + + +
+ +

Expand the server node in the tree to see areas.

+

+ Areas contain components and apps that provide data, operations, and configurations. +

+
+
+
+
+ ); +} + /** * Component tab content - renders based on active tab */ @@ -449,6 +539,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit const isArea = selectedEntity.type === 'area'; const isApp = selectedEntity.type === 'app'; const isFunction = selectedEntity.type === 'function'; + const isServer = selectedEntity.type === 'server'; const hasTopicData = isTopic && selectedEntity.topicData; // Prefer full topics array (with QoS, type info) over topicsInfo (names only) const hasTopicsArray = isComponent && selectedEntity.topics && selectedEntity.topics.length > 0; @@ -467,6 +558,8 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // Get icon for entity type const getEntityTypeIcon = () => { switch (selectedEntity.type) { + case 'server': + return ; case 'area': return ; case 'component': @@ -483,6 +576,8 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // Get background color for entity type const getEntityBgColor = () => { switch (selectedEntity.type) { + case 'server': + return 'bg-primary/10'; case 'area': return 'bg-cyan-100 dark:bg-cyan-900'; case 'component': @@ -530,6 +625,15 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit )} + {/* Server Entity View */} + {isServer && !hasError && ( + + )} + {/* Area Entity View */} {isArea && !hasError && ( @@ -561,7 +665,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit )} {/* Component/Generic Header */} - {!isArea && !isApp && !isFunction && ( + {!isServer && !isArea && !isApp && !isFunction && (
diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index d82a23d..7205241 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -99,6 +99,8 @@ function getEntityColor(type: string, _data?: unknown, isSelected?: boolean): st return 'text-emerald-500'; case 'function': return 'text-violet-500'; + case 'server': + return 'text-primary'; case 'topic': return 'text-blue-400'; case 'service': diff --git a/src/components/ServerInfoPanel.tsx b/src/components/ServerInfoPanel.tsx index 6fe42b3..f4a857c 100644 --- a/src/components/ServerInfoPanel.tsx +++ b/src/components/ServerInfoPanel.tsx @@ -118,7 +118,11 @@ export function ServerInfoPanel() {
- {capabilities?.server_name || 'SOVD Server'} + + {versionInfo?.sovd_info?.[0]?.vendor_info?.name || + capabilities?.server_name || + 'SOVD Server'} + @@ -135,19 +139,25 @@ export function ServerInfoPanel() {
SOVD Version

- {capabilities?.sovd_version || versionInfo?.sovd_version || 'Unknown'} + {versionInfo?.sovd_info?.[0]?.version || capabilities?.sovd_version || 'Unknown'}

+ {versionInfo?.sovd_info?.[0]?.vendor_info?.version && ( +
+
Implementation Version
+

{versionInfo.sovd_info[0].vendor_info.version}

+
+ )} {capabilities?.server_version && (
Server Version

{capabilities.server_version}

)} - {versionInfo?.implementation_version && ( + {versionInfo?.sovd_info?.[0]?.base_uri && (
-
Implementation
-

{versionInfo.implementation_version}

+
Base URI
+

{versionInfo.sovd_info[0].base_uri}

)}
diff --git a/src/lib/store.ts b/src/lib/store.ts index e18bb5d..a6c7683 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -15,6 +15,7 @@ import type { Fault, VirtualFolderData, App, + VersionInfo, } from './types'; import { isVirtualFolderData } from './types'; import { createSovdClient, type SovdApiClient } from './sovd-api'; @@ -273,15 +274,44 @@ export const useAppStore = create()( }); }, - // Load root entities + // Load root entities - creates a server node as root with areas as children loadRootEntities: async () => { - const { client } = get(); + const { client, serverUrl } = get(); if (!client) return; try { - const entities = await client.getEntities(); - const treeNodes = entities.map((e: SovdEntity) => toTreeNode(e)); - set({ rootEntities: treeNodes }); + // Fetch version info and areas in parallel + const [versionInfo, entities] = await Promise.all([ + client.getVersionInfo().catch(() => null), + client.getEntities(), + ]); + + // Extract server info from version-info response + const sovdInfo = versionInfo?.sovd_info?.[0]; + const serverName = sovdInfo?.vendor_info?.name || 'SOVD Server'; + const serverVersion = sovdInfo?.vendor_info?.version || ''; + const sovdVersion = sovdInfo?.version || ''; + + // Create server root node with areas as children + const serverNode: EntityTreeNode = { + id: 'server', + name: serverName, + type: 'server', + href: serverUrl || '', + path: '/server', + hasChildren: true, + isLoading: false, + isExpanded: false, + children: entities.map((e: SovdEntity) => toTreeNode(e, '/server')), + data: { + versionInfo, + serverVersion, + sovdVersion, + serverUrl, + }, + }; + + set({ rootEntities: [serverNode], expandedPaths: ['/server'] }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; toast.error(`Failed to load entities: ${message}`); @@ -350,8 +380,8 @@ export const useAppStore = create()( }; }); } else { - // For areas and components - use entity path without /resources/data - const entityPath = path.replace('/resources/data', ''); + // For areas and components - use entity path without /resources/data and /server prefix + const entityPath = path.replace('/resources/data', '').replace(/^\/server/, ''); const topics = await client.getEntities(entityPath); children = topics.map((topic: SovdEntity & { data?: ComponentTopic }) => { const cleanName = topic.name.startsWith('/') ? topic.name.slice(1) : topic.name; @@ -453,10 +483,16 @@ export const useAppStore = create()( return; } - // Regular node loading for entities (areas, subareas, components, subcomponents) - // These load their direct children: areas load subareas+components, components load subcomponents+apps + // Regular node loading for entities (server, areas, subareas, components, subcomponents) + // These load their direct children const nodeType = node?.type?.toLowerCase() || ''; + // Handle server node - children (areas) are already loaded in loadRootEntities + if (nodeType === 'server') { + // Server children (areas) are pre-loaded, nothing to do + return; + } + // Check if this is a loadable entity type const isAreaOrSubarea = nodeType === 'area' || nodeType === 'subarea'; const isComponentOrSubcomponent = nodeType === 'component' || nodeType === 'subcomponent'; @@ -473,11 +509,14 @@ 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(path), + client.getEntities(apiPath), client.listSubareas(node.id).catch(() => []), ]); @@ -546,7 +585,9 @@ export const useAppStore = create()( set({ loadingPaths: [...loadingPaths, path] }); try { - const entities = await client.getEntities(path); + // Convert tree path to API path (remove /server prefix) + const apiPath = path.replace(/^\/server/, ''); + const entities = await client.getEntities(apiPath); const children = entities.map((e: SovdEntity) => toTreeNode(e, path)); // Update tree with children @@ -630,7 +671,9 @@ export const useAppStore = create()( }); try { - const details = await client.getEntityDetails(path); + // Convert tree path to API path (remove /server prefix) + const apiPath = path.replace(/^\/server/, ''); + const details = await client.getEntityDetails(apiPath); // Update tree node with full data MERGED with direction info // This preserves isPublisher/isSubscriber for the tree icons @@ -679,6 +722,34 @@ export const useAppStore = create()( return; } + // Handle Server node selection - show server info panel + if (node && node.type === 'server') { + const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; + const serverData = node.data as { + versionInfo?: VersionInfo; + serverVersion?: string; + sovdVersion?: string; + serverUrl?: string; + }; + + set({ + selectedPath: path, + expandedPaths: newExpandedPaths, + isLoadingDetails: false, + selectedEntity: { + id: node.id, + name: node.name, + type: 'server', + href: node.href, + versionInfo: serverData?.versionInfo, + serverVersion: serverData?.serverVersion, + sovdVersion: serverData?.sovdVersion, + serverUrl: serverData?.serverUrl, + }, + }); + return; + } + // Optimization for Component/Subcomponent - just select it and auto-expand // Don't modify children - virtual folders (resources/, subcomponents/) are already there if (node && (node.type === 'component' || node.type === 'subcomponent')) { @@ -834,7 +905,9 @@ export const useAppStore = create()( }); try { - const details = await client.getEntityDetails(path); + // Convert tree path to API path (remove /server prefix) + const apiPath = path.replace(/^\/server/, ''); + const details = await client.getEntityDetails(apiPath); set({ selectedEntity: details, isLoadingDetails: false }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/src/lib/types.ts b/src/lib/types.ts index 1c9699e..cc3c5fe 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -643,16 +643,34 @@ export interface ServerCapabilities { entry_points: Record; } +/** + * Vendor info in SOVD version response + */ +export interface VendorInfo { + /** Vendor/implementation name */ + name: string; + /** Vendor/implementation version */ + version: string; +} + +/** + * Single SOVD info entry from version-info response + */ +export interface SovdInfoEntry { + /** Base URI for the API */ + base_uri: string; + /** SOVD specification version */ + version: string; + /** Vendor-specific information */ + vendor_info?: VendorInfo; +} + /** * Version info from GET /version-info */ export interface VersionInfo { - /** SOVD specification version */ - sovd_version: string; - /** Server implementation version */ - implementation_version?: string; - /** Additional version details */ - details?: Record; + /** Array of SOVD version info entries */ + sovd_info: SovdInfoEntry[]; } // ============================================================================= From cbf430df9de07116b9aac68d1d1f3fce34d9d910 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 27 Jan 2026 08:44:54 +0000 Subject: [PATCH 11/26] fix: address PR #21 review comments - Make hasChildren dynamic based on metadata/children count - Add debouncing and memoization to SearchCommand - Add entity type icons to breadcrumb navigation - Remove misleading legacy comment from topicsInfo (field is actively used) - Add backward compatibility for componentId in type guard - Log warning on version info fetch failure - Add visibility-based polling to FaultsCountBadge - Fix namespace parsing with filter(Boolean) - Add safety counter to while loop - Add Escape key handler to mobile sidebar overlay - Use recursive findAreaNode for nested areas - Fix clearFault to use fault.entity_type - Remove redundant virtual-folder check - Consolidate ServerPanel into ServerInfoPanel - Add optional chaining for FunctionsPanel API calls - Fix home button to navigate to /server --- src/App.tsx | 9 +- src/components/AppsPanel.tsx | 12 ++- src/components/AreasPanel.tsx | 30 +++++- src/components/EntityDetailPanel.tsx | 153 +++++++++------------------ src/components/FaultsDashboard.tsx | 23 +++- src/components/FunctionsPanel.tsx | 13 ++- src/components/SearchCommand.tsx | 43 +++++--- src/lib/store.ts | 25 ++++- src/lib/types.ts | 8 +- 9 files changed, 174 insertions(+), 142 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7243858..1831851 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -113,7 +113,14 @@ function App() {
setSidebarOpen(false)} - aria-hidden="true" + onKeyDown={(event) => { + if (event.key === 'Escape') { + setSidebarOpen(false); + } + }} + role="button" + tabIndex={0} + aria-label="Close sidebar" /> )} diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index 0cbd800..aba5156 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -123,11 +123,13 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI
@@ -626,13 +575,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit )} {/* Server Entity View */} - {isServer && !hasError && ( - - )} + {isServer && !hasError && } {/* Area Entity View */} {isArea && !hasError && ( @@ -782,13 +725,17 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // e.g., /root/route_server/resources/data -> /root/route_server const folderPathParts = selectedPath.split('/'); // Remove folder names (e.g., data/operations/configurations/faults and resources) + // Use safety counter to prevent infinite loops + const folderNames = ['data', 'operations', 'configurations', 'faults', 'resources']; + let safetyCounter = 0; + const maxIterations = 10; while ( folderPathParts.length > 0 && - ['data', 'operations', 'configurations', 'faults', 'resources'].includes( - folderPathParts[folderPathParts.length - 1] || '' - ) + folderNames.includes(folderPathParts[folderPathParts.length - 1] || '') && + safetyCounter < maxIterations ) { folderPathParts.pop(); + safetyCounter++; } const basePath = folderPathParts.join('/'); // Determine entity type from folder data diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx index 0a3c5b5..e0b9ebf 100644 --- a/src/components/FaultsDashboard.tsx +++ b/src/components/FaultsDashboard.tsx @@ -332,8 +332,10 @@ export function FaultsDashboard() { // Find the fault to get entity info const fault = faults.find((f) => f.code === code); if (fault) { - // Note: This assumes faults are from components. May need adjustment. - await client.clearFault('components', fault.entity_id, code); + // Determine the correct entity group based on the fault's entity type + const entityGroup = + fault.entity_type === 'app' || fault.entity_type === 'apps' ? 'apps' : 'components'; + await client.clearFault(entityGroup, fault.entity_id, code); } // Reload faults after clearing await loadFaults(true); @@ -666,6 +668,7 @@ export function FaultsDashboard() { /** * Faults count badge for sidebar + * Polls for fault count only when visible via document.hidden check */ export function FaultsCountBadge() { const [count, setCount] = useState(0); @@ -679,6 +682,8 @@ export function FaultsCountBadge() { useEffect(() => { const loadCount = async () => { + // Skip polling when document is hidden (tab not visible) + if (document.hidden) return; if (!client || !isConnected) { setCount(0); return; @@ -697,7 +702,19 @@ export function FaultsCountBadge() { loadCount(); const interval = setInterval(loadCount, DEFAULT_POLL_INTERVAL); - return () => clearInterval(interval); + + // Also listen for visibility changes to pause/resume polling + const handleVisibilityChange = () => { + if (!document.hidden) { + loadCount(); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; }, [client, isConnected]); if (count === 0) return null; diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx index 4eb6fdc..98ddb9b 100644 --- a/src/components/FunctionsPanel.tsx +++ b/src/components/FunctionsPanel.tsx @@ -59,10 +59,17 @@ export function FunctionsPanel({ functionId, functionName, description, path, on try { // Load hosts, data, and operations in parallel + // Use optional chaining to handle missing API methods gracefully const [hostsData, topicsData, opsData] = await Promise.all([ - client.getFunctionHosts(functionId).catch(() => []), - client.getFunctionData(functionId).catch(() => []), - client.getFunctionOperations(functionId).catch(() => []), + client.getFunctionHosts + ? client.getFunctionHosts(functionId).catch(() => [] as string[]) + : Promise.resolve([]), + client.getFunctionData + ? client.getFunctionData(functionId).catch(() => [] as ComponentTopic[]) + : Promise.resolve([]), + client.getFunctionOperations + ? client.getFunctionOperations(functionId).catch(() => [] as Operation[]) + : Promise.resolve([]), ]); setHosts(hostsData); diff --git a/src/components/SearchCommand.tsx b/src/components/SearchCommand.tsx index 7152226..aff1fa8 100644 --- a/src/components/SearchCommand.tsx +++ b/src/components/SearchCommand.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; import { Layers, Box, Cpu, GitBranch, Search } from 'lucide-react'; import { @@ -14,14 +14,12 @@ import type { EntityTreeNode } from '@/lib/types'; /** * Flatten tree nodes for search indexing + * Note: Virtual folders are no longer created in the tree (resources shown in detail panel) */ function flattenTree(nodes: EntityTreeNode[]): EntityTreeNode[] { const result: EntityTreeNode[] = []; for (const node of nodes) { - // Skip virtual folders (data, operations, configurations, faults) - if (node.type === 'virtual-folder') continue; - result.push(node); if (node.children && node.children.length > 0) { @@ -80,6 +78,7 @@ interface SearchCommandProps { */ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) { const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); const { rootEntities, selectEntity, isConnected } = useAppStore( useShallow((state) => ({ @@ -89,18 +88,30 @@ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) { })) ); - // Flatten tree for searching - const allEntities = flattenTree(rootEntities); - - // Filter entities based on search - const filteredEntities = search - ? allEntities.filter( - (entity) => - entity.name.toLowerCase().includes(search.toLowerCase()) || - entity.id.toLowerCase().includes(search.toLowerCase()) || - entity.path.toLowerCase().includes(search.toLowerCase()) - ) - : allEntities.slice(0, 20); // Show first 20 when no search + // Debounce search input for better performance on large trees + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + }, 150); + return () => clearTimeout(timer); + }, [search]); + + // Memoize flattened tree to avoid recalculating on every render + const allEntities = useMemo(() => flattenTree(rootEntities), [rootEntities]); + + // Memoize filtered entities based on debounced search + const filteredEntities = useMemo(() => { + if (!debouncedSearch) { + return allEntities.slice(0, 20); // Show first 20 when no search + } + const searchLower = debouncedSearch.toLowerCase(); + return allEntities.filter( + (entity) => + entity.name.toLowerCase().includes(searchLower) || + entity.id.toLowerCase().includes(searchLower) || + entity.path.toLowerCase().includes(searchLower) + ); + }, [allEntities, debouncedSearch]); const handleSelect = useCallback( (path: string) => { diff --git a/src/lib/store.ts b/src/lib/store.ts index a6c7683..3af3d1c 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -129,9 +129,18 @@ function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode const path = parentPath ? `${parentPath}/${entity.id}` : `/${entity.id}`; const entityType = entity.type.toLowerCase(); - // Areas and components have children (loaded on expand) - // Apps are leaf nodes - their resources are shown in the detail panel - const hasChildren = entityType !== 'app'; + // Prefer explicit metadata / existing children if available; fall back to type heuristic. + let hasChildren: boolean; + const entityAny = entity as unknown as Record; + if (Object.prototype.hasOwnProperty.call(entityAny, 'hasChildren') && typeof entityAny.hasChildren === 'boolean') { + hasChildren = entityAny.hasChildren as boolean; + } else if (Array.isArray(entityAny.children)) { + hasChildren = (entityAny.children as unknown[]).length > 0; + } else { + // Areas and components typically have children (loaded on expand) + // Apps are usually leaf nodes - their resources are shown in the detail panel + hasChildren = entityType !== 'app'; + } return { ...entity, @@ -282,7 +291,11 @@ export const useAppStore = create()( try { // Fetch version info and areas in parallel const [versionInfo, entities] = await Promise.all([ - client.getVersionInfo().catch(() => null), + client.getVersionInfo().catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.warn(`Failed to fetch server version info: ${message}`); + return null as VersionInfo | null; + }), client.getEntities(), ]); @@ -353,7 +366,9 @@ export const useAppStore = create()( let children: EntityTreeNode[] = []; // Map entityType to API collection name - const apiEntityType = folderData.entityType === 'app' ? 'apps' : 'components'; + // Note: Areas don't have their own operations/configurations/faults - they belong to components + const apiEntityType: 'apps' | 'components' = + folderData.entityType === 'app' ? 'apps' : 'components'; if (folderData.folderType === 'data') { // Load topics for data folder diff --git a/src/lib/types.ts b/src/lib/types.ts index cc3c5fe..336e90d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -136,15 +136,19 @@ export interface VirtualFolderData { entityId: string; /** Parent entity type (area, subarea, component, subcomponent, app) */ entityType: 'area' | 'subarea' | 'component' | 'subcomponent' | 'app'; - /** Topics info (for data folder) - legacy support */ + /** Topics info used when building data/ virtual folders from pre-fetched topic lists */ topicsInfo?: ComponentTopicsInfo; } /** * Type guard for VirtualFolderData + * Supports both new entityId and legacy componentId fields for backward compatibility */ export function isVirtualFolderData(data: unknown): data is VirtualFolderData { - return !!data && typeof data === 'object' && 'folderType' in data && 'entityId' in data; + if (!data || typeof data !== 'object') return false; + if (!('folderType' in data)) return false; + // Support both entityId (new) and componentId (legacy) + return 'entityId' in data || 'componentId' in data; } /** From c5b0a5e3f2379277535c727e21f7f1d4b7643512 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 27 Jan 2026 09:08:47 +0000 Subject: [PATCH 12/26] refactor: remove dead VirtualFolderData code Resources (data, operations, configurations, faults) are now shown in entity detail panel tabs instead of tree virtual folders. Removed: - VirtualFolderData interface and isVirtualFolderData type guard from types.ts - Virtual folder handling in loadChildren and selectEntity from store.ts - VirtualFolderContent component and folderType handling from EntityDetailPanel.tsx - Unused DataFolderPanel import --- src/components/EntityDetailPanel.tsx | 101 --------------- src/lib/store.ts | 176 --------------------------- src/lib/types.ts | 29 ----- 3 files changed, 306 deletions(-) diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 84a40d0..6504a06 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -26,7 +26,6 @@ import { EntityDetailSkeleton } from '@/components/EntityDetailSkeleton'; import { TopicDiagnosticsPanel } from '@/components/TopicDiagnosticsPanel'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; -import { DataFolderPanel } from '@/components/DataFolderPanel'; import { FaultsPanel } from '@/components/FaultsPanel'; import { AreasPanel } from '@/components/AreasPanel'; import { AppsPanel } from '@/components/AppsPanel'; @@ -320,63 +319,6 @@ function ParameterDetailCard({ entity, componentId }: ParameterDetailCardProps) ); } -/** - * Virtual folder content - redirect to appropriate panel - */ -interface VirtualFolderContentProps { - folderType: 'resources' | 'data' | 'operations' | 'configurations' | 'faults' | 'subareas' | 'subcomponents'; - componentId: string; - basePath: string; - entityType?: 'components' | 'apps'; -} - -function VirtualFolderContent({ - folderType, - componentId, - basePath, - entityType = 'components', -}: VirtualFolderContentProps) { - switch (folderType) { - case 'resources': - return ( - - -
-

Resources for {componentId}

-

- Expand this folder to view data, operations, configurations, and faults. -

-
-
-
- ); - case 'subareas': - case 'subcomponents': - return ( - - -
-

- {folderType === 'subareas' ? 'Subareas' : 'Subcomponents'} for {componentId} -

-

Expand this folder to view child {folderType}.

-
-
-
- ); - case 'data': - return ; - case 'operations': - return ; - case 'configurations': - return ; - case 'faults': - return ; - default: - return null; - } -} - interface EntityDetailPanelProps { onConnectClick: () => void; viewMode?: 'entity' | 'faults-dashboard'; @@ -718,49 +660,6 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit ) : selectedEntity.type === 'parameter' ? ( // Parameter detail view - ) : selectedEntity.folderType ? ( - // Virtual folder selected - show appropriate panel - (() => { - // Extract base path (component path) from folder path - // e.g., /root/route_server/resources/data -> /root/route_server - const folderPathParts = selectedPath.split('/'); - // Remove folder names (e.g., data/operations/configurations/faults and resources) - // Use safety counter to prevent infinite loops - const folderNames = ['data', 'operations', 'configurations', 'faults', 'resources']; - let safetyCounter = 0; - const maxIterations = 10; - while ( - folderPathParts.length > 0 && - folderNames.includes(folderPathParts[folderPathParts.length - 1] || '') && - safetyCounter < maxIterations - ) { - folderPathParts.pop(); - safetyCounter++; - } - const basePath = folderPathParts.join('/'); - // Determine entity type from folder data - const entityType = selectedEntity.entityType === 'app' ? 'apps' : 'components'; - // Use entityId (new) or componentId (legacy) for backward compatibility - const componentId = - (selectedEntity.entityId as string) || (selectedEntity.componentId as string); - return ( - - ); - })() ) : ( diff --git a/src/lib/store.ts b/src/lib/store.ts index 3af3d1c..4e6b28e 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -13,11 +13,9 @@ import type { CreateExecutionRequest, CreateExecutionResponse, Fault, - VirtualFolderData, App, VersionInfo, } from './types'; -import { isVirtualFolderData } from './types'; import { createSovdClient, type SovdApiClient } from './sovd-api'; const STORAGE_KEY = 'sovd_web_ui_server_url'; @@ -344,160 +342,6 @@ export const useAppStore = create()( // Check if we already have this data in the tree const node = findNode(rootEntities, path); - // Handle virtual folders (resources/, data/, operations/, configurations/, etc.) - if (node && isVirtualFolderData(node.data)) { - const folderData = node.data as VirtualFolderData; - - // Resources folder - children are already defined, just expand - if (folderData.folderType === 'resources') { - // Resources folder has static children (data/operations/configurations/faults) - // They are created in toTreeNode, nothing to load - return; - } - - // Skip if already has loaded children (for folders that need API calls) - if (node.children && node.children.length > 0) { - return; - } - - set({ loadingPaths: [...loadingPaths, path] }); - - try { - let children: EntityTreeNode[] = []; - - // Map entityType to API collection name - // Note: Areas don't have their own operations/configurations/faults - they belong to components - const apiEntityType: 'apps' | 'components' = - folderData.entityType === 'app' ? 'apps' : 'components'; - - if (folderData.folderType === 'data') { - // Load topics for data folder - if (folderData.entityType === 'app') { - const topics = await client.getAppData(folderData.entityId); - children = topics.map((topic) => { - const uniqueId = topic.uniqueKey || topic.topic; - const cleanName = uniqueId.startsWith('/') ? uniqueId.slice(1) : uniqueId; - const encodedName = encodeURIComponent(cleanName); - return { - id: encodedName, - name: topic.topic, - type: 'topic', - href: `${path}/${encodedName}`, - path: `${path}/${encodedName}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: { - ...topic, - isPublisher: topic.isPublisher ?? false, - isSubscriber: topic.isSubscriber ?? false, - }, - }; - }); - } else { - // For areas and components - use entity path without /resources/data and /server prefix - const entityPath = path.replace('/resources/data', '').replace(/^\/server/, ''); - const topics = await client.getEntities(entityPath); - children = topics.map((topic: SovdEntity & { data?: ComponentTopic }) => { - const cleanName = topic.name.startsWith('/') ? topic.name.slice(1) : topic.name; - const encodedName = encodeURIComponent(cleanName); - return { - id: encodedName, - name: topic.name, - type: 'topic', - href: `${path}/${encodedName}`, - path: `${path}/${encodedName}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: topic.data || { - topic: topic.name, - isPublisher: - folderData.topicsInfo?.publishes?.includes(topic.name) ?? false, - isSubscriber: - folderData.topicsInfo?.subscribes?.includes(topic.name) ?? false, - }, - }; - }); - } - } else if (folderData.folderType === 'operations') { - // Load operations for operations folder - const ops = await client.listOperations(folderData.entityId, apiEntityType); - children = ops.map((op) => ({ - id: op.name, - name: op.name, - type: op.kind === 'service' ? 'service' : 'action', - href: `${path}/${op.name}`, - path: `${path}/${op.name}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: op, - })); - } else if (folderData.folderType === 'configurations') { - // Load parameters for configurations folder - const config = await client.listConfigurations(folderData.entityId, apiEntityType); - children = config.parameters.map((param) => ({ - id: param.name, - name: param.name, - type: 'parameter', - href: `${path}/${param.name}`, - path: `${path}/${param.name}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: param, - })); - } else if (folderData.folderType === 'faults') { - // Load faults for this entity - const faultsResponse = await client.listEntityFaults(apiEntityType, folderData.entityId); - children = faultsResponse.items.map((fault) => ({ - id: fault.code, - name: `${fault.code}: ${fault.message}`, - type: 'fault', - href: `${path}/${encodeURIComponent(fault.code)}`, - path: `${path}/${encodeURIComponent(fault.code)}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: fault, - })); - } - // Note: subareas and subcomponents are no longer loaded via folder - // They are loaded as direct children of area/component entities - - const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ - ...n, - children, - hasChildren: children.length > 0, - isLoading: false, - })); - - set({ - rootEntities: updatedTree, - loadingPaths: get().loadingPaths.filter((p) => p !== path), - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - // Don't show error for empty results - some entities may not have resources - if (!message.includes('not found') && !message.includes('404')) { - toast.error(`Failed to load ${folderData.folderType}: ${message}`); - } - // Still update tree to show empty folder - const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ - ...n, - children: [], - hasChildren: false, - isLoading: false, - })); - set({ - rootEntities: updatedTree, - loadingPaths: get().loadingPaths.filter((p) => p !== path), - }); - } - return; - } - // Regular node loading for entities (server, areas, subareas, components, subcomponents) // These load their direct children const nodeType = node?.type?.toLowerCase() || ''; @@ -851,26 +695,6 @@ export const useAppStore = create()( return; } - // Handle virtual folder selection - show appropriate panel - if (node && isVirtualFolderData(node.data)) { - const folderData = node.data as VirtualFolderData; - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: `${folderData.entityId} / ${node.name}`, - type: 'folder', - href: node.href, - // Pass folder info so detail panel knows what to show - folderType: folderData.folderType, - entityId: folderData.entityId, - entityType: folderData.entityType, - }, - }); - return; - } - // Handle parameter selection - show parameter detail with data from tree if (node && node.type === 'parameter' && node.data) { // Extract componentId from path: /area/component/configurations/paramName diff --git a/src/lib/types.ts b/src/lib/types.ts index 336e90d..1a7df80 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -122,35 +122,6 @@ export interface TopicNodeData { isSubscriber: boolean; } -/** - * Virtual folder data for entity subfolders - */ -export interface VirtualFolderData { - /** - * Type of virtual folder: - * - resources: container for data/operations/configurations/faults - * - data, operations, configurations, faults: resource collections inside resources/ - */ - folderType: 'resources' | 'data' | 'operations' | 'configurations' | 'faults'; - /** Parent entity ID */ - entityId: string; - /** Parent entity type (area, subarea, component, subcomponent, app) */ - entityType: 'area' | 'subarea' | 'component' | 'subcomponent' | 'app'; - /** Topics info used when building data/ virtual folders from pre-fetched topic lists */ - topicsInfo?: ComponentTopicsInfo; -} - -/** - * Type guard for VirtualFolderData - * Supports both new entityId and legacy componentId fields for backward compatibility - */ -export function isVirtualFolderData(data: unknown): data is VirtualFolderData { - if (!data || typeof data !== 'object') return false; - if (!('folderType' in data)) return false; - // Support both entityId (new) and componentId (legacy) - return 'entityId' in data || 'componentId' in data; -} - /** * Component topic data from GET /components/{id}/data */ From 1782e7893a543519ad9edf00e4e7774ffdf364f7 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 27 Jan 2026 20:09:17 +0000 Subject: [PATCH 13/26] feat: add tree view mode toggle (Logical/Functional) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add toggle between two tree views in the sidebar: - Logical View: Areas → Components → Apps (default) - Functional View: Functions → Apps (hosts) Changes: - Add treeViewMode state and setTreeViewMode action to store - Update loadRootEntities to fetch either areas or functions - Add loadChildren support for function nodes (loads hosts) - Add selectEntity handler for function type - Add view mode toggle buttons in EntityTreeSidebar - Fix FunctionsPanel to handle host objects from API - Add defensive null checks in EntityTreeNode --- src/components/EntityTreeNode.tsx | 8 +- src/components/EntityTreeSidebar.tsx | 63 ++++++++++++--- src/components/FunctionsPanel.tsx | 33 ++++++-- src/lib/sovd-api.ts | 3 +- src/lib/store.ts | 111 +++++++++++++++++++++++---- src/lib/types.ts | 4 +- 6 files changed, 182 insertions(+), 40 deletions(-) diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index 7205241..ce55c7d 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -42,7 +42,7 @@ interface EntityTreeNodeProps { * - Function: GitBranch (capability grouping) */ function getEntityIcon(type: string, _data?: unknown, isExpanded?: boolean) { - switch (type.toLowerCase()) { + switch ((type || '').toLowerCase()) { // Entity types case 'area': case 'subarea': @@ -221,7 +221,9 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { - {node.name} + + {typeof node.name === 'string' ? node.name : String(node.name || node.id || '')} + {/* Topic direction indicators */} {topicData && ( @@ -251,7 +253,7 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { isSelected ? 'text-primary/70' : 'text-muted-foreground bg-muted/50' )} > - {node.type} + {typeof node.type === 'string' ? node.type : 'unknown'}
diff --git a/src/components/EntityTreeSidebar.tsx b/src/components/EntityTreeSidebar.tsx index f16686f..e23184a 100644 --- a/src/components/EntityTreeSidebar.tsx +++ b/src/components/EntityTreeSidebar.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Server, Settings, RefreshCw, Search, X, AlertTriangle } from 'lucide-react'; +import { Server, Settings, RefreshCw, Search, X, AlertTriangle, Layers, GitBranch } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { EntityTreeNode } from '@/components/EntityTreeNode'; @@ -8,7 +8,7 @@ import { EntityTreeSkeleton } from '@/components/EntityTreeSkeleton'; import { ThemeToggle } from '@/components/ThemeToggle'; import { EmptyState } from '@/components/EmptyState'; import { FaultsCountBadge } from '@/components/FaultsDashboard'; -import { useAppStore } from '@/lib/store'; +import { useAppStore, type TreeViewMode } from '@/lib/store'; import type { EntityTreeNode as EntityTreeNodeType } from '@/lib/types'; interface EntityTreeSidebarProps { @@ -45,15 +45,26 @@ export function EntityTreeSidebar({ onSettingsClick, onFaultsDashboardClick }: E const [searchQuery, setSearchQuery] = useState(''); const [isRefreshing, setIsRefreshing] = useState(false); - const { isConnected, isConnecting, serverUrl, rootEntities, loadRootEntities } = useAppStore( - useShallow((state) => ({ - isConnected: state.isConnected, - isConnecting: state.isConnecting, - serverUrl: state.serverUrl, - rootEntities: state.rootEntities, - loadRootEntities: state.loadRootEntities, - })) - ); + const { isConnected, isConnecting, serverUrl, rootEntities, loadRootEntities, treeViewMode, setTreeViewMode } = + useAppStore( + useShallow((state) => ({ + isConnected: state.isConnected, + isConnecting: state.isConnecting, + serverUrl: state.serverUrl, + rootEntities: state.rootEntities, + loadRootEntities: state.loadRootEntities, + treeViewMode: state.treeViewMode, + setTreeViewMode: state.setTreeViewMode, + })) + ); + + const handleViewModeChange = async (mode: TreeViewMode) => { + if (mode !== treeViewMode) { + setIsRefreshing(true); + await setTreeViewMode(mode); + setIsRefreshing(false); + } + }; const filteredEntities = useMemo(() => { if (!searchQuery.trim()) { @@ -124,6 +135,36 @@ export function EntityTreeSidebar({ onSettingsClick, onFaultsDashboardClick }: E
)} + {/* View mode toggle */} + {isConnected && ( +
+
+ + +
+
+ )} + {/* Search bar */} {isConnected && (
diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx index 98ddb9b..b82c58b 100644 --- a/src/components/FunctionsPanel.tsx +++ b/src/components/FunctionsPanel.tsx @@ -6,6 +6,13 @@ import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; import type { ComponentTopic, Operation } from '@/lib/types'; +/** Host app object returned from /functions/{id}/hosts */ +interface FunctionHost { + id: string; + name: string; + href: string; +} + type FunctionTab = 'overview' | 'hosts' | 'data' | 'operations'; interface TabConfig { @@ -39,7 +46,7 @@ interface FunctionsPanelProps { */ export function FunctionsPanel({ functionId, functionName, description, path, onNavigate }: FunctionsPanelProps) { const [activeTab, setActiveTab] = useState('overview'); - const [hosts, setHosts] = useState([]); + const [hosts, setHosts] = useState([]); const [topics, setTopics] = useState([]); const [operations, setOperations] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -62,8 +69,8 @@ export function FunctionsPanel({ functionId, functionName, description, path, on // Use optional chaining to handle missing API methods gracefully const [hostsData, topicsData, opsData] = await Promise.all([ client.getFunctionHosts - ? client.getFunctionHosts(functionId).catch(() => [] as string[]) - : Promise.resolve([]), + ? client.getFunctionHosts(functionId).catch(() => [] as FunctionHost[]) + : Promise.resolve([]), client.getFunctionData ? client.getFunctionData(functionId).catch(() => [] as ComponentTopic[]) : Promise.resolve([]), @@ -72,7 +79,16 @@ export function FunctionsPanel({ functionId, functionName, description, path, on : Promise.resolve([]), ]); - setHosts(hostsData); + // Normalize hosts - API returns objects with {id, name, href} + const normalizedHosts = hostsData.map((h: unknown) => { + if (typeof h === 'string') { + return { id: h, name: h, href: `/api/v1/apps/${h}` }; + } + const hostObj = h as FunctionHost; + return { id: hostObj.id, name: hostObj.name || hostObj.id, href: hostObj.href || '' }; + }); + + setHosts(normalizedHosts); setTopics(topicsData); setOperations(opsData); } catch (error) { @@ -226,16 +242,17 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
) : (
- {hosts.map((hostId) => ( + {hosts.map((host) => (
handleResourceClick(`/apps/${hostId}`)} + onClick={() => handleResourceClick(`/apps/${host.id}`)} >
- {hostId} + {host.name} + {host.id} app diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index 50ddd6c..ecf4313 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -1146,8 +1146,9 @@ export class SovdApiClient { /** * Get apps hosting a function * @param functionId Function identifier + * @returns Array of host objects (with id, name, href) */ - async getFunctionHosts(functionId: string): Promise { + async getFunctionHosts(functionId: string): Promise { const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/hosts`), { method: 'GET', headers: { Accept: 'application/json' }, diff --git a/src/lib/store.ts b/src/lib/store.ts index 4e6b28e..97cb864 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -15,11 +15,14 @@ import type { Fault, App, VersionInfo, + SovdFunction, } from './types'; import { createSovdClient, type SovdApiClient } from './sovd-api'; const STORAGE_KEY = 'sovd_web_ui_server_url'; +export type TreeViewMode = 'logical' | 'functional'; + export interface AppState { // Connection state serverUrl: string | null; @@ -30,6 +33,7 @@ export interface AppState { client: SovdApiClient | null; // Entity tree state + treeViewMode: TreeViewMode; rootEntities: EntityTreeNode[]; loadingPaths: string[]; expandedPaths: string[]; @@ -60,6 +64,7 @@ export interface AppState { // Actions connect: (url: string, baseEndpoint?: string) => Promise; disconnect: () => void; + setTreeViewMode: (mode: TreeViewMode) => Promise; loadRootEntities: () => Promise; loadChildren: (path: string) => Promise; toggleExpanded: (path: string) => void; @@ -199,6 +204,7 @@ export const useAppStore = create()( connectionError: null, client: null, + treeViewMode: 'logical', rootEntities: [], loadingPaths: [], expandedPaths: [], @@ -281,21 +287,26 @@ export const useAppStore = create()( }); }, - // Load root entities - creates a server node as root with areas as children + // Set tree view mode (logical vs functional) and reload entities + setTreeViewMode: async (mode: TreeViewMode) => { + set({ treeViewMode: mode, rootEntities: [], expandedPaths: [] }); + await get().loadRootEntities(); + }, + + // Load root entities - creates a server node as root + // In logical mode: Areas -> Components -> Apps + // In functional mode: Functions -> Apps (hosts) loadRootEntities: async () => { - const { client, serverUrl } = get(); + const { client, serverUrl, treeViewMode } = get(); if (!client) return; try { - // Fetch version info and areas in parallel - const [versionInfo, entities] = await Promise.all([ - client.getVersionInfo().catch((error: unknown) => { - const message = error instanceof Error ? error.message : 'Unknown error'; - toast.warn(`Failed to fetch server version info: ${message}`); - return null as VersionInfo | null; - }), - client.getEntities(), - ]); + // Fetch version info + 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}`); + return null as VersionInfo | null; + }); // Extract server info from version-info response const sovdInfo = versionInfo?.sovd_info?.[0]; @@ -303,22 +314,51 @@ export const useAppStore = create()( const serverVersion = sovdInfo?.vendor_info?.version || ''; const sovdVersion = sovdInfo?.version || ''; - // Create server root node with areas as children + let children: EntityTreeNode[] = []; + + if (treeViewMode === 'functional') { + // Functional view: Functions -> Apps (hosts) + const functions = await client.listFunctions().catch(() => [] as SovdFunction[]); + children = functions.map((fn: SovdFunction) => { + const fnName = typeof fn.name === 'string' ? fn.name : fn.id || 'Unknown'; + const fnId = typeof fn.id === 'string' ? fn.id : String(fn.id); + return { + id: fnId, + name: fnName, + type: 'function', + href: fn.href || '', + path: `/server/${fnId}`, + children: undefined, + isLoading: false, + isExpanded: false, + // Functions always potentially have hosts - load on expand + hasChildren: true, + data: fn, + }; + }); + } else { + // Logical view: Areas -> Components -> Apps + const entities = await client.getEntities(); + children = entities.map((e: SovdEntity) => toTreeNode(e, '/server')); + } + + // Create server root node const serverNode: EntityTreeNode = { id: 'server', name: serverName, type: 'server', href: serverUrl || '', path: '/server', - hasChildren: true, + hasChildren: children.length > 0, isLoading: false, isExpanded: false, - children: entities.map((e: SovdEntity) => toTreeNode(e, '/server')), + children, data: { versionInfo, serverVersion, sovdVersion, serverUrl, + treeViewMode, }, }; @@ -355,8 +395,9 @@ export const useAppStore = create()( // Check if this is a loadable entity type const isAreaOrSubarea = nodeType === 'area' || nodeType === 'subarea'; const isComponentOrSubcomponent = nodeType === 'component' || nodeType === 'subcomponent'; + const isFunction = nodeType === 'function'; - if (node && (isAreaOrSubarea || isComponentOrSubcomponent)) { + if (node && (isAreaOrSubarea || isComponentOrSubcomponent || isFunction)) { // Check if we already loaded children if (node.children && node.children.length > 0) { // Already loaded children, skip fetch @@ -404,6 +445,26 @@ export const useAppStore = create()( ); loadedEntities = [...subcompNodes, ...appNodes]; + } else if (isFunction) { + // Load hosts (apps) for this function + const hosts = await client.getFunctionHosts(node.id).catch(() => []); + + // Hosts response contains objects with {id, name, href} + loadedEntities = hosts.map((host: unknown) => { + const hostObj = host as { id?: string; name?: string; href?: string }; + const hostId = hostObj.id || ''; + const hostName = hostObj.name || hostObj.id || ''; + return { + id: hostId, + name: hostName, + type: 'app', + href: hostObj.href || `${path}/${hostId}`, + path: `${path}/${hostId}`, + hasChildren: false, + isLoading: false, + isExpanded: false, + }; + }); } const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ @@ -649,6 +710,26 @@ export const useAppStore = create()( return; } + // Handle Function entity selection - show function panel with hosts + if (node && node.type === 'function') { + const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; + const functionData = node.data as SovdFunction | undefined; + + set({ + selectedPath: path, + expandedPaths: newExpandedPaths, + isLoadingDetails: false, + selectedEntity: { + id: node.id, + name: node.name, + type: 'function', + href: node.href, + description: functionData?.description, + }, + }); + return; + } + // Handle App entity selection - auto-expand to show virtual folders if (node && node.type === 'app') { const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; diff --git a/src/lib/types.ts b/src/lib/types.ts index 1a7df80..ab62b68 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -506,8 +506,8 @@ export interface AppCapabilities { export interface SovdFunction extends SovdEntity { /** Description of the function */ description?: string; - /** IDs of apps that host this function */ - hosts: string[]; + /** IDs of apps that host this function (loaded from separate /hosts endpoint) */ + hosts?: string[]; } /** From 73be4acc836f81d5fde29301f760e07de75aede4 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 27 Jan 2026 20:35:56 +0000 Subject: [PATCH 14/26] feat(panels): add resource tabs (Data, Operations, Config, Faults) to all entity panels - Create EntityResourceTabs reusable component for resource display - Add Resources tab to AreasPanel with aggregated entity resources - Add Configurations and Faults tabs to FunctionsPanel - Update sovd-api.ts with SovdResourceEntityType and getEntityData method - Add red badge styling for faults when count > 0 --- src/components/AppsPanel.tsx | 5 +- src/components/AreasPanel.tsx | 24 +- src/components/EntityResourceTabs.tsx | 317 ++++++++++++++++++++++++++ src/components/FunctionsPanel.tsx | 152 +++++++++++- src/lib/sovd-api.ts | 43 +++- 5 files changed, 514 insertions(+), 27 deletions(-) create mode 100644 src/components/EntityResourceTabs.tsx diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index aba5156..34432d1 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -166,7 +166,10 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI {tab.label} {count > 0 && ( - + 0 ? 'bg-red-500 text-white' : ''}`} + > {count} )} diff --git a/src/components/AreasPanel.tsx b/src/components/AreasPanel.tsx index 4e20027..1edf2a8 100644 --- a/src/components/AreasPanel.tsx +++ b/src/components/AreasPanel.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Layers, Box, ChevronRight, MapPin } from 'lucide-react'; +import { Layers, Box, ChevronRight, MapPin, Database } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; +import { EntityResourceTabs } from '@/components/EntityResourceTabs'; import type { EntityTreeNode } from '@/lib/types'; -type AreaTab = 'overview' | 'components'; +type AreaTab = 'overview' | 'components' | 'resources'; interface TabConfig { id: AreaTab; @@ -17,6 +18,7 @@ interface TabConfig { const AREA_TABS: TabConfig[] = [ { id: 'overview', label: 'Overview', icon: Layers }, { id: 'components', label: 'Components', icon: Box }, + { id: 'resources', label: 'Resources', icon: Database }, ]; interface AreasPanelProps { @@ -162,14 +164,14 @@ export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) {
Subareas
)} -
- - {/* Tip about resources */} -
-

- Tip: Areas contain components. To view data, operations, - configurations, or faults, select a component or app from this area. -

+
@@ -221,6 +223,8 @@ export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) { )} + + {activeTab === 'resources' && }
); } diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx new file mode 100644 index 0000000..8f65718 --- /dev/null +++ b/src/components/EntityResourceTabs.tsx @@ -0,0 +1,317 @@ +import { useState, useEffect } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare, Clock } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useAppStore } from '@/lib/store'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; +import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; + +type ResourceTab = 'data' | 'operations' | 'configurations' | 'faults'; + +interface TabConfig { + id: ResourceTab; + label: string; + icon: typeof Database; +} + +const RESOURCE_TABS: TabConfig[] = [ + { id: 'data', label: 'Data', icon: Database }, + { id: 'operations', label: 'Operations', icon: Zap }, + { id: 'configurations', label: 'Config', icon: Settings }, + { id: 'faults', label: 'Faults', icon: AlertTriangle }, +]; + +interface EntityResourceTabsProps { + entityId: string; + entityType: SovdResourceEntityType; + onNavigate?: (path: string) => void; +} + +/** + * Reusable component for displaying entity resources (data, operations, configurations, faults) + * Works with areas, components, apps, and functions + */ +export function EntityResourceTabs({ entityId, entityType, onNavigate }: EntityResourceTabsProps) { + const [activeTab, setActiveTab] = useState('data'); + const [isLoading, setIsLoading] = useState(false); + const [data, setData] = useState([]); + const [operations, setOperations] = useState([]); + const [configurations, setConfigurations] = useState([]); + const [faults, setFaults] = useState([]); + + const { client, selectEntity } = useAppStore( + useShallow((state) => ({ + client: state.client, + selectEntity: state.selectEntity, + })) + ); + + useEffect(() => { + const loadResources = async () => { + if (!client) return; + setIsLoading(true); + + try { + const [dataRes, opsRes, configRes, faultsRes] = await Promise.all([ + client.getEntityData(entityType, entityId).catch(() => [] as ComponentTopic[]), + client.listOperations(entityId, entityType).catch(() => [] as Operation[]), + client.listConfigurations(entityId, entityType).catch(() => ({ parameters: [] })), + client.listEntityFaults(entityType, entityId).catch(() => ({ items: [] })), + ]); + + setData(dataRes); + setOperations(opsRes); + setConfigurations(configRes.parameters || []); + setFaults(faultsRes.items || []); + } catch (error) { + console.error('Failed to load entity resources:', error); + } finally { + setIsLoading(false); + } + }; + + loadResources(); + }, [client, entityId, entityType]); + + const handleNavigate = (path: string) => { + if (onNavigate) { + onNavigate(path); + } else { + selectEntity(path); + } + }; + + // Count resources for badges + const services = operations.filter((o) => o.kind === 'service'); + const actions = operations.filter((o) => o.kind === 'action'); + + return ( +
+ {/* Tab Navigation */} +
+ {RESOURCE_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + 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 === 'faults') count = faults.length; + + return ( + + ); + })} +
+ + {isLoading ? ( + + + + + + ) : ( + <> + {/* Data Tab */} + {activeTab === 'data' && ( + + + + + Data Items + + Aggregated data from child entities + + + {data.length === 0 ? ( +
+ +

No data items available.

+
+ ) : ( +
+ {data.map((item, idx) => ( +
+ handleNavigate( + `/${entityType}/${entityId}/data/${encodeURIComponent(item.topic)}` + ) + } + > + + {item.topic} + {item.type && ( + + {item.type.split('/').pop()} + + )} +
+ ))} +
+ )} +
+
+ )} + + {/* Operations Tab */} + {activeTab === 'operations' && ( + + + + + Operations + + + {services.length} services, {actions.length} actions + + + + {operations.length === 0 ? ( +
+ +

No operations available.

+
+ ) : ( +
+ {operations.map((op) => ( +
+ handleNavigate( + `/${entityType}/${entityId}/operations/${encodeURIComponent(op.name)}` + ) + } + > + {op.kind === 'service' ? ( + + ) : ( + + )} + {op.name} + + {op.kind} + +
+ ))} +
+ )} +
+
+ )} + + {/* Configurations Tab */} + {activeTab === 'configurations' && ( + + + + + Configurations + + Parameters from child entities + + + {configurations.length === 0 ? ( +
+ +

No configurations available.

+
+ ) : ( +
+ {configurations.map((param) => ( +
+ + {param.name} + + {param.type} + + + {String(param.value)} + +
+ ))} +
+ )} +
+
+ )} + + {/* Faults Tab */} + {activeTab === 'faults' && ( + + + + + Faults + + Active faults from child entities + + + {faults.length === 0 ? ( +
+ +

No active faults.

+
+ ) : ( +
+ {faults.map((fault) => ( +
+ +
+
{fault.code}
+
+ {fault.message} +
+
+ + {fault.severity} + +
+ ))} +
+ )} +
+
+ )} + + )} +
+ ); +} diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx index b82c58b..ed3ea2e 100644 --- a/src/components/FunctionsPanel.tsx +++ b/src/components/FunctionsPanel.tsx @@ -1,10 +1,21 @@ import { useState, useEffect } from 'react'; import { useShallow } from 'zustand/shallow'; -import { GitBranch, Cpu, Database, Zap, ChevronRight, Users, Info } from 'lucide-react'; +import { + GitBranch, + Cpu, + Database, + Zap, + ChevronRight, + Users, + Info, + Settings, + AlertTriangle, + Loader2, +} from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; -import type { ComponentTopic, Operation } from '@/lib/types'; +import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; /** Host app object returned from /functions/{id}/hosts */ interface FunctionHost { @@ -13,7 +24,7 @@ interface FunctionHost { href: string; } -type FunctionTab = 'overview' | 'hosts' | 'data' | 'operations'; +type FunctionTab = 'overview' | 'hosts' | 'data' | 'operations' | 'configurations' | 'faults'; interface TabConfig { id: FunctionTab; @@ -26,6 +37,8 @@ const FUNCTION_TABS: TabConfig[] = [ { id: 'hosts', label: 'Hosts', icon: Cpu }, { id: 'data', label: 'Data', icon: Database }, { id: 'operations', label: 'Operations', icon: Zap }, + { id: 'configurations', label: 'Config', icon: Settings }, + { id: 'faults', label: 'Faults', icon: AlertTriangle }, ]; interface FunctionsPanelProps { @@ -49,6 +62,8 @@ 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( @@ -65,9 +80,8 @@ export function FunctionsPanel({ functionId, functionName, description, path, on setIsLoading(true); try { - // Load hosts, data, and operations in parallel - // Use optional chaining to handle missing API methods gracefully - const [hostsData, topicsData, opsData] = await Promise.all([ + // 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([]), @@ -77,6 +91,8 @@ export function FunctionsPanel({ functionId, functionName, description, path, on client.getFunctionOperations ? client.getFunctionOperations(functionId).catch(() => [] as Operation[]) : Promise.resolve([]), + client.listConfigurations(functionId, 'functions').catch(() => ({ parameters: [] })), + client.listEntityFaults('functions', functionId).catch(() => ({ items: [] })), ]); // Normalize hosts - API returns objects with {id, name, href} @@ -91,6 +107,8 @@ 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); } finally { @@ -145,6 +163,8 @@ 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 === 'faults') count = faults.length; return ( + +
{hosts.length === 0 && !isLoading && ( @@ -350,7 +391,100 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
)} - {isLoading &&
Loading function resources...
} + {activeTab === 'configurations' && ( + + + + + Configurations + + Parameters from all host apps + + + {configurations.length === 0 ? ( +
+ +

No configurations available.

+
+ ) : ( +
+ {configurations.map((param) => ( +
+ + {param.name} + + {param.type} + + + {String(param.value)} + +
+ ))} +
+ )} +
+
+ )} + + {activeTab === 'faults' && ( + + + + + Faults + + Active faults from all host apps + + + {faults.length === 0 ? ( +
+ +

No active faults.

+
+ ) : ( +
+ {faults.map((fault) => ( +
+ +
+
{fault.code}
+
+ {fault.message} +
+
+ + {fault.severity} + +
+ ))} +
+ )} +
+
+ )} + + {isLoading && ( + + + + + + )}
); } diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index ecf4313..8a4deab 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -31,6 +31,9 @@ import type { SovdError, } from './types'; +/** Entity types that support resource collections (data, operations, configurations, faults) */ +export type SovdResourceEntityType = 'areas' | 'components' | 'apps' | 'functions'; + /** * Helper to unwrap items from SOVD API response * API returns {items: [...]} format, but we often want just the array @@ -532,12 +535,12 @@ export class SovdApiClient { /** * List all configurations (parameters) for an entity - * @param entityId Entity ID (component or app) - * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity ID + * @param entityType Entity type */ async listConfigurations( entityId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/configurations`), { method: 'GET', @@ -695,10 +698,10 @@ export class SovdApiClient { /** * List all operations (services + actions) for an entity (component or app) - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity ID */ - async listOperations(entityId: string, entityType: 'components' | 'apps' = 'components'): Promise { + async listOperations(entityId: string, entityType: SovdResourceEntityType = 'components'): Promise { const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/operations`), { method: 'GET', headers: { Accept: 'application/json' }, @@ -1196,6 +1199,32 @@ export class SovdApiClient { return unwrapItems(await response.json()); } + // =========================================================================== + // 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 { + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/data`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + if (response.status === 404) { + return []; + } + throw new Error(`HTTP ${response.status}`); + } + + return unwrapItems(await response.json()); + } + // =========================================================================== // FAULTS API (Diagnostic Trouble Codes) // =========================================================================== @@ -1282,10 +1311,10 @@ export class SovdApiClient { /** * List faults for a specific entity - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier */ - async listEntityFaults(entityType: 'components' | 'apps', entityId: string): Promise { + async listEntityFaults(entityType: SovdResourceEntityType, entityId: string): Promise { const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/faults`), { method: 'GET', headers: { Accept: 'application/json' }, From b3eb69bd981b90472c06f504e14b2f3b0ab84005 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 27 Jan 2026 21:07:39 +0000 Subject: [PATCH 15/26] fix(detail-panel): hide redundant 'No detailed information' card for server view --- src/components/EntityDetailPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 6504a06..cba83eb 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -653,8 +653,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit hasTopicsInfo={hasTopicsInfo ?? false} selectEntity={selectEntity} /> - ) : isArea || isApp || isFunction ? null : selectedEntity.type === 'service' || // Already handled above with specialized panels - selectedEntity.type === 'action' ? ( + ) : isArea || isApp || isFunction || isServer ? null : selectedEntity.type === 'action' ? ( // Already handled above with specialized panels // Service/Action detail view ) : selectedEntity.type === 'parameter' ? ( From fa8cfd0d10d89b8d32f9550a9de9f8921d392a73 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 27 Jan 2026 21:15:50 +0000 Subject: [PATCH 16/26] fix(apps-panel): use ConfigurationPanel for inline parameter editing Replace simple readonly parameter list with full ConfigurationPanel component that provides: - Inline editing by clicking on parameter values - Save/cancel with Enter/Escape keys - Boolean toggle buttons - Reset to default per parameter - Reset All button for bulk restore - Proper type validation and parsing --- src/components/AppsPanel.tsx | 60 ++++++------------------------------ 1 file changed, 10 insertions(+), 50 deletions(-) diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index 34432d1..0496c10 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -5,7 +5,8 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { useAppStore } from '@/lib/store'; -import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; +import { ConfigurationPanel } from '@/components/ConfigurationPanel'; +import type { ComponentTopic, Operation, Fault } from '@/lib/types'; type AppTab = 'overview' | 'data' | 'operations' | 'configurations' | 'faults'; @@ -47,35 +48,33 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI const [activeTab, setActiveTab] = useState('overview'); const [topics, setTopics] = useState([]); const [operations, setOperations] = useState([]); - const [parameters, setParameters] = useState([]); const [faults, setFaults] = useState([]); const [isLoading, setIsLoading] = useState(false); - const { client, selectEntity } = useAppStore( + const { client, selectEntity, configurations } = useAppStore( useShallow((state) => ({ client: state.client, selectEntity: state.selectEntity, + configurations: state.configurations, })) ); - // Load app resources on mount + // Load app resources on mount (configurations are loaded by ConfigurationPanel) useEffect(() => { const loadAppData = async () => { if (!client) return; setIsLoading(true); try { - // Load all resources in parallel - const [topicsData, opsData, configData, faultsData] = await Promise.all([ + // 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.listConfigurations(appId, 'apps').catch(() => ({ parameters: [] })), client.listEntityFaults('apps', appId).catch(() => ({ items: [] })), ]); setTopics(topicsData); setOperations(opsData); - setParameters(configData.parameters); setFaults(faultsData.items); } catch (error) { console.error('Failed to load app data:', error); @@ -150,7 +149,7 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI let count = 0; if (tab.id === 'data') count = topics.length; if (tab.id === 'operations') count = operations.length; - if (tab.id === 'configurations') count = parameters.length; + if (tab.id === 'configurations') count = configurations.get(appId)?.length || 0; if (tab.id === 'faults') count = activeFaults.length; return ( @@ -236,7 +235,7 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI className="p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left" > -
{parameters.length}
+
{configurations.get(appId)?.length || 0}
Parameters
From 333a3fb489fcd33622f338b4644e174b9f101040 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 28 Jan 2026 09:10:44 +0000 Subject: [PATCH 18/26] fix(api): transform data items response to ComponentTopic format getEntityData was returning raw API response without mapping id/name fields to topic property. EntityResourceTabs expects topic field for display. - Add DataItem interface for API response structure - Transform to ComponentTopic with topic from name/x-medkit - Extract type info from x-medkit extension --- src/lib/sovd-api.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index 8a4deab..3edc9e0 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -1222,7 +1222,27 @@ export class SovdApiClient { throw new Error(`HTTP ${response.status}`); } - return unwrapItems(await response.json()); + // API returns {items: [{id, name, category, x-medkit}]} format + interface DataItem { + id: string; + name: string; + category?: string; + 'x-medkit'?: { + ros2?: { topic?: string; type?: string; direction?: string }; + type_info?: { schema?: Record }; + }; + } + const dataItems = unwrapItems(await response.json()); + + // Transform to ComponentTopic format + return dataItems.map((item) => ({ + topic: item.name || item['x-medkit']?.ros2?.topic || item.id, + timestamp: Date.now() * 1000000, + data: null, + status: 'metadata_only' as const, + type: item['x-medkit']?.ros2?.type, + type_info: item['x-medkit']?.type_info, + })); } // =========================================================================== From b1b2a216f5b240dc6be1569ad22e6388c1a3fa2a Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 28 Jan 2026 18:41:09 +0000 Subject: [PATCH 19/26] refactor(api): unify resource fetching with generic getResources method - Add generic getResources() method for unified data/operations/configurations/faults fetching - Add transformDataResponse() with direction fields (isPublisher, isSubscriber, uniqueKey) - Refactor getFunctionData(), getFunctionOperations(), getAppData() to use getResources() - Add publishToEntityData() for generic publishing to any entity type - Export SovdResourceEntityType from types.ts fix(ui): resolve Data panel display and publishing issues - Fix Component data showing 'No data available' despite correct count - Fix Function data items not being clickable - Fix topic publishing using wrong componentId (now extracts parent entity from path) - Add entityType support to DataPanel and TopicPublishForm for correct API endpoints - Add DataPanel component for single topic diagnostic view - Remove TopicDiagnosticsPanel (functionality merged into DataPanel) refactor(components): improve data flow in EntityDetailPanel - Add topicsData state to store fetched data for display - Update fetchResourceCounts to populate topicsData - Pass topicsData to DataTabContent as primary data source - Extract parent component ID from path for topic views --- src/components/AreasPanel.tsx | 2 +- src/components/ConfigurationPanel.tsx | 14 +- src/components/DataFolderPanel.tsx | 8 +- ...opicDiagnosticsPanel.tsx => DataPanel.tsx} | 24 +- src/components/EntityDetailPanel.tsx | 169 ++++- src/components/EntityResourceTabs.tsx | 64 +- src/components/FaultsPanel.tsx | 5 +- src/components/FunctionsPanel.tsx | 78 +-- src/components/OperationsPanel.tsx | 3 +- src/components/TopicPublishForm.tsx | 35 +- src/lib/schema-utils.ts | 97 +++ src/lib/sovd-api.ts | 608 +++++++++--------- src/lib/store.ts | 54 +- src/lib/types.ts | 18 +- 14 files changed, 699 insertions(+), 480 deletions(-) rename src/components/{TopicDiagnosticsPanel.tsx => DataPanel.tsx} (95%) diff --git a/src/components/AreasPanel.tsx b/src/components/AreasPanel.tsx index 1edf2a8..71a5b8f 100644 --- a/src/components/AreasPanel.tsx +++ b/src/components/AreasPanel.tsx @@ -224,7 +224,7 @@ export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) { )} - {activeTab === 'resources' && } + {activeTab === 'resources' && }
); } diff --git a/src/components/ConfigurationPanel.tsx b/src/components/ConfigurationPanel.tsx index de281dd..7a01dfd 100644 --- a/src/components/ConfigurationPanel.tsx +++ b/src/components/ConfigurationPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useShallow } from 'zustand/shallow'; import { Settings, Loader2, RefreshCw, Lock, Save, X, RotateCcw } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; @@ -7,13 +7,14 @@ 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'; interface ConfigurationPanelProps { componentId: string; /** Optional parameter name to highlight */ highlightParam?: string; /** Entity type for API calls */ - entityType?: 'components' | 'apps'; + entityType?: SovdResourceEntityType; } /** @@ -286,13 +287,16 @@ export function ConfigurationPanel({ const [isResettingAll, setIsResettingAll] = useState(false); const parameters = configurations.get(componentId) || []; + const prevComponentIdRef = useRef(null); - // Fetch configurations on mount (lazy loading) + // Fetch configurations on mount and when componentId changes useEffect(() => { - if (!configurations.has(componentId)) { + // Always fetch if componentId changed, or if not yet loaded + if (prevComponentIdRef.current !== componentId || !configurations.has(componentId)) { fetchConfigurations(componentId, entityType); } - }, [componentId, configurations, fetchConfigurations, entityType]); + prevComponentIdRef.current = componentId; + }, [componentId, entityType, fetchConfigurations, configurations]); const handleRefresh = useCallback(() => { fetchConfigurations(componentId, entityType); diff --git a/src/components/DataFolderPanel.tsx b/src/components/DataFolderPanel.tsx index 77686ad..7e82730 100644 --- a/src/components/DataFolderPanel.tsx +++ b/src/components/DataFolderPanel.tsx @@ -78,8 +78,8 @@ export function DataFolderPanel({ basePath }: DataFolderPanelProps) {
- Topics - ({topics.length} topics) + Data + ({topics.length} items)
); })} @@ -628,14 +735,15 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit ) : hasTopicData ? ( - // Single Topic View - use TopicDiagnosticsPanel + // Single Data View - use DataPanel (() => { const topic = selectedEntity.topicData!; return ( - ) : isArea || isApp || isFunction || isServer ? null : selectedEntity.type === 'action' ? ( // Already handled above with specialized panels // Service/Action detail view ) : selectedEntity.type === 'parameter' ? ( // Parameter detail view - + ) : ( diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index 8f65718..1abbf8e 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -4,6 +4,7 @@ import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare, Clock } import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; +import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import type { SovdResourceEntityType } from '@/lib/sovd-api'; import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; @@ -25,6 +26,8 @@ const RESOURCE_TABS: TabConfig[] = [ interface EntityResourceTabsProps { entityId: string; entityType: SovdResourceEntityType; + /** Tree path for navigation (e.g., /server/root for areas) */ + basePath?: string; onNavigate?: (path: string) => void; } @@ -32,7 +35,7 @@ interface EntityResourceTabsProps { * Reusable component for displaying entity resources (data, operations, configurations, faults) * Works with areas, components, apps, and functions */ -export function EntityResourceTabs({ entityId, entityType, onNavigate }: EntityResourceTabsProps) { +export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate }: EntityResourceTabsProps) { const [activeTab, setActiveTab] = useState('data'); const [isLoading, setIsLoading] = useState(false); const [data, setData] = useState([]); @@ -154,11 +157,13 @@ export function EntityResourceTabs({ entityId, entityType, onNavigate }: EntityR
- handleNavigate( - `/${entityType}/${entityId}/data/${encodeURIComponent(item.topic)}` - ) - } + onClick={() => { + // Use basePath for tree navigation, fallback to API path format + const navPath = basePath + ? `${basePath}/data/${encodeURIComponent(item.topic)}` + : `/${entityType}/${entityId}/data/${encodeURIComponent(item.topic)}`; + handleNavigate(navPath); + }} > {item.topic} @@ -199,11 +204,12 @@ export function EntityResourceTabs({ entityId, entityType, onNavigate }: EntityR
- handleNavigate( - `/${entityType}/${entityId}/operations/${encodeURIComponent(op.name)}` - ) - } + onClick={() => { + const navPath = basePath + ? `${basePath}/operations/${encodeURIComponent(op.name)}` + : `/${entityType}/${entityId}/operations/${encodeURIComponent(op.name)}`; + handleNavigate(navPath); + }} > {op.kind === 'service' ? ( @@ -224,41 +230,7 @@ export function EntityResourceTabs({ entityId, entityType, onNavigate }: EntityR {/* Configurations Tab */} {activeTab === 'configurations' && ( - - - - - Configurations - - Parameters from child entities - - - {configurations.length === 0 ? ( -
- -

No configurations available.

-
- ) : ( -
- {configurations.map((param) => ( -
- - {param.name} - - {param.type} - - - {String(param.value)} - -
- ))} -
- )} -
-
+ )} {/* Faults Tab */} diff --git a/src/components/FaultsPanel.tsx b/src/components/FaultsPanel.tsx index 9f3ba70..d7b8be5 100644 --- a/src/components/FaultsPanel.tsx +++ b/src/components/FaultsPanel.tsx @@ -6,11 +6,12 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useAppStore, type AppState } from '@/lib/store'; import type { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; interface FaultsPanelProps { componentId: string; - /** Type of entity: 'components' or 'apps' */ - entityType?: 'components' | 'apps'; + /** Type of entity */ + entityType?: SovdResourceEntityType; } /** diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx index ed3ea2e..b74bc3d 100644 --- a/src/components/FunctionsPanel.tsx +++ b/src/components/FunctionsPanel.tsx @@ -15,6 +15,7 @@ import { import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; +import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; /** Host app object returned from /functions/{id}/hosts */ @@ -323,22 +324,29 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
) : (
- {topics.map((topic, idx) => ( -
- - topic - - {topic.topic} - {topic.type && ( - - {topic.type} - - )} -
- ))} + {topics.map((topic, idx) => { + const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; + const encodedName = encodeURIComponent(cleanName); + const topicPath = `${path}/data/${encodedName}`; + return ( +
handleResourceClick(topicPath)} + > + + topic + + {topic.topic} + {topic.type && ( + + {topic.type} + + )} + +
+ ); + })}
)} @@ -391,43 +399,7 @@ export function FunctionsPanel({ functionId, functionName, description, path, on )} - {activeTab === 'configurations' && ( - - - - - Configurations - - Parameters from all host apps - - - {configurations.length === 0 ? ( -
- -

No configurations available.

-
- ) : ( -
- {configurations.map((param) => ( -
- - {param.name} - - {param.type} - - - {String(param.value)} - -
- ))} -
- )} -
-
- )} + {activeTab === 'configurations' && } {activeTab === 'faults' && ( diff --git a/src/components/OperationsPanel.tsx b/src/components/OperationsPanel.tsx index 58dfcb0..12ff422 100644 --- a/src/components/OperationsPanel.tsx +++ b/src/components/OperationsPanel.tsx @@ -32,6 +32,7 @@ import { ActionStatusPanel } from './ActionStatusPanel'; import { SchemaForm } from './SchemaFormField'; import { getSchemaDefaults } from '@/lib/schema-utils'; import { OperationResponseDisplay } from './OperationResponse'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; /** History entry for an operation invocation */ interface OperationHistoryEntry { @@ -46,7 +47,7 @@ interface OperationsPanelProps { /** Optional: highlight and auto-expand a specific operation */ highlightOperation?: string; /** Entity type for API calls */ - entityType?: 'components' | 'apps'; + entityType?: SovdResourceEntityType; } /** diff --git a/src/components/TopicPublishForm.tsx b/src/components/TopicPublishForm.tsx index ddfd2c9..ea0de5a 100644 --- a/src/components/TopicPublishForm.tsx +++ b/src/components/TopicPublishForm.tsx @@ -5,14 +5,16 @@ 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 type { ComponentTopic, TopicSchema } from '@/lib/types'; +import type { ComponentTopic, TopicSchema, SovdResourceEntityType } from '@/lib/types'; import type { SovdApiClient } from '@/lib/sovd-api'; interface TopicPublishFormProps { /** The topic to publish to */ topic: ComponentTopic; - /** Component ID (for API calls) */ + /** Entity ID (for API calls) */ componentId: string; + /** Entity type for API endpoint */ + entityType?: SovdResourceEntityType; /** API client instance */ client: SovdApiClient; /** External initial value (overrides topic-based defaults) */ @@ -59,7 +61,14 @@ function getInitialValues(topic: ComponentTopic): Record { * Form for publishing messages to a ROS 2 topic * Supports both schema-based form view and raw JSON editing */ -export function TopicPublishForm({ topic, componentId, client, initialValue, onValueChange }: TopicPublishFormProps) { +export function TopicPublishForm({ + topic, + componentId, + entityType = 'components', + client, + initialValue, + onValueChange, +}: TopicPublishFormProps) { const [viewMode, setViewMode] = useState('form'); const [formValues, setFormValues] = useState>(() => { if (initialValue && typeof initialValue === 'object') { @@ -128,11 +137,9 @@ export function TopicPublishForm({ topic, componentId, client, initialValue, onV }; const handlePublish = async () => { - // Extract topic name - remove leading slash and component namespace prefix - // Full topic path example: "/powertrain/engine/temperature" - // We need just the last segment for the API: "temperature" - const topicSegments = topic.topic.split('/').filter((s) => s); - const topicName = topicSegments[topicSegments.length - 1] || topic.topic; + // Use the full topic path for the API, but strip leading slash for cleaner URL + // The backend adds it back if needed + const topicName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; // Validate and get data to publish let dataToPublish: unknown; @@ -158,7 +165,7 @@ export function TopicPublishForm({ topic, componentId, client, initialValue, onV setIsPublishing(true); try { - await client.publishToComponentTopic(componentId, topicName, { + await client.publishToEntityData(entityType, componentId, topicName, { type: messageType, data: dataToPublish, }); @@ -197,7 +204,7 @@ export function TopicPublishForm({ topic, componentId, client, initialValue, onV return (
{/* View mode toggle - only show if we have schema */} - {hasSchema && ( + {hasSchema ? (
+ ) : ( +
+ {topic.type ? ( + Type: {topic.type} (schema not available) + ) : ( + Message type unknown - topic may not exist on ROS 2 graph yet + )} +
)} {/* Form or JSON editor */} diff --git a/src/lib/schema-utils.ts b/src/lib/schema-utils.ts index 4e010b1..cb8714d 100644 --- a/src/lib/schema-utils.ts +++ b/src/lib/schema-utils.ts @@ -1,5 +1,102 @@ import type { SchemaFieldType, TopicSchema } from '@/lib/types'; +// ============================================================================= +// JSON Schema to TopicSchema Conversion +// ============================================================================= + +/** + * JSON Schema format returned by the API + */ +interface JsonSchemaField { + type?: string; + properties?: Record; + items?: JsonSchemaField; +} + +/** + * Map JSON Schema types to ROS 2 primitive types + */ +function mapJsonSchemaType(type: string | undefined): string { + if (!type) return 'object'; + switch (type) { + case 'integer': + return 'int32'; + case 'number': + return 'float64'; + case 'boolean': + return 'bool'; + case 'string': + return 'string'; + case 'array': + return 'array'; + case 'object': + return 'object'; + default: + return type; + } +} + +/** + * Convert a single JSON Schema field to SchemaFieldType + */ +function convertJsonSchemaField(field: JsonSchemaField): SchemaFieldType { + const result: SchemaFieldType = { + type: mapJsonSchemaType(field.type), + }; + + // Handle nested objects (properties -> fields) + if (field.properties) { + result.fields = {}; + for (const [key, value] of Object.entries(field.properties)) { + result.fields[key] = convertJsonSchemaField(value); + } + } + + // Handle arrays + if (field.items) { + result.items = convertJsonSchemaField(field.items); + } + + return result; +} + +/** + * Convert JSON Schema format (from API) to TopicSchema format (for frontend) + * + * API returns: + * ```json + * { "type": "object", "properties": { "field": { "type": "integer" } } } + * ``` + * + * Frontend expects: + * ```json + * { "field": { "type": "int32" } } + * ``` + */ +export function convertJsonSchemaToTopicSchema(jsonSchema: unknown): TopicSchema | undefined { + if (!jsonSchema || typeof jsonSchema !== 'object') { + return undefined; + } + + const schema = jsonSchema as JsonSchemaField; + + // If it has properties at root level, convert them + if (schema.properties) { + const result: TopicSchema = {}; + for (const [key, value] of Object.entries(schema.properties)) { + result[key] = convertJsonSchemaField(value); + } + return result; + } + + // Already in TopicSchema format or unknown format + return jsonSchema as TopicSchema; +} + +// ============================================================================= +// Type Checking Utilities +// ============================================================================= + /** * Check if a type is a primitive ROS 2 type */ diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index 3edc9e0..917a254 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -12,6 +12,7 @@ import type { ResetAllConfigurationsResponse, Operation, DataItemResponse, + Parameter, // New SOVD-compliant types Execution, CreateExecutionRequest, @@ -29,10 +30,23 @@ import type { ServerCapabilities, VersionInfo, SovdError, + SovdResourceEntityType, } from './types'; +import { convertJsonSchemaToTopicSchema } from './schema-utils'; -/** Entity types that support resource collections (data, operations, configurations, faults) */ -export type SovdResourceEntityType = 'areas' | 'components' | 'apps' | 'functions'; +// 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 @@ -111,6 +125,15 @@ function normalizeBasePath(path: string): string { 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 */ @@ -153,6 +176,154 @@ export class SovdApiClient { } } + // =========================================================================== + // 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 unwrapItems(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 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") @@ -268,98 +439,60 @@ export class SovdApiClient { * @param path Entity path (e.g., "/area/component") */ async getEntityDetails(path: string): Promise { - // Path comes from the tree, e.g. "/area_id/component_id" - const parts = path.split('/').filter((p) => p); + // Path comes from the tree, e.g. "/server/area_id/component_id" or "/area_id/component_id" + let parts = path.split('/').filter((p) => p); - // Handle virtual folder paths: /area/component/data/topic or /area/component/apps/app/data/topic - // Transform to: components/{component}/data/{topic} or apps/{app}/data/{topic} for API call - if (parts.length >= 4 && parts.includes('data')) { - const dataIndex = parts.indexOf('data'); - // Check if this is an app topic (path contains 'apps' before 'data') - const appsIndex = parts.indexOf('apps'); - const isAppTopic = appsIndex !== -1 && appsIndex < dataIndex; - - if (isAppTopic && dataIndex >= 2) { - // App topic: /area/component/apps/app/data/topic - const appId = parts[appsIndex + 1]!; - const encodedTopicName = parts[dataIndex + 1]!; - const decodedTopicName = decodeURIComponent(encodedTopicName); - - const response = await fetchWithTimeout(this.getUrl(`apps/${appId}/data/${encodedTopicName}`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error(`Topic ${decodedTopicName} not found for app ${appId}`); - } - throw new Error(`HTTP ${response.status}`); - } + // Remove 'server' prefix if present (tree paths start with /server) + if (parts[0] === 'server') { + parts = parts.slice(1); + } - // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} - const item = (await response.json()) as DataItemResponse; - const xMedkit = item['x-medkit']; - const ros2 = xMedkit?.ros2; - - const topic: ComponentTopic = { - topic: ros2?.topic || `/${decodedTopicName}`, - timestamp: xMedkit?.timestamp || Date.now() * 1000000, - data: item.data, - status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', - type: ros2?.type, - publisher_count: xMedkit?.publisher_count, - subscriber_count: xMedkit?.subscriber_count, - isPublisher: ros2?.direction === 'publish', - isSubscriber: ros2?.direction === 'subscribe', - }; + // 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]; - return { - id: encodedTopicName, - name: topic.topic, - href: path, - topicData: topic, - rosType: topic.type, - type: 'topic', - }; + if (!topicName) { + throw new Error('Invalid path: missing topic name after /data/'); } - // Component topic: /area/component/data/topic - const componentId = parts[1]!; - const encodedTopicName = parts[dataIndex + 1]!; - const decodedTopicName = decodeURIComponent(encodedTopicName); + const decodedTopicName = decodeURIComponent(topicName); - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data/${encodedTopicName}`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); + // Determine entity type and ID based on path structure + let entityType: SovdResourceEntityType; + let entityId: string; - if (!response.ok) { - if (response.status === 404) { - throw new Error(`Topic ${decodedTopicName} not found for component ${componentId}`); - } - throw new Error(`HTTP ${response.status}`); + 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]!; } - // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} - const item = (await response.json()) as DataItemResponse; - const xMedkit = item['x-medkit']; - const ros2 = xMedkit?.ros2; - - const topic: ComponentTopic = { - topic: ros2?.topic || `/${decodedTopicName}`, - timestamp: xMedkit?.timestamp || Date.now() * 1000000, - data: item.data, - status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', - type: ros2?.type, - publisher_count: xMedkit?.publisher_count, - subscriber_count: xMedkit?.subscriber_count, - isPublisher: ros2?.direction === 'publish', - isSubscriber: ros2?.direction === 'subscribe', - }; + // Use the generic getTopicDetails method + const topic = await this.getTopicDetails(entityType, entityId, decodedTopicName); return { - id: encodedTopicName, + id: topicName, name: topic.topic, href: path, topicData: topic, @@ -377,36 +510,8 @@ export class SovdApiClient { // e.g., 'powertrain%2Fengine%2Ftemp' -> 'powertrain/engine/temp' const decodedTopicName = decodeURIComponent(encodedTopicName); - // Use the dedicated single-topic endpoint - // The REST API expects percent-encoded topic name in the URL - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data/${encodedTopicName}`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error(`Topic ${decodedTopicName} not found for component ${componentId}`); - } - throw new Error(`HTTP ${response.status}`); - } - - // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} - const item = (await response.json()) as DataItemResponse; - const xMedkit = item['x-medkit']; - const ros2 = xMedkit?.ros2; - - const topic: ComponentTopic = { - topic: ros2?.topic || `/${decodedTopicName}`, - timestamp: xMedkit?.timestamp || Date.now() * 1000000, - data: item.data, - status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', - type: ros2?.type, - publisher_count: xMedkit?.publisher_count, - subscriber_count: xMedkit?.subscriber_count, - isPublisher: ros2?.direction === 'publish', - isSubscriber: ros2?.direction === 'subscribe', - }; + // Use the generic getTopicDetails method + const topic = await this.getTopicDetails('components', componentId, decodedTopicName); return { id: encodedTopicName, @@ -426,7 +531,8 @@ export class SovdApiClient { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const topicsData = unwrapItems(await response.json()); + // 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.) @@ -465,18 +571,20 @@ export class SovdApiClient { } /** - * Publish to a component topic - * @param componentId Component ID - * @param topicName Topic name (relative to component namespace) + * 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 publishToComponentTopic( - componentId: string, + async publishToEntityData( + entityType: SovdResourceEntityType, + entityId: string, topicName: string, request: ComponentTopicPublishRequest ): Promise { const response = await fetchWithTimeout( - this.getUrl(`components/${componentId}/data/${topicName}`), + this.getUrl(`${entityType}/${entityId}/data/${topicName}`), { method: 'PUT', headers: { @@ -542,40 +650,19 @@ export class SovdApiClient { entityId: string, entityType: SovdResourceEntityType = 'components' ): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/configurations`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; - }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); - } - - const data = await response.json(); - // API returns {items: [...], x-medkit: {parameters: [...]}} - // Transform to ComponentConfigurations format - const xMedkit = data['x-medkit'] || {}; - return { - component_id: xMedkit.entity_id || entityId, - node_name: xMedkit.ros2?.node || entityId, - parameters: xMedkit.parameters || [], - }; + return this.getResources(entityType, entityId, 'configurations'); } /** * Get a specific configuration (parameter) value and metadata - * @param entityId Entity ID (component or app) + * @param entityId Entity ID * @param paramName Parameter name - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async getConfiguration( entityId: string, paramName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), @@ -598,16 +685,16 @@ export class SovdApiClient { /** * Set a configuration (parameter) value - * @param entityId Entity ID (component or app) + * @param entityId Entity ID * @param paramName Parameter name * @param request Request with new value - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async setConfiguration( entityId: string, paramName: string, request: SetConfigurationRequest, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), @@ -634,14 +721,14 @@ export class SovdApiClient { /** * Reset a configuration (parameter) to its default value - * @param entityId Entity ID (component or app) + * @param entityId Entity ID * @param paramName Parameter name - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async resetConfiguration( entityId: string, paramName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), @@ -661,17 +748,19 @@ export class SovdApiClient { throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); } - return await response.json(); + // 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 (component or app) - * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity ID + * @param entityType Entity type */ async resetAllConfigurations( entityId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/configurations`), { method: 'DELETE', @@ -702,31 +791,19 @@ export class SovdApiClient { * @param entityId Entity ID */ async listOperations(entityId: string, entityType: SovdResourceEntityType = 'components'): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/operations`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - return []; - } - throw new Error(`HTTP ${response.status}`); - } - - return unwrapItems(await response.json()); + return this.getResources(entityType, entityId, 'operations'); } /** * Get details of a specific operation * @param entityId Entity ID * @param operationName Operation name - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async getOperation( entityId: string, operationName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}`), @@ -749,13 +826,13 @@ export class SovdApiClient { * @param entityId Entity ID (component or app) * @param operationName Operation name * @param request Execution request with input data - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async createExecution( entityId: string, operationName: string, request: CreateExecutionRequest, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions`), @@ -782,12 +859,12 @@ export class SovdApiClient { * List all executions for an operation * @param entityId Entity ID * @param operationName Operation name - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async listExecutions( entityId: string, operationName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions`), @@ -810,13 +887,13 @@ export class SovdApiClient { * @param entityId Entity ID * @param operationName Operation name * @param executionId Execution ID - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async getExecution( entityId: string, operationName: string, executionId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl( @@ -841,13 +918,13 @@ export class SovdApiClient { * @param entityId Entity ID * @param operationName Operation name * @param executionId Execution ID - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async cancelExecution( entityId: string, operationName: string, executionId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl( @@ -990,44 +1067,7 @@ export class SovdApiClient { * @param appId App identifier */ async getAppData(appId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`apps/${appId}/data`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - // API returns {items: [{id, name, category, x-medkit}]} - interface DataItem { - id: string; - name: string; - category?: string; - 'x-medkit'?: { ros2?: { topic?: string; direction?: string; type?: string } }; - } - const dataItems = unwrapItems(await response.json()); - - // Transform to ComponentTopic format - // NOTE: Same topic can appear twice with different directions (publish/subscribe) - // We include direction in the key to make them unique - return dataItems.map((item) => { - const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id; - const direction = item['x-medkit']?.ros2?.direction; - const type = item['x-medkit']?.ros2?.type; - return { - topic: topicName, - timestamp: Date.now() * 1000000, - data: null, - status: 'metadata_only' as const, - // Include direction for unique key generation - isPublisher: direction === 'publish', - isSubscriber: direction === 'subscribe', - // Include unique key combining topic and direction - uniqueKey: direction ? `${topicName}:${direction}` : topicName, - type, - }; - }); + return this.getResources('apps', appId, 'data'); } /** @@ -1072,7 +1112,7 @@ export class SovdApiClient { */ async publishToAppTopic(appId: string, topicName: string, request: ComponentTopicPublishRequest): Promise { const response = await fetchWithTimeout( - this.getUrl(`apps/${appId}/data/${encodeURIComponent(topicName)}`), + this.getUrl(`apps/${appId}/data/${topicName}`), { method: 'PUT', headers: { @@ -1170,16 +1210,7 @@ export class SovdApiClient { * @param functionId Function identifier */ async getFunctionData(functionId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/data`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - return []; - } - - return unwrapItems(await response.json()); + return this.getResources('functions', functionId, 'data'); } /** @@ -1187,16 +1218,7 @@ export class SovdApiClient { * @param functionId Function identifier */ async getFunctionOperations(functionId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/operations`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - return []; - } - - return unwrapItems(await response.json()); + return this.getResources('functions', functionId, 'operations'); } // =========================================================================== @@ -1210,39 +1232,65 @@ export class SovdApiClient { * @param entityId Entity identifier */ async getEntityData(entityType: SovdResourceEntityType, entityId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/data`), { + 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) { - if (response.status === 404) { - return []; - } - throw new Error(`HTTP ${response.status}`); + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); } - // API returns {items: [{id, name, category, x-medkit}]} format - interface DataItem { - id: string; - name: string; - category?: string; - 'x-medkit'?: { - ros2?: { topic?: string; type?: string; direction?: string }; - type_info?: { schema?: Record }; - }; - } - const dataItems = unwrapItems(await response.json()); - - // Transform to ComponentTopic format - return dataItems.map((item) => ({ - topic: item.name || item['x-medkit']?.ros2?.topic || item.id, - timestamp: Date.now() * 1000000, - data: null, - status: 'metadata_only' as const, - type: item['x-medkit']?.ros2?.type, - type_info: item['x-medkit']?.type_info, - })); + // 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', + }; } // =========================================================================== @@ -1335,32 +1383,16 @@ export class SovdApiClient { * @param entityId Entity identifier */ async listEntityFaults(entityType: SovdResourceEntityType, entityId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/faults`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - return { items: [], count: 0 }; - } - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); - const items = (data.items || []).map((f: unknown) => - this.transformFault(f as Parameters[0]) - ); - return { items, count: data['x-medkit']?.count || items.length }; + return this.getResources(entityType, entityId, 'faults'); } /** * Get a specific fault by code - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier * @param faultCode Fault code */ - async getFault(entityType: 'components' | 'apps', entityId: string, faultCode: string): Promise { + async getFault(entityType: SovdResourceEntityType, entityId: string, faultCode: string): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), { @@ -1379,11 +1411,11 @@ export class SovdApiClient { /** * Clear a specific fault - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier * @param faultCode Fault code */ - async clearFault(entityType: 'components' | 'apps', entityId: string, faultCode: string): Promise { + async clearFault(entityType: SovdResourceEntityType, entityId: string, faultCode: string): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), { @@ -1400,10 +1432,10 @@ export class SovdApiClient { /** * Clear all faults for an entity - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier */ - async clearAllFaults(entityType: 'components' | 'apps', entityId: string): Promise { + async clearAllFaults(entityType: SovdResourceEntityType, entityId: string): Promise { const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/faults`), { method: 'DELETE', headers: { Accept: 'application/json' }, @@ -1437,12 +1469,12 @@ export class SovdApiClient { /** * Get fault snapshots for a specific entity - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier * @param faultCode Fault code */ async getEntityFaultSnapshots( - entityType: 'components' | 'apps', + entityType: SovdResourceEntityType, entityId: string, faultCode: string ): Promise { diff --git a/src/lib/store.ts b/src/lib/store.ts index 97cb864..d34469d 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -17,7 +17,7 @@ import type { VersionInfo, SovdFunction, } from './types'; -import { createSovdClient, type SovdApiClient } from './sovd-api'; +import { createSovdClient, type SovdApiClient, type SovdResourceEntityType } from './sovd-api'; const STORAGE_KEY = 'sovd_web_ui_server_url'; @@ -73,44 +73,44 @@ export interface AppState { clearSelection: () => void; // Configurations actions - fetchConfigurations: (entityId: string, entityType?: 'components' | 'apps') => Promise; + fetchConfigurations: (entityId: string, entityType?: SovdResourceEntityType) => Promise; setParameter: ( entityId: string, paramName: string, value: unknown, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise; - resetParameter: (entityId: string, paramName: string, entityType?: 'components' | 'apps') => Promise; + resetParameter: (entityId: string, paramName: string, entityType?: SovdResourceEntityType) => Promise; resetAllConfigurations: ( entityId: string, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise<{ reset_count: number; failed_count: number }>; // Operations actions - updated for SOVD Execution model - fetchOperations: (entityId: string, entityType?: 'components' | 'apps') => Promise; + fetchOperations: (entityId: string, entityType?: SovdResourceEntityType) => Promise; createExecution: ( entityId: string, operationName: string, request: CreateExecutionRequest, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise; refreshExecutionStatus: ( entityId: string, operationName: string, executionId: string, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise; cancelExecution: ( entityId: string, operationName: string, executionId: string, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise; setAutoRefreshExecutions: (enabled: boolean) => void; // Faults actions fetchFaults: () => Promise; - clearFault: (entityType: 'components' | 'apps', entityId: string, faultCode: string) => Promise; + clearFault: (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => Promise; subscribeFaultStream: () => void; unsubscribeFaultStream: () => void; } @@ -869,7 +869,9 @@ export const useAppStore = create()( set({ isRefreshing: true }); try { - const details = await client.getEntityDetails(selectedPath); + // 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 { toast.error('Failed to refresh data'); @@ -889,7 +891,7 @@ export const useAppStore = create()( // CONFIGURATIONS ACTIONS (ROS 2 Parameters) // =========================================================================== - fetchConfigurations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { + fetchConfigurations: async (entityId: string, entityType: SovdResourceEntityType = 'components') => { const { client, configurations } = get(); if (!client) return; @@ -911,7 +913,7 @@ export const useAppStore = create()( entityId: string, paramName: string, value: unknown, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { const { client, configurations } = get(); if (!client) return false; @@ -965,20 +967,16 @@ export const useAppStore = create()( resetParameter: async ( entityId: string, paramName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { - const { client, configurations } = get(); + const { client, fetchConfigurations } = get(); if (!client) return false; try { - const result = await client.resetConfiguration(entityId, paramName, entityType); + await client.resetConfiguration(entityId, paramName, entityType); - // Update local state with reset value - const newConfigs = new Map(configurations); - const params = newConfigs.get(entityId) || []; - const updatedParams = params.map((p) => (p.name === paramName ? { ...p, value: result.value } : p)); - newConfigs.set(entityId, updatedParams); - set({ configurations: newConfigs }); + // Refetch configurations to get updated value after reset + await fetchConfigurations(entityId, entityType); toast.success(`Parameter ${paramName} reset to default`); return true; } catch (error) { @@ -988,7 +986,7 @@ export const useAppStore = create()( } }, - resetAllConfigurations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { + resetAllConfigurations: async (entityId: string, entityType: SovdResourceEntityType = 'components') => { const { client, fetchConfigurations } = get(); if (!client) return { reset_count: 0, failed_count: 0 }; @@ -1016,7 +1014,7 @@ export const useAppStore = create()( // OPERATIONS ACTIONS (ROS 2 Services & Actions) - SOVD Execution Model // =========================================================================== - fetchOperations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { + fetchOperations: async (entityId: string, entityType: SovdResourceEntityType = 'components') => { const { client, operations } = get(); if (!client) return; @@ -1038,7 +1036,7 @@ export const useAppStore = create()( entityId: string, operationName: string, request: CreateExecutionRequest, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { const { client, activeExecutions } = get(); if (!client) return null; @@ -1075,7 +1073,7 @@ export const useAppStore = create()( entityId: string, operationName: string, executionId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { const { client, activeExecutions } = get(); if (!client) return; @@ -1095,7 +1093,7 @@ export const useAppStore = create()( entityId: string, operationName: string, executionId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { const { client, activeExecutions } = get(); if (!client) return false; @@ -1138,7 +1136,7 @@ export const useAppStore = create()( } }, - clearFault: async (entityType: 'components' | 'apps', entityId: string, faultCode: string) => { + clearFault: async (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => { const { client, fetchFaults } = get(); if (!client) return false; diff --git a/src/lib/types.ts b/src/lib/types.ts index ab62b68..4db0171 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2,6 +2,11 @@ * SOVD Entity types for discovery endpoints */ +/** + * SOVD Resource Entity Type for API endpoints + */ +export type SovdResourceEntityType = 'areas' | 'components' | 'apps' | 'functions'; + /** * QoS profile for a topic endpoint */ @@ -155,11 +160,11 @@ export interface ComponentTopic { } /** - * API response for data item (topic) from GET /components/{id}/data/{topic} + * API response for data item from GET /components/{id}/data/{dataId} * This is the raw response structure from the gateway API. */ export interface DataItemResponse { - /** Topic data payload */ + /** Data payload */ data: unknown; /** Item identifier */ id: string; @@ -174,6 +179,15 @@ export interface DataItemResponse { status?: string; publisher_count?: number; subscriber_count?: number; + /** Publisher endpoint information */ + publishers?: TopicEndpoint[]; + /** Subscriber endpoint information */ + subscribers?: TopicEndpoint[]; + /** Type information including schema (JSON schema for message fields) */ + type_info?: { + schema?: Record; + default_value?: unknown; + }; }; } From 6caa50412a79400e9924de52c4cfaabe9e8a6178 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 28 Jan 2026 20:21:41 +0000 Subject: [PATCH 20/26] refactor: rename componentId to entityId in resource panels - Rename componentId prop to entityId in all resource panels: - OperationsPanel, DataPanel, ConfigurationPanel, FaultsPanel - ActionStatusPanel, TopicPublishForm - Rename componentOperations to entityOperations for consistency - Update all panel usages in EntityDetailPanel, AppsPanel, FunctionsPanel, EntityResourceTabs - Fix operations API transformation to extract kind/type from x-medkit.ros2 - Add transformOperationsResponse() to properly parse SOVD API response This aligns naming with the universal entity model (areas, components, apps, functions) instead of component-specific terminology. --- .github/copilot-instructions.md | 3 +- src/components/ActionStatusPanel.tsx | 28 +++-- src/components/AppsPanel.tsx | 57 +---------- src/components/ConfigurationPanel.tsx | 40 ++++---- src/components/DataFolderPanel.tsx | 136 ------------------------- src/components/DataPanel.tsx | 6 +- src/components/EntityDetailPanel.tsx | 120 ++++++++++++++-------- src/components/EntityResourceTabs.tsx | 56 +--------- src/components/FaultsPanel.tsx | 12 +-- src/components/FunctionsPanel.tsx | 53 +--------- src/components/OperationsPanel.tsx | 48 +++++---- src/components/TopicPublishForm.tsx | 6 +- src/lib/sovd-api.ts | 141 +++++++++++++++++++++++++- src/lib/store.ts | 11 +- 14 files changed, 311 insertions(+), 406 deletions(-) delete mode 100644 src/components/DataFolderPanel.tsx diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c852eb..0c0fc07 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,8 +13,7 @@ src/ │ ├── EntityTreeSidebar.tsx # Main navigation tree │ ├── EntityDetailPanel.tsx # Entity details view │ ├── OperationsPanel.tsx # ROS 2 service/action invocation -│ ├── ConfigurationPanel.tsx # ROS 2 parameter management -│ └── DataFolderPanel.tsx # Topic subscriptions +│ └── ConfigurationPanel.tsx # ROS 2 parameter management ├── lib/ │ ├── sovd-api.ts # Typed HTTP client for gateway REST API │ ├── store.ts # Zustand state management diff --git a/src/components/ActionStatusPanel.tsx b/src/components/ActionStatusPanel.tsx index d892047..3fde416 100644 --- a/src/components/ActionStatusPanel.tsx +++ b/src/components/ActionStatusPanel.tsx @@ -5,12 +5,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useAppStore, type AppState } from '@/lib/store'; -import type { ExecutionStatus } from '@/lib/types'; +import type { ExecutionStatus, SovdResourceEntityType } from '@/lib/types'; interface ActionStatusPanelProps { - componentId: string; + entityId: string; operationName: string; executionId: string; + entityType?: SovdResourceEntityType; } /** @@ -77,7 +78,12 @@ function isActiveStatus(status: ExecutionStatus): boolean { return ['pending', 'running'].includes(status); } -export function ActionStatusPanel({ componentId, operationName, executionId }: ActionStatusPanelProps) { +export function ActionStatusPanel({ + entityId, + operationName, + executionId, + entityType = 'components', +}: ActionStatusPanelProps) { const { activeExecutions, autoRefreshExecutions, @@ -103,31 +109,31 @@ export function ActionStatusPanel({ componentId, operationName, executionId }: A // Manual refresh const handleRefresh = useCallback(() => { - refreshExecutionStatus(componentId, operationName, executionId); - }, [componentId, operationName, executionId, refreshExecutionStatus]); + refreshExecutionStatus(entityId, operationName, executionId, entityType); + }, [entityId, operationName, executionId, refreshExecutionStatus, entityType]); // Cancel action const handleCancel = useCallback(async () => { - await cancelExecution(componentId, operationName, executionId); - }, [componentId, operationName, executionId, cancelExecution]); + await cancelExecution(entityId, operationName, executionId, entityType); + }, [entityId, operationName, executionId, cancelExecution, entityType]); // Auto-refresh effect useEffect(() => { if (!autoRefreshExecutions || isTerminal) return; const interval = setInterval(() => { - refreshExecutionStatus(componentId, operationName, executionId); + refreshExecutionStatus(entityId, operationName, executionId, entityType); }, 1000); // Refresh every second return () => clearInterval(interval); - }, [autoRefreshExecutions, isTerminal, componentId, operationName, executionId, refreshExecutionStatus]); + }, [autoRefreshExecutions, isTerminal, entityId, operationName, executionId, refreshExecutionStatus, entityType]); // Initial fetch useEffect(() => { if (!execution) { - refreshExecutionStatus(componentId, operationName, executionId); + refreshExecutionStatus(entityId, operationName, executionId, entityType); } - }, [executionId, execution, componentId, operationName, refreshExecutionStatus]); + }, [executionId, execution, entityId, operationName, refreshExecutionStatus, entityType]); if (!execution) { return ( diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index 255cbf0..ff22272 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { useAppStore } from '@/lib/store'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { FaultsPanel } from '@/components/FaultsPanel'; +import { OperationsPanel } from '@/components/OperationsPanel'; import type { ComponentTopic, Operation, Fault } from '@/lib/types'; type AppTab = 'overview' | 'data' | 'operations' | 'configurations' | 'faults'; @@ -98,8 +99,6 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI // Count resources for badges const publishTopics = topics.filter((t) => t.isPublisher); const subscribeTopics = topics.filter((t) => t.isSubscriber); - const services = operations.filter((o) => o.kind === 'service'); - const actions = operations.filter((o) => o.kind === 'action'); const activeFaults = faults.filter((f) => f.status === 'active'); return ( @@ -313,59 +312,11 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI )} - {activeTab === 'operations' && ( - - - - - Operations - - - {services.length} services, {actions.length} actions - - - - {operations.length === 0 ? ( -
- No operations available for this app. -
- ) : ( -
- {operations.map((op) => { - const opPath = `${path}/operations/${encodeURIComponent(op.name)}`; - return ( -
handleResourceClick(opPath)} - > - - {op.kind} - - {op.name} - - {op.type} - - -
- ); - })} -
- )} -
-
- )} + {activeTab === 'operations' && } - {activeTab === 'configurations' && } + {activeTab === 'configurations' && } - {activeTab === 'faults' && } + {activeTab === 'faults' && } {isLoading &&
Loading app resources...
}
diff --git a/src/components/ConfigurationPanel.tsx b/src/components/ConfigurationPanel.tsx index 7a01dfd..d08dfcf 100644 --- a/src/components/ConfigurationPanel.tsx +++ b/src/components/ConfigurationPanel.tsx @@ -10,7 +10,7 @@ import type { Parameter, ParameterType } from '@/lib/types'; import type { SovdResourceEntityType } from '@/lib/sovd-api'; interface ConfigurationPanelProps { - componentId: string; + entityId: string; /** Optional parameter name to highlight */ highlightParam?: string; /** Entity type for API calls */ @@ -262,11 +262,7 @@ function parseValue(input: string, type: ParameterType): unknown { } } -export function ConfigurationPanel({ - componentId, - highlightParam, - entityType = 'components', -}: ConfigurationPanelProps) { +export function ConfigurationPanel({ entityId, highlightParam, entityType = 'components' }: ConfigurationPanelProps) { const { configurations, isLoadingConfigurations, @@ -286,44 +282,44 @@ export function ConfigurationPanel({ ); const [isResettingAll, setIsResettingAll] = useState(false); - const parameters = configurations.get(componentId) || []; - const prevComponentIdRef = useRef(null); + const parameters = configurations.get(entityId) || []; + const prevEntityIdRef = useRef(null); - // Fetch configurations on mount and when componentId changes + // Fetch configurations on mount and when entityId changes useEffect(() => { - // Always fetch if componentId changed, or if not yet loaded - if (prevComponentIdRef.current !== componentId || !configurations.has(componentId)) { - fetchConfigurations(componentId, entityType); + // Always fetch if entityId changed, or if not yet loaded + if (prevEntityIdRef.current !== entityId || !configurations.has(entityId)) { + fetchConfigurations(entityId, entityType); } - prevComponentIdRef.current = componentId; - }, [componentId, entityType, fetchConfigurations, configurations]); + prevEntityIdRef.current = entityId; + }, [entityId, entityType, fetchConfigurations, configurations]); const handleRefresh = useCallback(() => { - fetchConfigurations(componentId, entityType); - }, [componentId, fetchConfigurations, entityType]); + fetchConfigurations(entityId, entityType); + }, [entityId, fetchConfigurations, entityType]); const handleSetParameter = useCallback( async (name: string, value: unknown) => { - return setParameter(componentId, name, value, entityType); + return setParameter(entityId, name, value, entityType); }, - [componentId, setParameter, entityType] + [entityId, setParameter, entityType] ); const handleResetParameter = useCallback( async (name: string) => { - return resetParameter(componentId, name, entityType); + return resetParameter(entityId, name, entityType); }, - [componentId, resetParameter, entityType] + [entityId, resetParameter, entityType] ); const handleResetAll = useCallback(async () => { setIsResettingAll(true); try { - await resetAllConfigurations(componentId, entityType); + await resetAllConfigurations(entityId, entityType); } finally { setIsResettingAll(false); } - }, [componentId, resetAllConfigurations, entityType]); + }, [entityId, resetAllConfigurations, entityType]); if (isLoadingConfigurations && parameters.length === 0) { return ( diff --git a/src/components/DataFolderPanel.tsx b/src/components/DataFolderPanel.tsx deleted file mode 100644 index 7e82730..0000000 --- a/src/components/DataFolderPanel.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useEffect, useCallback } from 'react'; -import { useShallow } from 'zustand/shallow'; -import { Database, Loader2, RefreshCw, Radio, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { useAppStore, type AppState } from '@/lib/store'; - -interface DataFolderPanelProps { - /** Base path for navigation (e.g., /root/route_server) */ - basePath: string; -} - -export function DataFolderPanel({ basePath }: DataFolderPanelProps) { - const { rootEntities, selectEntity, loadChildren, expandedPaths, toggleExpanded } = useAppStore( - useShallow((state: AppState) => ({ - client: state.client, - rootEntities: state.rootEntities, - selectEntity: state.selectEntity, - loadChildren: state.loadChildren, - expandedPaths: state.expandedPaths, - toggleExpanded: state.toggleExpanded, - })) - ); - - // Find the data folder node in the tree - const dataFolderPath = `${basePath}/data`; - const findNode = useCallback((nodes: typeof rootEntities, path: string): (typeof rootEntities)[0] | null => { - for (const node of nodes) { - if (node.path === path) return node; - if (node.children) { - const found = findNode(node.children, path); - if (found) return found; - } - } - return null; - }, []); - - const dataFolder = findNode(rootEntities, dataFolderPath); - const topics = dataFolder?.children || []; - const isLoading = !dataFolder?.children && dataFolder !== null; - - // Load children if not loaded yet - useEffect(() => { - if (dataFolder && !dataFolder.children) { - loadChildren(dataFolderPath); - } - }, [dataFolder, dataFolderPath, loadChildren]); - - const handleRefresh = useCallback(() => { - loadChildren(dataFolderPath); - }, [dataFolderPath, loadChildren]); - - const handleTopicClick = useCallback( - (topicPath: string) => { - // Expand the data folder if not expanded - if (!expandedPaths.includes(dataFolderPath)) { - toggleExpanded(dataFolderPath); - } - // Navigate to topic - selectEntity(topicPath); - }, - [dataFolderPath, expandedPaths, toggleExpanded, selectEntity] - ); - - if (isLoading) { - return ( - - - - - - ); - } - - return ( - - -
-
- - Data - ({topics.length} items) -
- -
-
- - {topics.length === 0 ? ( -
No data available for this component.
- ) : ( -
- {topics.map((topic) => { - // Extract direction info from topic data - const topicData = topic.data as - | { isPublisher?: boolean; isSubscriber?: boolean; type?: string } - | undefined; - const isPublisher = topicData?.isPublisher ?? false; - const isSubscriber = topicData?.isSubscriber ?? false; - const topicType = topicData?.type || 'Unknown'; - - return ( -
handleTopicClick(topic.path)} - > - -
-
{topic.name}
-
{topicType}
-
- {/* Direction indicators */} -
- {isPublisher && ( - - - - )} - {isSubscriber && ( - - - - )} -
- -
- ); - })} -
- )} -
-
- ); -} diff --git a/src/components/DataPanel.tsx b/src/components/DataPanel.tsx index 8d37cf5..ca4262a 100644 --- a/src/components/DataPanel.tsx +++ b/src/components/DataPanel.tsx @@ -14,7 +14,7 @@ interface DataPanelProps { /** Data item from the API */ topic: ComponentTopic; /** Entity ID for publishing */ - componentId: string; + entityId: string; /** Entity type for API endpoint */ entityType?: SovdResourceEntityType; /** API client for publishing */ @@ -194,7 +194,7 @@ function QosDetails({ publishers, subscribers }: { publishers?: TopicEndpoint[]; */ export function DataPanel({ topic, - componentId, + entityId, entityType = 'components', client, isRefreshing = false, @@ -278,7 +278,7 @@ export function DataPanel({ Publish Message ; hasTopicsInfo: boolean; @@ -106,7 +106,7 @@ interface ComponentTabContentProps { function ComponentTabContent({ activeTab, - componentId, + entityId, selectedPath, selectedEntity, hasTopicsInfo, @@ -126,11 +126,11 @@ function ComponentTabContent({ /> ); case 'operations': - return ; + return ; case 'configurations': - return ; + return ; case 'faults': - return ; + return ; default: return null; } @@ -288,12 +288,13 @@ function DataTabContent({ */ interface OperationDetailCardProps { entity: NonNullable; - componentId: string; + entityId: string; + entityType: SovdResourceEntityType; } -function OperationDetailCard({ entity, componentId }: OperationDetailCardProps) { +function OperationDetailCard({ entity, entityId, entityType }: OperationDetailCardProps) { // Render full OperationsPanel with the specific operation highlighted - return ; + return ; } /** @@ -301,11 +302,11 @@ function OperationDetailCard({ entity, componentId }: OperationDetailCardProps) */ interface ParameterDetailCardProps { entity: NonNullable; - componentId: string; + entityId: string; entityType: SovdResourceEntityType; } -function ParameterDetailCard({ entity, componentId, entityType }: ParameterDetailCardProps) { +function ParameterDetailCard({ entity, entityId, entityType }: ParameterDetailCardProps) { const parameterData = entity.data as Parameter | undefined; if (!parameterData) { @@ -337,7 +338,7 @@ function ParameterDetailCard({ entity, componentId, entityType }: ParameterDetai
- +
); @@ -510,15 +511,55 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit (selectedEntity.topicsInfo.subscribes?.length ?? 0) > 0); const hasError = !!selectedEntity.error; - // Extract component ID from path for component/topic views + // Extract component ID from path for component/topic/operation views const pathParts = selectedPath.split('/').filter(Boolean); - // For topics, the path is like: /area/component/data/topicName - // We need to find the component ID (segment before /data/) + // For topics, the path is like: /server/area/component/data/topicName + // For operations, the path is like: /server/area/component/operations/opName + // or /server/function_name/operations/opName const dataIndex = pathParts.indexOf('data'); - const parentComponentId = dataIndex > 0 ? pathParts[dataIndex - 1] : null; - // Use parent component ID for topics, otherwise use entity's own ID - const componentId = isTopic && parentComponentId ? parentComponentId : selectedEntity.id; - const entityType = getEntityTypeFromPath(selectedPath); + const opsIndex = pathParts.indexOf('operations'); + const configIndex = pathParts.indexOf('configurations'); + + // Extract parent entity ID from path based on resource type + let parentEntityId: string | null = null; + if (dataIndex > 0) { + parentEntityId = pathParts[dataIndex - 1] ?? null; + } else if (opsIndex > 0) { + parentEntityId = pathParts[opsIndex - 1] ?? null; + } else if (configIndex > 0) { + parentEntityId = pathParts[configIndex - 1] ?? null; + } + + // Determine if this is an operation or parameter (resource-level entity) + const isOperationOrParam = + selectedEntity.type === 'service' || + selectedEntity.type === 'action' || + selectedEntity.type === 'parameter'; + + // Get entityId: prefer from entity (set by API for operations), then from path, then entity's own ID + const entityId = + (selectedEntity.componentId as string | undefined) ?? + ((isTopic || isOperationOrParam) && parentEntityId ? parentEntityId : null) ?? + selectedEntity.id; + + // Get entityType: prefer from entity (set by API for operations), then infer from type/path + let entityType: SovdResourceEntityType = + (selectedEntity.entityType as SovdResourceEntityType | undefined) ?? + getEntityTypeForApi(selectedEntity.type); + + // Fallback inference for operations/parameters when entityType not in entity + if (isOperationOrParam && !selectedEntity.entityType && parentEntityId) { + // Check if parent is a function (path: /server/function_name/operations/...) + // vs component/app/area based on path depth + // Functions: pathParts = ['server', 'func_name', 'operations', 'op_name'] - opsIndex is 2 + const resourceIndex = Math.max(dataIndex, opsIndex, configIndex); + if (resourceIndex === 2) { + // Short path: /server/entity/resource/name - could be function or area + // Check if the parent ID looks like a function (has underscore pattern) or area + entityType = 'functions'; // Default to functions for short paths + } + // Otherwise keep default (components) + } // Get icon for entity type const getEntityTypeIcon = () => { @@ -742,7 +783,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit - ) : isArea || isApp || isFunction || isServer ? null : selectedEntity.type === 'action' ? ( // Already handled above with specialized panels + ) : isArea || isApp || isFunction || isServer ? null : selectedEntity.type === 'action' || + selectedEntity.type === 'service' ? ( // Already handled above with specialized panels // Service/Action detail view - + ) : selectedEntity.type === 'parameter' ? ( // Parameter detail view - + ) : ( diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index 1abbf8e..dba4856 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare, Clock } from 'lucide-react'; +import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; +import { OperationsPanel } from '@/components/OperationsPanel'; import type { SovdResourceEntityType } from '@/lib/sovd-api'; import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; @@ -85,10 +86,6 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate } }; - // Count resources for badges - const services = operations.filter((o) => o.kind === 'service'); - const actions = operations.filter((o) => o.kind === 'action'); - return (
{/* Tab Navigation */} @@ -181,56 +178,11 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate )} {/* Operations Tab */} - {activeTab === 'operations' && ( - - - - - Operations - - - {services.length} services, {actions.length} actions - - - - {operations.length === 0 ? ( -
- -

No operations available.

-
- ) : ( -
- {operations.map((op) => ( -
{ - const navPath = basePath - ? `${basePath}/operations/${encodeURIComponent(op.name)}` - : `/${entityType}/${entityId}/operations/${encodeURIComponent(op.name)}`; - handleNavigate(navPath); - }} - > - {op.kind === 'service' ? ( - - ) : ( - - )} - {op.name} - - {op.kind} - -
- ))} -
- )} -
-
- )} + {activeTab === 'operations' && } {/* Configurations Tab */} {activeTab === 'configurations' && ( - + )} {/* Faults Tab */} diff --git a/src/components/FaultsPanel.tsx b/src/components/FaultsPanel.tsx index d7b8be5..48f9af2 100644 --- a/src/components/FaultsPanel.tsx +++ b/src/components/FaultsPanel.tsx @@ -9,7 +9,7 @@ import type { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; import type { SovdResourceEntityType } from '@/lib/sovd-api'; interface FaultsPanelProps { - componentId: string; + entityId: string; /** Type of entity */ entityType?: SovdResourceEntityType; } @@ -144,7 +144,7 @@ function FaultRow({ /** * Panel displaying faults for a component or app */ -export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPanelProps) { +export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanelProps) { const [faults, setFaults] = useState([]); const [isLoading, setIsLoading] = useState(true); const [clearingCodes, setClearingCodes] = useState>(new Set()); @@ -163,7 +163,7 @@ export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPa setError(null); try { - const response = await client.listEntityFaults(entityType, componentId); + const response = await client.listEntityFaults(entityType, entityId); setFaults(response.items || []); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load faults'); @@ -171,7 +171,7 @@ export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPa } finally { setIsLoading(false); } - }, [client, componentId, entityType]); + }, [client, entityId, entityType]); useEffect(() => { loadFaults(); @@ -184,7 +184,7 @@ export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPa setClearingCodes((prev) => new Set([...prev, code])); try { - await client.clearFault(entityType, componentId, code); + await client.clearFault(entityType, entityId, code); // Reload faults after clearing await loadFaults(); } catch { @@ -197,7 +197,7 @@ export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPa }); } }, - [client, componentId, entityType, loadFaults] + [client, entityId, entityType, loadFaults] ); // Count faults by severity diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx index b74bc3d..917ae09 100644 --- a/src/components/FunctionsPanel.tsx +++ b/src/components/FunctionsPanel.tsx @@ -16,6 +16,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; +import { OperationsPanel } from '@/components/OperationsPanel'; import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; /** Host app object returned from /functions/{id}/hosts */ @@ -128,10 +129,6 @@ export function FunctionsPanel({ functionId, functionName, description, path, on } }; - // Count resources for badges - const services = operations.filter((o) => o.kind === 'service'); - const actions = operations.filter((o) => o.kind === 'action'); - return (
{/* Function Header */} @@ -353,53 +350,9 @@ export function FunctionsPanel({ functionId, functionName, description, path, on )} - {activeTab === 'operations' && ( - - - - - Aggregated Operations - - - {services.length} services, {actions.length} actions from all hosts - - - - {operations.length === 0 ? ( -
- -

No operations available.

-
- ) : ( -
- {operations.map((op) => ( -
- - {op.kind} - - {op.name} - - {op.type} - -
- ))} -
- )} -
-
- )} + {activeTab === 'operations' && } - {activeTab === 'configurations' && } + {activeTab === 'configurations' && } {activeTab === 'faults' && ( diff --git a/src/components/OperationsPanel.tsx b/src/components/OperationsPanel.tsx index 12ff422..b2fd2e9 100644 --- a/src/components/OperationsPanel.tsx +++ b/src/components/OperationsPanel.tsx @@ -43,7 +43,7 @@ interface OperationHistoryEntry { } interface OperationsPanelProps { - componentId: string; + entityId: string; /** Optional: highlight and auto-expand a specific operation */ highlightOperation?: string; /** Entity type for API calls */ @@ -107,14 +107,16 @@ function isEmptySchema(schema: TopicSchema | null): boolean { */ function OperationRow({ operation, - componentId, + entityId, onInvoke, defaultExpanded = false, + entityType = 'components', }: { operation: Operation; - componentId: string; + entityId: string; onInvoke: (opName: string, payload: unknown) => Promise; defaultExpanded?: boolean; + entityType?: SovdResourceEntityType; }) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [useFormView, setUseFormView] = useState(true); @@ -357,9 +359,10 @@ function OperationRow({ {/* Action status monitoring for latest action */} {latestExecutionId && operation.kind === 'action' && ( )} @@ -428,7 +431,7 @@ function OperationRow({ ); } -export function OperationsPanel({ componentId, highlightOperation, entityType = 'components' }: OperationsPanelProps) { +export function OperationsPanel({ entityId, highlightOperation, entityType = 'components' }: OperationsPanelProps) { const { operations, isLoadingOperations, fetchOperations, createExecution } = useAppStore( useShallow((state: AppState) => ({ operations: state.operations, @@ -438,29 +441,28 @@ export function OperationsPanel({ componentId, highlightOperation, entityType = })) ); - const componentOperations = operations.get(componentId) || []; - const services = componentOperations.filter((op) => op.kind === 'service'); - const actions = componentOperations.filter((op) => op.kind === 'action'); + const entityOperations = operations.get(entityId) || []; + const services = entityOperations.filter((op) => op.kind === 'service'); + const actions = entityOperations.filter((op) => op.kind === 'action'); - // Fetch operations on mount (lazy loading) + // Fetch operations on mount or when entityId/entityType changes useEffect(() => { - if (!operations.has(componentId)) { - fetchOperations(componentId, entityType); - } - }, [componentId, operations, fetchOperations, entityType]); + // Always fetch when entityId or entityType changes to ensure fresh data + fetchOperations(entityId, entityType); + }, [entityId, entityType, fetchOperations]); const handleRefresh = useCallback(() => { - fetchOperations(componentId, entityType); - }, [componentId, fetchOperations, entityType]); + fetchOperations(entityId, entityType); + }, [entityId, fetchOperations, entityType]); const handleInvoke = useCallback( async (opName: string, payload: unknown) => { - return createExecution(componentId, opName, payload as Parameters[2], entityType); + return createExecution(entityId, opName, payload as Parameters[2], entityType); }, - [componentId, createExecution, entityType] + [entityId, createExecution, entityType] ); - if (isLoadingOperations && componentOperations.length === 0) { + if (isLoadingOperations && entityOperations.length === 0) { return ( @@ -487,11 +489,11 @@ export function OperationsPanel({ componentId, highlightOperation, entityType =
- {componentOperations.length === 0 ? ( + {entityOperations.length === 0 ? (

No operations available

-

This component has no services or actions

+

This entity has no services or actions

) : (
@@ -507,9 +509,10 @@ export function OperationsPanel({ componentId, highlightOperation, entityType = ))}
@@ -528,9 +531,10 @@ export function OperationsPanel({ componentId, highlightOperation, entityType = ))}
diff --git a/src/components/TopicPublishForm.tsx b/src/components/TopicPublishForm.tsx index ea0de5a..78be907 100644 --- a/src/components/TopicPublishForm.tsx +++ b/src/components/TopicPublishForm.tsx @@ -12,7 +12,7 @@ interface TopicPublishFormProps { /** The topic to publish to */ topic: ComponentTopic; /** Entity ID (for API calls) */ - componentId: string; + entityId: string; /** Entity type for API endpoint */ entityType?: SovdResourceEntityType; /** API client instance */ @@ -63,7 +63,7 @@ function getInitialValues(topic: ComponentTopic): Record { */ export function TopicPublishForm({ topic, - componentId, + entityId, entityType = 'components', client, initialValue, @@ -165,7 +165,7 @@ export function TopicPublishForm({ setIsPublishing(true); try { - await client.publishToEntityData(entityType, componentId, topicName, { + await client.publishToEntityData(entityType, entityId, topicName, { type: messageType, data: dataToPublish, }); diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index 917a254..a879a8c 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -11,6 +11,7 @@ import type { ResetConfigurationResponse, ResetAllConfigurationsResponse, Operation, + OperationKind, DataItemResponse, Parameter, // New SOVD-compliant types @@ -236,7 +237,7 @@ export class SovdApiClient { case 'data': return this.transformDataResponse(rawData) as ResourceListResults[T]; case 'operations': - return unwrapItems(rawData) as ResourceListResults[T]; + return this.transformOperationsResponse(rawData) as ResourceListResults[T]; case 'configurations': return this.transformConfigurationsResponse(rawData, entityId) as ResourceListResults[T]; case 'faults': @@ -246,6 +247,89 @@ export class SovdApiClient { } } + /** + * 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[] */ @@ -501,6 +585,61 @@ export class SovdApiClient { }; } + // 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]!; diff --git a/src/lib/store.ts b/src/lib/store.ts index d34469d..cfcf333 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -45,11 +45,11 @@ export interface AppState { isRefreshing: boolean; // Configurations state (ROS 2 Parameters) - configurations: Map; // componentId -> parameters + configurations: Map; // entityId -> parameters isLoadingConfigurations: boolean; // Operations state (ROS 2 Services & Actions) - operations: Map; // componentId -> operations + operations: Map; // entityId -> operations isLoadingOperations: boolean; // Active executions (for monitoring async actions) - SOVD Execution Model @@ -799,9 +799,12 @@ export const useAppStore = create()( // Handle service/action selection - show operation detail with data from tree if (node && (node.type === 'service' || node.type === 'action') && node.data) { - // Extract componentId from path: /area/component/operations/opName + // Extract componentId from path: /server/area/component/operations/opName + // or /server/function_name/operations/opName const pathSegments = path.split('/').filter(Boolean); - const componentId = (pathSegments.length >= 2 ? pathSegments[1] : pathSegments[0]) ?? ''; + const opsIndex = pathSegments.indexOf('operations'); + // Component/function ID is the segment right before 'operations' + const componentId = opsIndex > 0 ? pathSegments[opsIndex - 1] : (pathSegments[0] ?? ''); set({ selectedPath: path, From dd588291fade4f6e3ce64561f8001a4a9e158fed Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 28 Jan 2026 20:43:58 +0000 Subject: [PATCH 21/26] fix: unify FaultsPanel across all entity views and fix fault clearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EntityResourceTabs: replace inline faults card with FaultsPanel component - FunctionsPanel: replace inline faults card with FaultsPanel component - FaultsDashboard: add mapFaultEntityTypeToResourceType helper for proper API routing - FaultsDashboard: use store's clearFault for consistent error handling - sovd-api: fix transformFault to convert node names to SOVD app IDs (diagnostic_bridge → diagnostic-bridge) and set entity_type to 'app' This ensures consistent fault UI with clear buttons across areas, components, apps, and functions, and fixes 404 errors when clearing faults from dashboard. --- src/components/EntityResourceTabs.tsx | 50 ++------------------------- src/components/FaultsDashboard.tsx | 30 ++++++++++------ src/components/FunctionsPanel.tsx | 50 ++------------------------- src/lib/sovd-api.ts | 9 +++-- 4 files changed, 31 insertions(+), 108 deletions(-) diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index dba4856..f92de96 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'; 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'; @@ -186,54 +187,7 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate )} {/* Faults Tab */} - {activeTab === 'faults' && ( - - - - - Faults - - Active faults from child entities - - - {faults.length === 0 ? ( -
- -

No active faults.

-
- ) : ( -
- {faults.map((fault) => ( -
- -
-
{fault.code}
-
- {fault.message} -
-
- - {fault.severity} - -
- ))} -
- )} -
-
- )} + {activeTab === 'faults' && } )}
diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx index e0b9ebf..1b858d1 100644 --- a/src/components/FaultsDashboard.tsx +++ b/src/components/FaultsDashboard.tsx @@ -29,12 +29,25 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { Skeleton } from '@/components/ui/skeleton'; import { useAppStore } from '@/lib/store'; import type { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; /** * Default polling interval in milliseconds */ const DEFAULT_POLL_INTERVAL = 5000; +/** + * Map fault entity_type (may be singular or plural) to SovdResourceEntityType (always plural) + */ +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'; + // Default to components for 'component', 'components', or unknown types + return 'components'; +} + /** * Get badge variant for fault severity */ @@ -275,10 +288,11 @@ export function FaultsDashboard() { const [statusFilters, setStatusFilters] = useState>(new Set(['active', 'pending'])); const [groupByEntity, setGroupByEntity] = useState(true); - const { client, isConnected } = useAppStore( + const { client, isConnected, clearFault } = useAppStore( useShallow((state) => ({ client: state.client, isConnected: state.isConnected, + clearFault: state.clearFault, })) ); @@ -324,23 +338,19 @@ export function FaultsDashboard() { // Clear fault handler const handleClear = useCallback( async (code: string) => { - if (!client) return; - setClearingCodes((prev) => new Set([...prev, code])); try { // Find the fault to get entity info const fault = faults.find((f) => f.code === code); if (fault) { - // Determine the correct entity group based on the fault's entity type - const entityGroup = - fault.entity_type === 'app' || fault.entity_type === 'apps' ? 'apps' : 'components'; - await client.clearFault(entityGroup, fault.entity_id, code); + // Map the fault's entity_type to the correct resource type for the API + const entityGroup = mapFaultEntityTypeToResourceType(fault.entity_type); + // Use store's clearFault which has proper error handling with toasts + await clearFault(entityGroup, fault.entity_id, code); } // Reload faults after clearing await loadFaults(true); - } catch { - // Error handled by toast in store } finally { setClearingCodes((prev) => { const next = new Set(prev); @@ -349,7 +359,7 @@ export function FaultsDashboard() { }); } }, - [client, faults, loadFaults] + [faults, loadFaults, clearFault] ); // Filter faults diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx index 917ae09..431808f 100644 --- a/src/components/FunctionsPanel.tsx +++ b/src/components/FunctionsPanel.tsx @@ -17,6 +17,7 @@ import { Badge } from '@/components/ui/badge'; 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'; /** Host app object returned from /functions/{id}/hosts */ @@ -354,54 +355,7 @@ export function FunctionsPanel({ functionId, functionName, description, path, on {activeTab === 'configurations' && } - {activeTab === 'faults' && ( - - - - - Faults - - Active faults from all host apps - - - {faults.length === 0 ? ( -
- -

No active faults.

-
- ) : ( -
- {faults.map((fault) => ( -
- -
-
{fault.code}
-
- {fault.message} -
-
- - {fault.severity} - -
- ))} -
- )} -
-
- )} + {activeTab === 'faults' && } {isLoading && ( diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index a879a8c..f41eee2 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -1476,9 +1476,14 @@ export class SovdApiClient { } // 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 entity_id = source.split('/').pop() || 'unknown'; - const entity_type = source.includes('/bridge/') ? 'bridge' : 'component'; + 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, From 59b892521cdaeb1ebf8fe690fb5555d4acec9db5 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 29 Jan 2026 06:41:43 +0000 Subject: [PATCH 22/26] refactor: address PR review feedback for code quality improvements T1. **EntityTreeNode.tsx**: Remove unused `_data` parameter - Removed unused parameter from getEntityIcon() and getEntityColor() - Simplified function signatures from (type, _data?, isExpanded?) to (type, isExpanded?) 2. **ServerInfoPanel.tsx**: Extract repeated sovd_info access - Added `const sovdInfo = versionInfo?.sovd_info?.[0]` - Eliminated 6 duplicate accesses to versionInfo?.sovd_info?.[0] 3. **EntityResourceTabs.tsx**: Implement lazy loading per tab - Resources now load only when their tab is first selected - Added LoadedResources interface to track loaded tabs - Replaced eager Promise.all() with lazy loadTabResources() callback - Reduces unnecessary API calls when only viewing specific tabs 4. **FaultsDashboard.tsx**: Share faults polling state - Removed duplicate polling between FaultsDashboard and FaultsCountBadge - Both components now use shared faults state from useAppStore - Eliminated redundant API calls and local state management 5. **store.ts**: Refactor complex selectEntity function - Extracted 9 type-specific selection handlers: * handleTopicSelection, handleServerSelection * handleComponentSelection, handleAreaSelection * handleFunctionSelection, handleAppSelection * handleFaultSelection, handleParameterSelection * handleOperationSelection - Added fetchEntityFromApi() fallback helper - Reduced selectEntity from ~350 lines with 12+ conditional branches to ~90 lines - Improved maintainability and testability --- src/components/EntityResourceTabs.tsx | 81 +++- src/components/EntityTreeNode.tsx | 8 +- src/components/FaultsDashboard.tsx | 139 +++--- src/components/ServerInfoPanel.tsx | 17 +- src/lib/store.ts | 612 ++++++++++++++------------ 5 files changed, 460 insertions(+), 397 deletions(-) diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index f92de96..7331243 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } 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'; @@ -33,13 +33,29 @@ interface EntityResourceTabsProps { onNavigate?: (path: string) => void; } +/** Track which resources have been loaded */ +interface LoadedResources { + data: boolean; + operations: boolean; + configurations: boolean; + faults: boolean; +} + /** * Reusable component for displaying entity resources (data, operations, configurations, faults) - * Works with areas, components, apps, and functions + * Works with areas, components, apps, and functions. + * + * Resources are lazy-loaded per tab to avoid unnecessary API calls. */ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate }: EntityResourceTabsProps) { const [activeTab, setActiveTab] = useState('data'); const [isLoading, setIsLoading] = useState(false); + const [loadedTabs, setLoadedTabs] = useState({ + data: false, + operations: false, + configurations: false, + faults: false, + }); const [data, setData] = useState([]); const [operations, setOperations] = useState([]); const [configurations, setConfigurations] = useState([]); @@ -52,32 +68,55 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate })) ); - useEffect(() => { - const loadResources = async () => { - if (!client) return; - setIsLoading(true); + // Lazy load resources for the active tab + const loadTabResources = useCallback( + async (tab: ResourceTab) => { + if (!client || loadedTabs[tab]) return; + setIsLoading(true); try { - const [dataRes, opsRes, configRes, faultsRes] = await Promise.all([ - client.getEntityData(entityType, entityId).catch(() => [] as ComponentTopic[]), - client.listOperations(entityId, entityType).catch(() => [] as Operation[]), - client.listConfigurations(entityId, entityType).catch(() => ({ parameters: [] })), - client.listEntityFaults(entityType, entityId).catch(() => ({ items: [] })), - ]); - - setData(dataRes); - setOperations(opsRes); - setConfigurations(configRes.parameters || []); - setFaults(faultsRes.items || []); + switch (tab) { + case 'data': { + const dataRes = await client + .getEntityData(entityType, entityId) + .catch(() => [] as ComponentTopic[]); + setData(dataRes); + break; + } + case 'operations': { + const opsRes = await client.listOperations(entityId, entityType).catch(() => [] as Operation[]); + setOperations(opsRes); + break; + } + case 'configurations': { + const configRes = await client + .listConfigurations(entityId, entityType) + .catch(() => ({ parameters: [] })); + setConfigurations(configRes.parameters || []); + break; + } + case 'faults': { + const faultsRes = await client + .listEntityFaults(entityType, entityId) + .catch(() => ({ items: [] })); + setFaults(faultsRes.items || []); + break; + } + } + setLoadedTabs((prev) => ({ ...prev, [tab]: true })); } catch (error) { - console.error('Failed to load entity resources:', error); + console.error(`Failed to load ${tab} resources:`, error); } finally { setIsLoading(false); } - }; + }, + [client, entityId, entityType, loadedTabs] + ); - loadResources(); - }, [client, entityId, entityType]); + // Load resources when tab changes + useEffect(() => { + loadTabResources(activeTab); + }, [activeTab, loadTabResources]); const handleNavigate = (path: string) => { if (onNavigate) { diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index ce55c7d..4bd8c30 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -41,7 +41,7 @@ interface EntityTreeNodeProps { * - App: Cpu (ROS 2 node) * - Function: GitBranch (capability grouping) */ -function getEntityIcon(type: string, _data?: unknown, isExpanded?: boolean) { +function getEntityIcon(type: string, isExpanded?: boolean) { switch ((type || '').toLowerCase()) { // Entity types case 'area': @@ -82,7 +82,7 @@ function getEntityIcon(type: string, _data?: unknown, isExpanded?: boolean) { /** * Get color class for entity type */ -function getEntityColor(type: string, _data?: unknown, isSelected?: boolean): string { +function getEntityColor(type: string, isSelected?: boolean): string { if (isSelected) return 'text-primary'; switch (type.toLowerCase()) { @@ -144,8 +144,8 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { const isLoading = loadingPaths.includes(node.path); const isSelected = selectedPath === node.path; const hasChildren = node.hasChildren !== false; // Default to true if not specified - const Icon = getEntityIcon(node.type, node.data, isExpanded); - const iconColorClass = getEntityColor(node.type, node.data, isSelected); + const Icon = getEntityIcon(node.type, isExpanded); + const iconColorClass = getEntityColor(node.type, isSelected); // Get topic direction info if available const topicData = isTopicNodeData(node.data) ? node.data : null; diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx index 1b858d1..f7384ab 100644 --- a/src/components/FaultsDashboard.tsx +++ b/src/components/FaultsDashboard.tsx @@ -268,18 +268,18 @@ function DashboardSkeleton() { * Faults Dashboard - displays all faults across the system * * Features: - * - Real-time updates via polling (SSE support planned) + * - Real-time updates via shared store polling * - Filtering by severity and status * - Grouping by entity * - Clear fault actions + * + * Uses shared faults state from useAppStore to avoid duplicate API calls + * when both FaultsDashboard and FaultsCountBadge are visible. */ export function FaultsDashboard() { - const [faults, setFaults] = useState([]); - const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [autoRefresh, setAutoRefresh] = useState(true); const [clearingCodes, setClearingCodes] = useState>(new Set()); - const [error, setError] = useState(null); // Filters const [severityFilters, setSeverityFilters] = useState>( @@ -288,52 +288,41 @@ export function FaultsDashboard() { const [statusFilters, setStatusFilters] = useState>(new Set(['active', 'pending'])); const [groupByEntity, setGroupByEntity] = useState(true); - const { client, isConnected, clearFault } = useAppStore( + // Use shared faults state from store + const { faults, isLoadingFaults, isConnected, fetchFaults, clearFault } = useAppStore( useShallow((state) => ({ - client: state.client, + faults: state.faults, + isLoadingFaults: state.isLoadingFaults, isConnected: state.isConnected, + fetchFaults: state.fetchFaults, clearFault: state.clearFault, })) ); - // Load faults - const loadFaults = useCallback( - async (showRefreshIndicator = false) => { - if (!client || !isConnected) return; - - if (showRefreshIndicator) { - setIsRefreshing(true); - } - setError(null); - - try { - const response = await client.listAllFaults(); - setFaults(response.items || []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load faults'); - } finally { - setIsLoading(false); - setIsRefreshing(false); - } - }, - [client, isConnected] - ); - - // Initial load + // Load faults on mount useEffect(() => { - loadFaults(); - }, [loadFaults]); + if (isConnected) { + fetchFaults(); + } + }, [isConnected, fetchFaults]); - // Auto-refresh polling + // Auto-refresh polling using shared store useEffect(() => { if (!autoRefresh || !isConnected) return; const interval = setInterval(() => { - loadFaults(false); + fetchFaults(); }, DEFAULT_POLL_INTERVAL); return () => clearInterval(interval); - }, [autoRefresh, isConnected, loadFaults]); + }, [autoRefresh, isConnected, fetchFaults]); + + // Manual refresh handler + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + await fetchFaults(); + setIsRefreshing(false); + }, [fetchFaults]); // Clear fault handler const handleClear = useCallback( @@ -350,7 +339,7 @@ export function FaultsDashboard() { await clearFault(entityGroup, fault.entity_id, code); } // Reload faults after clearing - await loadFaults(true); + await fetchFaults(); } finally { setClearingCodes((prev) => { const next = new Set(prev); @@ -359,7 +348,7 @@ export function FaultsDashboard() { }); } }, - [faults, loadFaults, clearFault] + [faults, fetchFaults, clearFault] ); // Filter faults @@ -437,7 +426,7 @@ export function FaultsDashboard() { ); } - if (isLoading) { + if (isLoadingFaults && faults.length === 0) { return (
@@ -484,10 +473,12 @@ export function FaultsDashboard() {
@@ -617,19 +608,7 @@ export function FaultsDashboard() { {/* Faults List */} - {error ? ( - - -
- -

{error}

- -
-
-
- ) : filteredFaults.length === 0 ? ( + {filteredFaults.length === 0 ? (
@@ -678,45 +657,37 @@ export function FaultsDashboard() { /** * Faults count badge for sidebar - * Polls for fault count only when visible via document.hidden check + * + * Uses shared faults state from useAppStore to avoid duplicate polling. + * The main polling happens in FaultsDashboard or when faults are fetched elsewhere. */ export function FaultsCountBadge() { - const [count, setCount] = useState(0); - - const { client, isConnected } = useAppStore( + const { faults, isConnected, fetchFaults } = useAppStore( useShallow((state) => ({ - client: state.client, + faults: state.faults, isConnected: state.isConnected, + fetchFaults: state.fetchFaults, })) ); + // Trigger initial fetch and set up polling when connected useEffect(() => { - const loadCount = async () => { - // Skip polling when document is hidden (tab not visible) - if (document.hidden) return; - if (!client || !isConnected) { - setCount(0); - return; - } + if (!isConnected) return; - try { - const response = await client.listAllFaults(); - const activeCount = (response.items || []).filter( - (f) => f.status === 'active' && (f.severity === 'critical' || f.severity === 'error') - ).length; - setCount(activeCount); - } catch { - setCount(0); - } - }; + // Initial fetch + fetchFaults(); - loadCount(); - const interval = setInterval(loadCount, DEFAULT_POLL_INTERVAL); + // Poll for updates when document is visible + const interval = setInterval(() => { + if (!document.hidden) { + fetchFaults(); + } + }, DEFAULT_POLL_INTERVAL); - // Also listen for visibility changes to pause/resume polling + // Also listen for visibility changes to refresh when tab becomes visible const handleVisibilityChange = () => { if (!document.hidden) { - loadCount(); + fetchFaults(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); @@ -725,7 +696,13 @@ export function FaultsCountBadge() { clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [client, isConnected]); + }, [isConnected, fetchFaults]); + + // Count active critical/error faults + const count = useMemo(() => { + return faults.filter((f) => f.status === 'active' && (f.severity === 'critical' || f.severity === 'error')) + .length; + }, [faults]); if (count === 0) return null; diff --git a/src/components/ServerInfoPanel.tsx b/src/components/ServerInfoPanel.tsx index f4a857c..9a3ad74 100644 --- a/src/components/ServerInfoPanel.tsx +++ b/src/components/ServerInfoPanel.tsx @@ -108,6 +108,9 @@ export function ServerInfoPanel() { ); } + // Extract first SOVD info entry for cleaner access + const sovdInfo = versionInfo?.sovd_info?.[0]; + return (
{/* Server Overview */} @@ -119,9 +122,7 @@ export function ServerInfoPanel() {
- {versionInfo?.sovd_info?.[0]?.vendor_info?.name || - capabilities?.server_name || - 'SOVD Server'} + {sovdInfo?.vendor_info?.name || capabilities?.server_name || 'SOVD Server'} @@ -139,13 +140,13 @@ export function ServerInfoPanel() {
SOVD Version

- {versionInfo?.sovd_info?.[0]?.version || capabilities?.sovd_version || 'Unknown'} + {sovdInfo?.version || capabilities?.sovd_version || 'Unknown'}

- {versionInfo?.sovd_info?.[0]?.vendor_info?.version && ( + {sovdInfo?.vendor_info?.version && (
Implementation Version
-

{versionInfo.sovd_info[0].vendor_info.version}

+

{sovdInfo.vendor_info.version}

)} {capabilities?.server_version && ( @@ -154,10 +155,10 @@ export function ServerInfoPanel() {

{capabilities.server_version}

)} - {versionInfo?.sovd_info?.[0]?.base_uri && ( + {sovdInfo?.base_uri && (
Base URI
-

{versionInfo.sovd_info[0].base_uri}

+

{sovdInfo.base_uri}

)}
diff --git a/src/lib/store.ts b/src/lib/store.ts index cfcf333..ea9a662 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -193,6 +193,279 @@ function findNode(nodes: EntityTreeNode[], path: string): EntityTreeNode | null return null; } +// ============================================================================= +// Entity Selection Handlers +// ============================================================================= + +/** Result from an entity selection handler */ +interface SelectionResult { + selectedPath: string; + selectedEntity: SovdEntityDetails; + expandedPaths?: string[]; + rootEntities?: EntityTreeNode[]; + isLoadingDetails: boolean; +} + +/** Context passed to entity selection handlers */ +interface SelectionContext { + node: EntityTreeNode; + path: string; + expandedPaths: string[]; + rootEntities: EntityTreeNode[]; +} + +/** + * Handle topic node selection + * Distinguished between TopicNodeData (partial) and ComponentTopic (full) + */ +async function handleTopicSelection(ctx: SelectionContext, client: SovdApiClient): Promise { + const { node, path, rootEntities } = ctx; + if (node.type !== 'topic' || !node.data) return null; + + const data = node.data as TopicNodeData | ComponentTopic; + const isTopicNodeData = 'isPublisher' in data && 'isSubscriber' in data && !('type' in data); + + if (isTopicNodeData) { + // TopicNodeData - need to fetch full details + const { isPublisher, isSubscriber } = data as TopicNodeData; + const apiPath = path.replace(/^\/server/, ''); + const details = await client.getEntityDetails(apiPath); + + // 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: details, + rootEntities: updatedTree, + isLoadingDetails: false, + }; + } + + // Full ComponentTopic data available + const topicData = data as ComponentTopic; + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: node.name, + href: node.href, + topicData, + rosType: topicData.type, + type: 'topic', + }, + isLoadingDetails: false, + }; +} + +/** Handle server node selection */ +function handleServerSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'server') return null; + + const serverData = node.data as { + versionInfo?: VersionInfo; + serverVersion?: string; + sovdVersion?: string; + serverUrl?: string; + }; + + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: 'server', + href: node.href, + versionInfo: serverData?.versionInfo, + serverVersion: serverData?.serverVersion, + sovdVersion: serverData?.sovdVersion, + serverUrl: serverData?.serverUrl, + }, + isLoadingDetails: false, + }; +} + +/** Handle component/subcomponent node selection */ +function handleComponentSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'component' && node.type !== 'subcomponent') return null; + + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: node.type, + href: node.href, + topicsInfo: node.topicsInfo, + }, + isLoadingDetails: false, + }; +} + +/** Handle area/subarea node selection */ +function handleAreaSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'area' && node.type !== 'subarea') return null; + + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: node.type, + href: node.href, + }, + isLoadingDetails: false, + }; +} + +/** Handle function node selection */ +function handleFunctionSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'function') return null; + + const functionData = node.data as SovdFunction | undefined; + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: 'function', + href: node.href, + description: functionData?.description, + }, + isLoadingDetails: false, + }; +} + +/** Handle app node selection */ +function handleAppSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'app') return null; + + const appData = node.data as App | undefined; + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: 'app', + href: node.href, + fqn: appData?.fqn || node.name, + node_name: appData?.node_name, + namespace: appData?.namespace, + component_id: appData?.component_id, + }, + isLoadingDetails: false, + }; +} + +/** Handle fault node selection */ +function handleFaultSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path } = ctx; + if (node.type !== 'fault' || !node.data) return null; + + const fault = node.data as Fault; + const pathSegments = path.split('/').filter(Boolean); + const entityId = pathSegments.length >= 2 ? pathSegments[pathSegments.length - 3] : ''; + + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: fault.message, + type: 'fault', + href: node.href, + data: fault, + entityId, + }, + isLoadingDetails: false, + }; +} + +/** Handle parameter node selection */ +function handleParameterSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path } = ctx; + if (node.type !== 'parameter' || !node.data) return null; + + const pathSegments = path.split('/').filter(Boolean); + const componentId = (pathSegments.length >= 2 ? pathSegments[1] : pathSegments[0]) ?? ''; + + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: node.name, + type: 'parameter', + href: node.href, + data: node.data, + componentId, + }, + isLoadingDetails: false, + }; +} + +/** Handle service/action node selection */ +function handleOperationSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path } = ctx; + if ((node.type !== 'service' && node.type !== 'action') || !node.data) return null; + + const pathSegments = path.split('/').filter(Boolean); + const opsIndex = pathSegments.indexOf('operations'); + const componentId = opsIndex > 0 ? pathSegments[opsIndex - 1] : (pathSegments[0] ?? ''); + + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: node.name, + type: node.type, + href: node.href, + data: node.data, + componentId, + }, + isLoadingDetails: false, + }; +} + +/** Fallback: fetch entity details from API when not in tree */ +async function fetchEntityFromApi( + path: string, + client: SovdApiClient, + set: (state: Partial) => void +): Promise { + set({ selectedPath: path, isLoadingDetails: true, selectedEntity: null }); + + try { + const apiPath = path.replace(/^\/server/, ''); + const details = await client.getEntityDetails(apiPath); + 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'; + + set({ + selectedEntity: { id, name: id, type: inferredType, href: path, error: 'Failed to load details' }, + isLoadingDetails: false, + }); + } +} + export const useAppStore = create()( persist( (set, get) => ({ @@ -546,7 +819,6 @@ export const useAppStore = create()( if (!client || path === selectedPath) return; // Auto-expand parent paths and load children if needed - // This ensures navigation to deep paths (like /area/component/data/topic) works const pathParts = path.split('/').filter(Boolean); const newExpandedPaths = [...expandedPaths]; let currentPath = ''; @@ -556,10 +828,8 @@ export const useAppStore = create()( if (!newExpandedPaths.includes(currentPath)) { newExpandedPaths.push(currentPath); } - // Check if this node needs children loaded const parentNode = findNode(rootEntities, currentPath); if (parentNode && parentNode.hasChildren !== false && !parentNode.children) { - // Trigger load but don't await - let it happen in background loadChildren(currentPath); } } @@ -568,298 +838,74 @@ export const useAppStore = create()( set({ expandedPaths: newExpandedPaths }); } - // OPTIMIZATION: Check if tree already has this data const node = findNode(rootEntities, path); + if (!node) { + // Node not in tree - fall back to API fetch + await fetchEntityFromApi(path, client, set); + return; + } - // Optimization for Topic - check if we have TopicNodeData or full ComponentTopic - if (node && node.type === 'topic' && node.data) { - const data = node.data as TopicNodeData | ComponentTopic; - - // Check if it's TopicNodeData (from topicsInfo - only has isPublisher/isSubscriber) - // vs full ComponentTopic (from /components/{id}/data - has type, publishers, QoS etc) - const isTopicNodeData = 'isPublisher' in data && 'isSubscriber' in data && !('type' in data); - - if (isTopicNodeData) { - // Preserve isPublisher/isSubscriber info from TopicNodeData - const { isPublisher, isSubscriber } = data as TopicNodeData; + const ctx: SelectionContext = { node, path, expandedPaths, rootEntities }; - // This is TopicNodeData - fetch actual topic details with full metadata - set({ - selectedPath: path, - isLoadingDetails: true, - selectedEntity: null, - }); - - try { - // Convert tree path to API path (remove /server prefix) - const apiPath = path.replace(/^\/server/, ''); - const details = await client.getEntityDetails(apiPath); - - // Update tree node with full data MERGED with direction info - // This preserves isPublisher/isSubscriber for the tree icons - const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ - ...n, - data: { - ...details.topicData, - isPublisher, - isSubscriber, - }, - })); - set({ rootEntities: updatedTree }); - - set({ selectedEntity: details, isLoadingDetails: false }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to load topic details: ${message}`); + // Try each handler in order - first match wins + // Topic requires special handling (async + possible error) + if (node.type === 'topic' && node.data) { + set({ selectedPath: path, isLoadingDetails: true, selectedEntity: null }); + try { + const result = await handleTopicSelection(ctx, client); + if (result) { set({ - selectedEntity: { - id: node.id, - name: node.name, - type: 'topic', - href: node.href, - error: 'Failed to load details', - }, - isLoadingDetails: false, + selectedPath: result.selectedPath, + selectedEntity: result.selectedEntity, + isLoadingDetails: result.isLoadingDetails, + ...(result.rootEntities && { rootEntities: result.rootEntities }), }); + return; } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to load topic details: ${message}`); + set({ + selectedEntity: { + id: node.id, + name: node.name, + type: 'topic', + href: node.href, + error: 'Failed to load details', + }, + isLoadingDetails: false, + }); return; } - - // Full ComponentTopic data available (from expanded component children) - const topicData = data as ComponentTopic; - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - href: node.href, - topicData, - rosType: topicData.type, - type: 'topic', - }, - }); - return; - } - - // Handle Server node selection - show server info panel - if (node && node.type === 'server') { - const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; - const serverData = node.data as { - versionInfo?: VersionInfo; - serverVersion?: string; - sovdVersion?: string; - serverUrl?: string; - }; - - set({ - selectedPath: path, - expandedPaths: newExpandedPaths, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: 'server', - href: node.href, - versionInfo: serverData?.versionInfo, - serverVersion: serverData?.serverVersion, - sovdVersion: serverData?.sovdVersion, - serverUrl: serverData?.serverUrl, - }, - }); - return; - } - - // Optimization for Component/Subcomponent - just select it and auto-expand - // Don't modify children - virtual folders (resources/, subcomponents/) are already there - if (node && (node.type === 'component' || node.type === 'subcomponent')) { - // Auto-expand to show virtual folders - const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; - - set({ - selectedPath: path, - expandedPaths: newExpandedPaths, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: node.type, - href: node.href, - // Pass topicsInfo if available for the Data tab - topicsInfo: node.topicsInfo, - }, - }); - return; - } - - // Handle Area/Subarea entity selection - auto-expand to show virtual folders - if (node && (node.type === 'area' || node.type === 'subarea')) { - const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; - - set({ - selectedPath: path, - expandedPaths: newExpandedPaths, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: node.type, - href: node.href, - }, - }); - return; - } - - // Handle Function entity selection - show function panel with hosts - if (node && node.type === 'function') { - const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; - const functionData = node.data as SovdFunction | undefined; - - set({ - selectedPath: path, - expandedPaths: newExpandedPaths, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: 'function', - href: node.href, - description: functionData?.description, - }, - }); - return; - } - - // Handle App entity selection - auto-expand to show virtual folders - if (node && node.type === 'app') { - const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; - const appData = node.data as App | undefined; - - set({ - selectedPath: path, - expandedPaths: newExpandedPaths, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: 'app', - href: node.href, - // Pass app-specific data - fqn: appData?.fqn || node.name, - node_name: appData?.node_name, - namespace: appData?.namespace, - component_id: appData?.component_id, - }, - }); - return; } - // Handle fault selection - show fault details - if (node && node.type === 'fault' && node.data) { - const fault = node.data as Fault; - // Extract entity info from path: /area/component/faults/code - const pathSegments = path.split('/').filter(Boolean); - const entityId = pathSegments.length >= 2 ? pathSegments[pathSegments.length - 3] : ''; - - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: fault.message, - type: 'fault', - href: node.href, - data: fault, - entityId, - }, - }); - return; - } - - // Handle parameter selection - show parameter detail with data from tree - if (node && node.type === 'parameter' && node.data) { - // Extract componentId from path: /area/component/configurations/paramName - const pathSegments = path.split('/').filter(Boolean); - const componentId = (pathSegments.length >= 2 ? pathSegments[1] : pathSegments[0]) ?? ''; - - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: 'parameter', - href: node.href, - data: node.data, - componentId, - }, - }); - return; - } - - // Handle service/action selection - show operation detail with data from tree - if (node && (node.type === 'service' || node.type === 'action') && node.data) { - // Extract componentId from path: /server/area/component/operations/opName - // or /server/function_name/operations/opName - const pathSegments = path.split('/').filter(Boolean); - const opsIndex = pathSegments.indexOf('operations'); - // Component/function ID is the segment right before 'operations' - const componentId = opsIndex > 0 ? pathSegments[opsIndex - 1] : (pathSegments[0] ?? ''); - - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: node.type, - href: node.href, - data: node.data, - componentId, - }, - }); - return; - } - - set({ - selectedPath: path, - isLoadingDetails: true, - selectedEntity: null, - }); - - try { - // Convert tree path to API path (remove /server prefix) - const apiPath = path.replace(/^\/server/, ''); - const details = await client.getEntityDetails(apiPath); - 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}`); - - // Set fallback entity to allow panel to render - // Infer entity type from path structure - const segments = path.split('/').filter(Boolean); - const id = segments[segments.length - 1] || path; - let inferredType: string; - if (segments.length === 1) { - inferredType = 'area'; - } else if (segments.length === 2) { - inferredType = 'component'; - } else { - inferredType = 'unknown'; + // Synchronous handlers + const handlers = [ + handleServerSelection, + handleComponentSelection, + handleAreaSelection, + handleFunctionSelection, + handleAppSelection, + handleFaultSelection, + handleParameterSelection, + handleOperationSelection, + ]; + + for (const handler of handlers) { + const result = handler(ctx); + if (result) { + set({ + selectedPath: result.selectedPath, + selectedEntity: result.selectedEntity, + isLoadingDetails: result.isLoadingDetails, + ...(result.expandedPaths && { expandedPaths: result.expandedPaths }), + }); + return; } - - set({ - selectedEntity: { - id, - name: id, - type: inferredType, - href: path, - error: 'Failed to load details', - }, - isLoadingDetails: false, - }); } + + // No handler matched - fall back to API fetch + await fetchEntityFromApi(path, client, set); }, // Refresh the currently selected entity (re-fetch from server) From 66f4a0b6a927164b15db7b913dff1ae0c2384979 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 29 Jan 2026 06:50:45 +0000 Subject: [PATCH 23/26] docs: update copilot instructions and add search limitation note 1. **SearchCommand.tsx**: Add TODO comment about search limitation - Document that search only indexes expanded tree nodes - Unexpanded children (e.g., apps under components) not searchable - Suggest API-based search as future improvement 2. **.github/copilot-instructions.md**: Update to reflect current architecture - Add missing components: FaultsDashboard, FaultsPanel, ServerInfoPanel, EntityResourceTabs, EntityTreeNode, SearchCommand - Document SOVD entity hierarchy (Area/Subarea/Component/Subcomponent/App/Function) - Add Entity Selection Handlers section describing extracted handlers pattern - Update Zustand example (useAppStore with shared faults state) - Expand API reference with /functions, /faults endpoints - Add conventions: lazy loading per tab, useShallow for subscriptions - Note search limitation in Important Notes section - Remove outdated references (useStore, integration tests directory) --- .github/copilot-instructions.md | 151 ++++++++++++++++++++++++------- src/components/SearchCommand.tsx | 5 +- 2 files changed, 120 insertions(+), 36 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0c0fc07..aafc292 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,44 +8,107 @@ React 19 + Vite + TypeScript SPA for browsing SOVD (Service-Oriented Vehicle Dia ``` src/ -├── components/ # React components -│ ├── ui/ # shadcn/ui primitives (Button, Card, Dialog, etc.) -│ ├── EntityTreeSidebar.tsx # Main navigation tree -│ ├── EntityDetailPanel.tsx # Entity details view -│ ├── OperationsPanel.tsx # ROS 2 service/action invocation -│ └── ConfigurationPanel.tsx # ROS 2 parameter management +├── components/ # React components +│ ├── ui/ # shadcn/ui primitives (Button, Card, Dialog, etc.) +│ ├── EntityTreeSidebar.tsx # Main navigation tree with collapsible nodes +│ ├── EntityTreeNode.tsx # Tree node component with expand/collapse +│ ├── EntityDetailPanel.tsx # Entity details view (dispatch to type-specific panels) +│ ├── EntityResourceTabs.tsx # Tabbed interface for data/operations/configs/faults +│ ├── ServerInfoPanel.tsx # Server connection info and capabilities +│ ├── OperationsPanel.tsx # ROS 2 service/action invocation +│ ├── ConfigurationPanel.tsx # ROS 2 parameter management +│ ├── FaultsDashboard.tsx # System-wide faults view with filtering +│ ├── FaultsPanel.tsx # Entity-specific faults +│ ├── SearchCommand.tsx # Ctrl+K command palette for entity search +│ └── ServerConnectionDialog.tsx # Server URL input dialog ├── lib/ -│ ├── sovd-api.ts # Typed HTTP client for gateway REST API -│ ├── store.ts # Zustand state management -│ ├── types.ts # TypeScript interfaces for API types -│ ├── schema-utils.ts # JSON Schema utilities -│ └── utils.ts # Utility functions +│ ├── sovd-api.ts # Typed HTTP client for gateway REST API +│ ├── store.ts # Zustand state management (entity tree, selection, faults) +│ ├── types.ts # TypeScript interfaces for API types +│ ├── schema-utils.ts # JSON Schema utilities for form generation +│ └── utils.ts # Utility functions └── test/ - └── setup.ts # Vitest setup + └── setup.ts # Vitest setup ``` +## Entity Model + +**SOVD entity hierarchy:** + +- **Area** → namespace grouping (e.g., `/powertrain`, `/chassis`) +- **Subarea** → nested namespace +- **Component** → logical grouping, contains apps +- **Subcomponent** → nested component +- **App** → individual ROS 2 node +- **Function** → capability grouping (functional view) + +**Resources** (available on components/apps/functions/areas): + +- `data` → ROS 2 topics +- `operations` → ROS 2 services and actions +- `configurations` → ROS 2 parameters +- `faults` → diagnostic trouble codes + ## Key Patterns ### State Management (Zustand) ```typescript // src/lib/store.ts -export const useStore = create()( +export const useAppStore = create()( persist( (set, get) => ({ // Connection state - serverUrl: 'http://localhost:8080', + serverUrl: null, + client: null, + isConnected: false, + // Entity tree - entities: [], - selectedEntityPath: null, + rootEntities: [], + selectedPath: null, + selectedEntity: null, + expandedPaths: [], + + // Shared faults state (used by FaultsDashboard and FaultsCountBadge) + faults: [], + isLoadingFaults: false, + // Actions - selectEntity: (path) => set({ selectedEntityPath: path }), + connect: async (url) => { + /* ... */ + }, + selectEntity: async (path) => { + /* ... */ + }, + loadChildren: async (path) => { + /* ... */ + }, + fetchFaults: async () => { + /* ... */ + }, }), - { name: 'sovd-ui-storage' } + { name: 'sovd_web_ui_server_url', partialize: (state) => ({ serverUrl, baseEndpoint }) } ) ); ``` +### Entity Selection Handlers + +The `selectEntity` action uses type-specific handlers for cleaner code: + +```typescript +// Handlers extracted from selectEntity for maintainability +handleTopicSelection(ctx, client); // Async - may fetch full topic data +handleServerSelection(ctx); // Show server info panel +handleComponentSelection(ctx); // Auto-expand, show resources +handleAreaSelection(ctx); // Auto-expand +handleFunctionSelection(ctx); // Show function with hosts +handleAppSelection(ctx); // Show app details +handleFaultSelection(ctx); // Show fault details +handleParameterSelection(ctx); // Show parameter editor +handleOperationSelection(ctx); // Show operation invocation +``` + ### API Client ```typescript @@ -53,46 +116,64 @@ export const useStore = create()( export class SovdApiClient { constructor(private baseUrl: string) {} - async getComponents(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/components`); - return response.json(); - } + // Entity listing + async getAreas(): Promise; + async getComponents(): Promise; + async getApps(): Promise; + async getFunctions(): Promise; + + // Entity resources + async getEntityData(entityType, entityId): Promise; + async listOperations(entityId, entityType): Promise; + async listConfigurations(entityId, entityType): Promise; + async listEntityFaults(entityType, entityId): Promise; + + // Operations (SOVD Execution Model) + async createExecution(entityId, operationName, request): Promise; + async getExecutionStatus(entityId, operationName, executionId): Promise; + async cancelExecution(entityId, operationName, executionId): Promise; } ``` ## Conventions -- Use Zustand for client state +- Use `useAppStore` with `useShallow` for selective subscriptions - All API types defined in `lib/types.ts` - Use `@/` path alias for imports from src - Prefer composition over inheritance - Use shadcn/ui components from `components/ui/` +- Resources (data, operations, configurations, faults) shown in detail panel tabs, not as tree nodes +- Lazy load resources per tab in `EntityResourceTabs` to avoid unnecessary API calls - Format with Prettier (automatic via husky pre-commit) ## Testing - Unit tests: `*.test.ts` next to source files -- Integration tests: `src/test/integration/` - Use `@testing-library/react` for component tests - Run tests: `npm test` +- Run lint: `npm run lint` ## Gateway API Reference Default base URL: `http://localhost:8080/api/v1` -| Method | Endpoint | Description | -| ------ | ---------------------------------------- | ------------------------------------ | -| GET | `/areas` | List all areas (namespace groupings) | -| GET | `/components` | List all components | -| GET | `/apps` | List all apps (ROS 2 nodes) | -| GET | `/components/{id}/data` | List data topics for component | -| GET | `/components/{id}/operations` | List operations (services/actions) | -| GET | `/components/{id}/configurations` | List configurations (parameters) | -| POST | `/components/{id}/operations/{name}` | Call operation | -| PUT | `/components/{id}/configurations/{name}` | Update configuration | +| Method | Endpoint | Description | +| ------ | ------------------------------------------- | ------------------------------------ | +| GET | `/areas` | List all areas (namespace groupings) | +| GET | `/components` | List all components | +| GET | `/apps` | List all apps (ROS 2 nodes) | +| GET | `/functions` | List all functions | +| GET | `/{entity_type}/{id}/data` | List data topics for entity | +| GET | `/{entity_type}/{id}/operations` | List operations (services/actions) | +| GET | `/{entity_type}/{id}/configurations` | List configurations (parameters) | +| GET | `/{entity_type}/{id}/faults` | List faults for entity | +| GET | `/faults` | List all faults across system | +| POST | `/{entity_type}/{id}/operations/{name}` | Create execution (call operation) | +| DELETE | `/{entity_type}/{id}/faults/{code}` | Clear a fault | +| PUT | `/{entity_type}/{id}/configurations/{name}` | Update configuration value | ## Important Notes - This UI connects to `ros2_medkit_gateway` running on port 8080 - Entity IDs are alphanumeric + underscore + hyphen only -- Virtual folders (data/, operations/, configurations/) are UI constructs, not API entities +- Entity types for API: `areas`, `components`, `apps`, `functions` (plural) diff --git a/src/components/SearchCommand.tsx b/src/components/SearchCommand.tsx index aff1fa8..994ee4e 100644 --- a/src/components/SearchCommand.tsx +++ b/src/components/SearchCommand.tsx @@ -14,7 +14,10 @@ import type { EntityTreeNode } from '@/lib/types'; /** * Flatten tree nodes for search indexing - * Note: Virtual folders are no longer created in the tree (resources shown in detail panel) + * + * TODO: This only indexes nodes currently in tree state. Unexpanded entity children + * (e.g. apps under components) aren't searchable until the parent is expanded. + * Consider fetching a full entity list from API for comprehensive search. */ function flattenTree(nodes: EntityTreeNode[]): EntityTreeNode[] { const result: EntityTreeNode[] = []; From 75579d8a6f4991e1115cdadc942f9873e5683bed Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 29 Jan 2026 06:54:08 +0000 Subject: [PATCH 24/26] fix: improve accessibility and state management 1. **App.tsx**: Fix mobile sidebar overlay accessibility - Replace div with semantic button element for overlay - Remove redundant role and tabIndex attributes (implicit in button) - Add cursor-default to maintain visual consistency - Improves keyboard navigation and screen reader experience 2. **EntityDetailPanel.tsx**: Remove unnecessary key props - Remove key={entityId} from OperationsPanel, ConfigurationPanel, FaultsPanel - Previous behavior caused full component remount on entity change - This lost in-progress form state and execution history - Components now maintain state across entity selections when appropriate - More granular state management without losing user context --- src/App.tsx | 7 +++---- src/components/EntityDetailPanel.tsx | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1831851..68e5bba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -110,16 +110,15 @@ function App() { {/* Overlay for mobile when sidebar is open */} {sidebarOpen && ( -
setSidebarOpen(false)} onKeyDown={(event) => { if (event.key === 'Escape') { setSidebarOpen(false); } }} - role="button" - tabIndex={0} aria-label="Close sidebar" /> )} diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 4c1a165..5e62f29 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -126,11 +126,11 @@ function ComponentTabContent({ /> ); case 'operations': - return ; + return ; case 'configurations': - return ; + return ; case 'faults': - return ; + return ; default: return null; } From 3594a871a7d6d8287058720a73e3bb0accd466ab Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 29 Jan 2026 06:56:35 +0000 Subject: [PATCH 25/26] fix: add validation logging for data quality issues 1. **FaultsDashboard.tsx**: Add warning for unexpected entity types - Explicitly handle 'component'/'components' case before fallback - Log console.warn when unknown entity type is encountered - Helps identify bugs where unexpected entity types are passed - Maintains existing default behavior while improving debuggability 2. **store.ts**: Add validation for malformed function data - Validate fn.id exists and is string or number before mapping - Warn when both fn.name and fn.id are missing - Prevents data quality issues from being silently masked - Aids debugging when API returns unexpected function data structure - Preserves existing fallback behavior ('Unknown', String(fn.id)) --- src/components/FaultsDashboard.tsx | 9 ++++++++- src/lib/store.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx index f7384ab..360e069 100644 --- a/src/components/FaultsDashboard.tsx +++ b/src/components/FaultsDashboard.tsx @@ -44,7 +44,14 @@ function mapFaultEntityTypeToResourceType(entityType: string): SovdResourceEntit if (type === 'area' || type === 'areas') return 'areas'; if (type === 'app' || type === 'apps') return 'apps'; if (type === 'function' || type === 'functions') return 'functions'; - // Default to components for 'component', 'components', or unknown types + if (type === 'component' || type === 'components') return 'components'; + + // Log unexpected entity types to aid debugging + console.warn( + '[FaultsDashboard] Unexpected fault entity_type received:', + entityType, + '- defaulting to "components".' + ); return 'components'; } diff --git a/src/lib/store.ts b/src/lib/store.ts index ea9a662..e2450ab 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -593,6 +593,14 @@ export const useAppStore = create()( // Functional view: Functions -> Apps (hosts) const functions = await client.listFunctions().catch(() => [] as SovdFunction[]); children = functions.map((fn: SovdFunction) => { + // Validate function data quality + if (!fn.id || (typeof fn.id !== 'string' && typeof fn.id !== 'number')) { + console.warn('[Store] Malformed function data - missing or invalid id:', fn); + } + if (!fn.name && !fn.id) { + console.warn('[Store] Malformed function data - missing both name and id:', fn); + } + const fnName = typeof fn.name === 'string' ? fn.name : fn.id || 'Unknown'; const fnId = typeof fn.id === 'string' ? fn.id : String(fn.id); return { From a7eeac4c9228a623fc5a370b26be6a39b5d6afc9 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 29 Jan 2026 06:59:03 +0000 Subject: [PATCH 26/26] fix: improve error messaging and clarify tree node consistency 1. **store.ts - loadRootEntities**: Enhance version info error handling - Add more informative warning message when version info fetch fails - Explain impact to users: "Server will be shown with generic name" - Clarify that version info may be incomplete but UI remains functional - Maintains existing fallback behavior with better user communication 2. **store.ts - toTreeNode**: Add documentation for hasChildren logic - Clarify relationship between hasChildren and children: undefined - hasChildren controls whether expand button is shown - children: undefined means "not loaded yet" (lazy loading) - Document all three hasChildren determination paths: * Explicit metadata from API (preferred) * Children array length check (if provided) * Type-based heuristic fallback (areas/components have children, apps don't) - Prevents confusion about apparent inconsistency between flags --- src/lib/store.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/lib/store.ts b/src/lib/store.ts index e2450ab..6312dd7 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -132,26 +132,31 @@ function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode const path = parentPath ? `${parentPath}/${entity.id}` : `/${entity.id}`; const entityType = entity.type.toLowerCase(); - // Prefer explicit metadata / existing children if available; fall back to type heuristic. + // Determine hasChildren based on explicit metadata or type heuristic + // Note: hasChildren controls whether expand button is shown + // children: undefined means "not loaded yet" (lazy loading on expand) let hasChildren: boolean; const entityAny = entity as unknown as Record; if (Object.prototype.hasOwnProperty.call(entityAny, 'hasChildren') && typeof entityAny.hasChildren === 'boolean') { + // Explicit hasChildren metadata from API - use as-is hasChildren = entityAny.hasChildren as boolean; } else if (Array.isArray(entityAny.children)) { + // Children array provided - check if non-empty hasChildren = (entityAny.children as unknown[]).length > 0; } else { - // Areas and components typically have children (loaded on expand) - // Apps are usually leaf nodes - their resources are shown in the detail panel + // No explicit metadata - use type-based heuristic: + // Areas and components typically have children (components, apps, subareas) + // Apps are leaf nodes - their resources shown in detail panel, not tree hasChildren = entityType !== 'app'; } return { ...entity, path, - children: undefined, // Children loaded lazily on expand + children: undefined, // Children always loaded lazily on expand isLoading: false, isExpanded: false, - hasChildren, + hasChildren, // Controls whether expand button is shown }; } @@ -574,14 +579,17 @@ export const useAppStore = create()( if (!client) return; try { - // Fetch version info + // 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}`); + 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 + // Extract server info from version-info response (fallback to generic values if unavailable) const sovdInfo = versionInfo?.sovd_info?.[0]; const serverName = sovdInfo?.vendor_info?.name || 'SOVD Server'; const serverVersion = sovdInfo?.vendor_info?.version || '';