diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index be6f299..31dbb76 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -1,11 +1,9 @@ -import { useState } from 'react'; import { useShallow } from 'zustand/shallow'; -import { toast } from 'react-toastify'; -import { Copy, Loader2, Send, Radio, ChevronDown, ChevronRight } from 'lucide-react'; +import { Copy, Loader2, Radio, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; import { EmptyState } from '@/components/EmptyState'; +import { TopicDiagnosticsPanel } from '@/components/TopicDiagnosticsPanel'; import { useAppStore, type AppState } from '@/lib/store'; import type { ComponentTopic } from '@/lib/types'; @@ -18,90 +16,24 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { selectedPath, selectedEntity, isLoadingDetails, + isRefreshing, isConnected, client, + selectEntity, + refreshSelectedEntity, } = useAppStore( useShallow((state: AppState) => ({ selectedPath: state.selectedPath, selectedEntity: state.selectedEntity, isLoadingDetails: state.isLoadingDetails, + isRefreshing: state.isRefreshing, isConnected: state.isConnected, client: state.client, + selectEntity: state.selectEntity, + refreshSelectedEntity: state.refreshSelectedEntity, })) ); - const [expandedTopics, setExpandedTopics] = useState>(new Set()); - const [publishingTopics, setPublishingTopics] = useState>(new Set()); - const [topicInputs, setTopicInputs] = useState>({}); - - const toggleTopicExpanded = (topicPath: string) => { - setExpandedTopics(prev => { - const next = new Set(prev); - if (next.has(topicPath)) { - next.delete(topicPath); - } else { - next.add(topicPath); - } - return next; - }); - }; - - const handlePublishToTopic = async (topic: ComponentTopic, topicName: string) => { - if (!client || !selectedEntity) return; - - const inputData = topicInputs[topic.topic] || ''; - if (!inputData.trim()) { - toast.error('Please enter message data'); - return; - } - - // Parse JSON before setting publishing state to avoid stuck state on parse error - let data: unknown; - try { - data = JSON.parse(inputData); - } catch { - toast.error('Invalid JSON format. Please check your message data.'); - return; - } - - setPublishingTopics(prev => new Set(prev).add(topic.topic)); - - try { - const messageType = inferMessageType(topic.data); - - await client.publishToComponentTopic(selectedEntity.id, topicName, { - type: messageType, - data, - }); - - toast.success(`Published to ${topic.topic}`); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to publish'; - toast.error(`Publish failed: ${message}`); - } finally { - setPublishingTopics(prev => { - const next = new Set(prev); - next.delete(topic.topic); - return next; - }); - } - }; - - // Helper to infer message type from data structure - const inferMessageType = (data: unknown): string => { - // This is a simplified heuristic - in production you'd want to query type info - if (data && typeof data === 'object') { - const keys = Object.keys(data as object); - if (keys.includes('linear') && keys.includes('angular')) { - return 'geometry_msgs/msg/Twist'; - } - if (keys.includes('data') && keys.length === 1) { - return 'std_msgs/msg/String'; - } - } - return 'std_msgs/msg/String'; // Default fallback - }; - const handleCopyEntity = async () => { if (selectedEntity) { await navigator.clipboard.writeText(JSON.stringify(selectedEntity, null, 2)); @@ -137,7 +69,14 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { // Entity detail view if (selectedEntity) { - const hasTopics = selectedEntity.type === 'component' && selectedEntity.topics && selectedEntity.topics.length > 0; + const isTopic = selectedEntity.type === 'topic'; + const isComponent = selectedEntity.type === 'component'; + 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; + const hasTopicsInfo = isComponent && !hasTopicsArray && selectedEntity.topicsInfo && + ((selectedEntity.topicsInfo.publishes?.length ?? 0) > 0 || + (selectedEntity.topicsInfo.subscribes?.length ?? 0) > 0); const hasError = !!selectedEntity.error; return ( @@ -171,113 +110,144 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { - ) : hasTopics ? ( + ) : hasTopicData ? ( + // Single Topic View - use TopicDiagnosticsPanel + (() => { + const topic = selectedEntity.topicData!; + // Extract component ID from path /area/component/topic + const componentId = selectedPath.split('/')[2]; + + return ( + + ); + })() + ) : hasTopicsArray ? ( + // Component view with full topics array (type, QoS, publishers info)
- {/* Topics List */} - {selectedEntity.topics!.map((topic: ComponentTopic) => { - const topicName = topic.topic.split('/').pop() || topic.topic; - const isExpanded = expandedTopics.has(topic.topic); - const isPublishing = publishingTopics.has(topic.topic); - const hasNoData = topic.data === null || topic.data === undefined; + {/* Topics List - Rich View with Type and QoS info */} +
+ {(selectedEntity.topics as ComponentTopic[]).map((topic) => { + const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; + const encodedName = encodeURIComponent(cleanName); + const topicPath = `${selectedPath}/${encodedName}`; - return ( - - -
-
- -
-
- {topic.topic} - {hasNoData && ( - - No Data - - )} + return ( + selectEntity(topicPath)} + > + +
+ +
+
{topic.topic}
+
+ {topic.type || 'Unknown Type'}
- - {hasNoData - ? 'No messages received (topic may be inactive)' - : `Last update: ${new Date(topic.timestamp / 1000000).toLocaleString()}` - } -
+
- -
- - {isExpanded && ( - - {/* Latest Data */} -
-
Latest Message
- {hasNoData ? ( -
- No data available - topic exists but is not publishing messages -
- ) : ( -
-                                                            {JSON.stringify(topic.data, null, 2)}
-                                                        
- )} + + + ); + })} +
+
+ ) : hasTopicsInfo ? ( + // Component view with publishes/subscribes arrays + (() => { + // Safe to access - hasTopicsInfo already verified this exists + const topicsInfo = selectedEntity.topicsInfo as NonNullable; + return ( +
+ {/* Publishes Section */} + {topicsInfo.publishes.length > 0 && ( + + +
+ + Publishes + + ({topicsInfo.publishes.length} topics) +
+
+ +
+ {topicsInfo.publishes.map((topic: string) => { + const cleanName = topic.startsWith('/') ? topic.slice(1) : topic; + const encodedName = encodeURIComponent(cleanName); + const topicPath = `${selectedPath}/${encodedName}`; - {/* Publish Form - only show if we have data to infer type */} - {!hasNoData && ( -
-
Publish Message
-