581 lines
30 KiB
TypeScript
581 lines
30 KiB
TypeScript
import { Activity, ArrowRight, CircleDot, ListChecks, ListX, Radio, RefreshCw, Send, Waypoints, X } from 'lucide-react'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import type React from 'react'
|
|
import type { ModuleRow } from './types'
|
|
|
|
type TopicRole = {
|
|
name: string
|
|
type: string
|
|
}
|
|
|
|
type RosModule = {
|
|
module: ModuleRow
|
|
publishers: TopicRole[]
|
|
subscribers: TopicRole[]
|
|
runtime: 'local' | 'remote'
|
|
}
|
|
|
|
type RosConnection = {
|
|
topic: TopicRole
|
|
publishers: RosEndpoint[]
|
|
subscribers: RosEndpoint[]
|
|
active: boolean
|
|
}
|
|
|
|
type RosEndpoint = {
|
|
node: string
|
|
type: string
|
|
}
|
|
|
|
type RosGraphSnapshot = {
|
|
topics: Array<{
|
|
name: string
|
|
types: string[]
|
|
publishers: RosEndpoint[]
|
|
subscribers: RosEndpoint[]
|
|
}>
|
|
message?: string
|
|
}
|
|
|
|
type WorkspaceRosConnectionsViewProps = {
|
|
dark: boolean
|
|
modules: ModuleRow[]
|
|
remoteWorkspace: boolean
|
|
platformName: string
|
|
rosDomainId: string
|
|
onRefreshModules: () => Promise<void>
|
|
loadRosGraph: () => Promise<RosGraphSnapshot>
|
|
}
|
|
|
|
const TOPIC_TYPES: Record<string, string> = {
|
|
'/amcl_pose': 'geometry_msgs/PoseWithCovarianceStamped',
|
|
'/cloud_map': 'sensor_msgs/PointCloud2',
|
|
'/cmd_vel': 'geometry_msgs/Twist',
|
|
'/joint_states': 'sensor_msgs/JointState',
|
|
'/map': 'nav_msgs/OccupancyGrid',
|
|
'/odom': 'nav_msgs/Odometry',
|
|
'/plan': 'nav_msgs/Path',
|
|
'/scan': 'sensor_msgs/LaserScan',
|
|
'/tf': 'tf2_msgs/TFMessage',
|
|
'/tf_static': 'tf2_msgs/TFMessage',
|
|
}
|
|
|
|
function topic(name: string): TopicRole {
|
|
return { name, type: TOPIC_TYPES[name] ?? 'ROS message' }
|
|
}
|
|
|
|
function moduleTopics(module: ModuleRow): Pick<RosModule, 'publishers' | 'subscribers'> {
|
|
const name = module.name.toLowerCase()
|
|
const is3d = name.includes('3d')
|
|
|
|
if (name.includes('mkmini') || name.includes('turtlebot') || name.includes('vehicle')) {
|
|
return { publishers: ['/odom', '/tf', '/joint_states'].map(topic), subscribers: ['/cmd_vel'].map(topic) }
|
|
}
|
|
if (name.includes('gazebo')) {
|
|
return { publishers: ['/clock', '/tf', '/joint_states'].map(topic), subscribers: [] }
|
|
}
|
|
if (name.includes('perception')) {
|
|
return {
|
|
publishers: (is3d ? ['/cloud_map'] : ['/scan']).map(topic),
|
|
subscribers: ['/tf'].map(topic),
|
|
}
|
|
}
|
|
if (name.includes('slam')) {
|
|
return {
|
|
publishers: (is3d ? ['/cloud_map', '/map', '/tf'] : ['/map', '/tf']).map(topic),
|
|
subscribers: (is3d ? ['/cloud_map', '/odom', '/tf'] : ['/scan', '/odom', '/tf']).map(topic),
|
|
}
|
|
}
|
|
if (name.includes('localization')) {
|
|
return {
|
|
publishers: ['/amcl_pose', '/tf'].map(topic),
|
|
subscribers: ['/map', '/scan', '/odom', '/tf'].map(topic),
|
|
}
|
|
}
|
|
if (name.includes('planning')) {
|
|
return {
|
|
publishers: ['/plan', '/cmd_vel'].map(topic),
|
|
subscribers: ['/map', '/amcl_pose', '/odom', '/tf'].map(topic),
|
|
}
|
|
}
|
|
if (name.includes('control')) {
|
|
return {
|
|
publishers: ['/cmd_vel'].map(topic),
|
|
subscribers: ['/plan', '/odom', '/tf'].map(topic),
|
|
}
|
|
}
|
|
if (name.includes('rviz') || name.includes('gui')) {
|
|
return {
|
|
publishers: [],
|
|
subscribers: ['/map', '/scan', '/cloud_map', '/plan', '/tf'].map(topic),
|
|
}
|
|
}
|
|
if (name.includes('map')) {
|
|
return { publishers: ['/map'].map(topic), subscribers: [] }
|
|
}
|
|
return { publishers: [], subscribers: [] }
|
|
}
|
|
|
|
function resolveRuntime(module: ModuleRow, remoteWorkspace: boolean): RosModule['runtime'] {
|
|
if (module.runtimeTarget === 'LOCAL') return 'local'
|
|
if (module.runtimeTarget === 'PLATFORM') return 'remote'
|
|
return remoteWorkspace ? 'remote' : 'local'
|
|
}
|
|
|
|
function getModuleNodeName(module: ModuleRow): string {
|
|
return `/${module.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`
|
|
}
|
|
|
|
function summarizeEndpoints(endpoints: RosEndpoint[]): string {
|
|
if (!endpoints.length) return '대기 중'
|
|
const firstNode = endpoints[0].node
|
|
const truncatedFirstNode = firstNode.length > 14 ? `${firstNode.slice(0, 14)}...` : firstNode
|
|
return endpoints.length === 1 ? truncatedFirstNode : `${truncatedFirstNode} 외 ${endpoints.length - 1}`
|
|
}
|
|
|
|
function RuntimeLane({
|
|
title,
|
|
modules,
|
|
dark,
|
|
selectedModuleKeys,
|
|
onToggleModule,
|
|
onSelectAll,
|
|
onClearSelection,
|
|
}: {
|
|
title: string
|
|
modules: RosModule[]
|
|
dark: boolean
|
|
selectedModuleKeys: Record<string, boolean>
|
|
onToggleModule: (module: ModuleRow) => void
|
|
onSelectAll: () => void
|
|
onClearSelection: () => void
|
|
}): React.JSX.Element {
|
|
const hasSelectedModules = modules.some(({ module }) => selectedModuleKeys[module.folderName ?? module.name] !== false)
|
|
const hasUnselectedModules = modules.some(({ module }) => selectedModuleKeys[module.folderName ?? module.name] === false)
|
|
return (
|
|
<section className={['min-h-0 min-w-[200px] border-r last:border-r-0', dark ? 'border-white/10 bg-[#222222]' : 'border-[#dbe1eb] bg-[#f8f9fc]'].join(' ')}>
|
|
<header className={['sticky top-0 z-10 flex items-center justify-between gap-2 border-b px-4 py-3', dark ? 'border-white/10 bg-[#252525]' : 'border-[#dbe1eb] bg-white'].join(' ')}>
|
|
<div className="flex items-center gap-2">
|
|
<span className={['grid h-6 w-6 place-items-center rounded-md', dark ? 'bg-[#0f766e]/25 text-[#7ee7d3]' : 'bg-[#e7f8f5] text-[#0f766e]'].join(' ')}>
|
|
<Radio size={14} />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<strong className={['block truncate text-[12px]', dark ? 'text-white/90' : 'text-[#182033]'].join(' ')}>{title}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-0.5">
|
|
<button
|
|
type="button"
|
|
aria-label="전체 선택"
|
|
title="전체 선택"
|
|
onClick={onSelectAll}
|
|
disabled={!hasUnselectedModules}
|
|
className={['grid h-6 w-6 place-items-center rounded transition', hasUnselectedModules ? 'cursor-pointer' : 'cursor-default opacity-35', dark ? 'text-white/52 hover:bg-white/10 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/52' : 'text-[#6c7890] hover:bg-[#edf2f8] hover:text-[#1e293b] disabled:hover:bg-transparent disabled:hover:text-[#6c7890]'].join(' ')}
|
|
>
|
|
<ListChecks size={15} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="전체 해제"
|
|
title="전체 해제"
|
|
onClick={onClearSelection}
|
|
disabled={!hasSelectedModules}
|
|
className={['grid h-6 w-6 place-items-center rounded transition', hasSelectedModules ? 'cursor-pointer' : 'cursor-default opacity-35', dark ? 'text-white/52 hover:bg-white/10 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/52' : 'text-[#6c7890] hover:bg-[#edf2f8] hover:text-[#1e293b] disabled:hover:bg-transparent disabled:hover:text-[#6c7890]'].join(' ')}
|
|
>
|
|
<ListX size={15} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<div className="space-y-2 p-3">
|
|
{modules.length ? modules.map(({ module, publishers, subscribers }) => {
|
|
const moduleKey = module.folderName ?? module.name
|
|
const selected = selectedModuleKeys[moduleKey] !== false
|
|
return (
|
|
<button
|
|
key={moduleKey}
|
|
type="button"
|
|
aria-pressed={selected}
|
|
onClick={() => onToggleModule(module)}
|
|
className={[
|
|
'w-full cursor-pointer rounded-md border p-3 text-left shadow-sm transition hover:-translate-y-px',
|
|
selected
|
|
? dark ? 'border-[#60a5fa]/45 bg-[#2563eb]/14 ring-1 ring-[#2563eb]/18 hover:bg-[#2563eb]/20' : 'border-[#82b3ff] bg-[#edf5ff] ring-1 ring-[#2563eb]/10 hover:bg-[#e3efff]'
|
|
: dark ? 'border-white/10 bg-[#2a2a2a] opacity-58 hover:border-white/24 hover:opacity-80' : 'border-[#dbe1eb] bg-white opacity-60 hover:border-[#9fb7db] hover:opacity-85'
|
|
].join(' ')}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<span className={['mt-0.5 h-2 w-2 shrink-0 rounded-full', module.state === 'running' ? 'bg-[#31c48d] shadow-[0_0_8px_rgba(49,196,141,0.75)]' : module.state === 'stopped' ? 'bg-[#aab4c3]' : 'bg-[#f59e0b]'].join(' ')} />
|
|
<div className="min-w-0 flex-1">
|
|
<strong className={['block truncate text-[12px]', dark ? 'text-white/88' : 'text-[#20293b]'].join(' ')}>{module.name}</strong>
|
|
</div>
|
|
</div>
|
|
{(publishers.length > 0 || subscribers.length > 0) ? (
|
|
<div className="mt-3 space-y-2 text-[10px]">
|
|
{publishers.length > 0 && (
|
|
<div>
|
|
<span className={dark ? 'text-[#7ee7d3]' : 'text-[#087f6a]'}>발행</span>
|
|
<div className={['mt-1 flex flex-wrap gap-1', dark ? 'text-white/62' : 'text-[#556176]'].join(' ')}>
|
|
{publishers.map((item) => <code key={item.name} className={['rounded px-1.5 py-0.5', dark ? 'bg-[#ffffff0d]' : 'bg-[#eef2f7]'].join(' ')}>{item.name}</code>)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{subscribers.length > 0 && (
|
|
<div>
|
|
<span className={dark ? 'text-[#9fc6ff]' : 'text-[#2563a8]'}>구독</span>
|
|
<div className={['mt-1 flex flex-wrap gap-1', dark ? 'text-white/62' : 'text-[#556176]'].join(' ')}>
|
|
{subscribers.map((item) => <code key={item.name} className={['rounded px-1.5 py-0.5', dark ? 'bg-[#ffffff0d]' : 'bg-[#eef2f7]'].join(' ')}>{item.name}</code>)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className={['mt-3 text-[10px]', dark ? 'text-white/38' : 'text-[#8690a2]'].join(' ')}>토픽 정의가 아직 없습니다.</p>
|
|
)}
|
|
</button>
|
|
)
|
|
}) : (
|
|
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>
|
|
실행 중인 모듈이 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function TopicDetailDrawer({
|
|
dark,
|
|
rosDomainId,
|
|
connection,
|
|
onClose,
|
|
onResizeStart,
|
|
}: {
|
|
dark: boolean
|
|
rosDomainId: string
|
|
connection: RosConnection
|
|
onClose: () => void
|
|
onResizeStart: (event: React.MouseEvent<HTMLDivElement>) => void
|
|
}): React.JSX.Element {
|
|
return (
|
|
<section className={['relative grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-t', dark ? 'border-white/12 bg-[#242424]' : 'border-[#cfd8e6] bg-white'].join(' ')}>
|
|
<div
|
|
role="separator"
|
|
aria-label="토픽 상세 패널 크기 조절"
|
|
onMouseDown={onResizeStart}
|
|
className={['group absolute inset-x-0 top-0 z-10 grid h-2 cursor-ns-resize items-start justify-items-center', dark ? 'hover:bg-white/6' : 'hover:bg-[#edf2f8]'].join(' ')}
|
|
>
|
|
<span className={['mt-0.5 h-px w-10 rounded-full transition', dark ? 'bg-white/22 group-hover:bg-white/55' : 'bg-[#9daabc] group-hover:bg-[#64748b]'].join(' ')} />
|
|
</div>
|
|
<header className={['flex min-h-12 items-center justify-between border-b px-4 py-2', dark ? 'border-white/10' : 'border-[#e0e6ee]'].join(' ')}>
|
|
<div className="flex min-w-0 items-center gap-2 self-center">
|
|
<span className={['grid h-7 w-7 shrink-0 place-items-center rounded-md', dark ? 'bg-[#2563eb]/18 text-[#9fc6ff]' : 'bg-[#edf4ff] text-[#2563a8]'].join(' ')}><Send size={14} /></span>
|
|
<div className="min-w-0">
|
|
<strong className={['block text-[12px]', dark ? 'text-white/90' : 'text-[#1e293b]'].join(' ')}>토픽 상세</strong>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="토픽 상세 닫기"
|
|
onClick={onClose}
|
|
className={['grid h-7 w-7 shrink-0 cursor-pointer place-items-center self-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#718096] hover:bg-[#edf2f8] hover:text-[#1e293b]'].join(' ')}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</header>
|
|
<div className="thin-y-scrollbar min-h-0 overflow-auto p-4">
|
|
<div className="grid min-w-[660px] grid-cols-[minmax(230px,0.8fr)_minmax(250px,1fr)_minmax(250px,1fr)] gap-4">
|
|
<div>
|
|
<div className={['rounded-md border p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#e0e6ee] bg-[#f8fafc]'].join(' ')}>
|
|
<dl className={['grid grid-cols-2 gap-x-3 gap-y-2 text-[10px]', dark ? 'text-white/62' : 'text-[#4f5d74]'].join(' ')}>
|
|
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>토픽</dt><dd className="mt-0.5"><code className={['block break-all font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#1d4ed8]'].join(' ')}>{connection.topic.name}</code></dd></div>
|
|
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>메시지 타입</dt><dd className="mt-0.5"><code className="block break-all font-semibold">{connection.topic.type}</code></dd></div>
|
|
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>상태</dt><dd className={['mt-0.5 inline-flex items-center gap-1 font-semibold', connection.active ? 'text-[#22a06b]' : dark ? 'text-white/40' : 'text-[#8a95a8]'].join(' ')}><span className={['h-1.5 w-1.5 rounded-full', connection.active ? 'bg-[#31c48d]' : 'bg-[#a9b3c2]'].join(' ')} />{connection.active ? '발행·구독 연결됨' : '연결 대기'}</dd></div>
|
|
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>발행자</dt><dd className="mt-0.5 font-semibold">{connection.publishers.length}</dd></div>
|
|
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>구독자</dt><dd className="mt-0.5 font-semibold">{connection.subscribers.length}</dd></div>
|
|
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>QoS</dt><dd className="mt-0.5 font-semibold">기본값</dd></div>
|
|
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>Domain</dt><dd className="mt-0.5 font-semibold">{rosDomainId}</dd></div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>발행 노드</h3>
|
|
<div className="mt-2 space-y-1.5">
|
|
{connection.publishers.length ? connection.publishers.map((endpoint) => (
|
|
<div key={endpoint.node} className={['rounded-md border px-2.5 py-2 text-[11px]', dark ? 'border-white/10 bg-white/[0.03] text-white/72' : 'border-[#e0e6ee] bg-[#fbfcfe] text-[#42516a]'].join(' ')}>
|
|
<strong className="block truncate font-semibold">{endpoint.node}</strong>
|
|
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#8995a8]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
|
|
</div>
|
|
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>발행 노드 없음</p>}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>구독 노드</h3>
|
|
<div className="mt-2 space-y-1.5">
|
|
{connection.subscribers.length ? connection.subscribers.map((endpoint) => (
|
|
<div key={endpoint.node} className={['rounded-md border px-2.5 py-2 text-[11px]', dark ? 'border-white/10 bg-white/[0.03] text-white/72' : 'border-[#e0e6ee] bg-[#fbfcfe] text-[#42516a]'].join(' ')}>
|
|
<strong className="block truncate font-semibold">{endpoint.node}</strong>
|
|
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#8995a8]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
|
|
</div>
|
|
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>구독 노드 없음</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export default function WorkspaceRosConnectionsView({
|
|
dark,
|
|
modules,
|
|
remoteWorkspace,
|
|
platformName,
|
|
rosDomainId,
|
|
onRefreshModules,
|
|
loadRosGraph,
|
|
}: WorkspaceRosConnectionsViewProps): React.JSX.Element {
|
|
const runningRosModules = useMemo<RosModule[]>(() => modules
|
|
.filter((module) => module.state === 'running')
|
|
.map((module) => ({ module, runtime: resolveRuntime(module, remoteWorkspace), ...moduleTopics(module) })), [modules, remoteWorkspace])
|
|
const [selectedModuleKeys, setSelectedModuleKeys] = useState<Record<string, boolean>>({})
|
|
const [rosGraph, setRosGraph] = useState<RosGraphSnapshot>({ topics: [] })
|
|
const [rosGraphLoading, setRosGraphLoading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const runningKeys = new Set(runningRosModules.map(({ module }) => module.folderName ?? module.name))
|
|
setSelectedModuleKeys((current) => {
|
|
const next: Record<string, boolean> = {}
|
|
runningKeys.forEach((key) => {
|
|
next[key] = key in current ? current[key] : true
|
|
})
|
|
return next
|
|
})
|
|
}, [runningRosModules])
|
|
|
|
const rosModules = useMemo(
|
|
() => runningRosModules.filter(({ module }) => selectedModuleKeys[module.folderName ?? module.name] !== false),
|
|
[runningRosModules, selectedModuleKeys],
|
|
)
|
|
const connections = useMemo<RosConnection[]>(() => {
|
|
const selectedTopics = new Map<string, TopicRole>()
|
|
rosModules.forEach((module) => {
|
|
module.publishers.forEach((item) => selectedTopics.set(item.name, item))
|
|
module.subscribers.forEach((item) => selectedTopics.set(item.name, item))
|
|
})
|
|
const graphTopics = new Map(rosGraph.topics.map((topic) => [topic.name, topic]))
|
|
return [...selectedTopics.values()]
|
|
.map((item) => {
|
|
const graphTopic = graphTopics.get(item.name)
|
|
const fallbackPublishers = rosModules
|
|
.filter((module) => module.publishers.some((entry) => entry.name === item.name))
|
|
.map(({ module }) => ({ node: getModuleNodeName(module), type: item.type }))
|
|
const fallbackSubscribers = rosModules
|
|
.filter((module) => module.subscribers.some((entry) => entry.name === item.name))
|
|
.map(({ module }) => ({ node: getModuleNodeName(module), type: item.type }))
|
|
const publishers = graphTopic?.publishers ?? fallbackPublishers
|
|
const subscribers = graphTopic?.subscribers ?? fallbackSubscribers
|
|
return {
|
|
topic: { name: item.name, type: graphTopic?.types[0] ?? item.type },
|
|
publishers,
|
|
subscribers,
|
|
active: publishers.length > 0 && subscribers.length > 0,
|
|
}
|
|
})
|
|
.filter((connection) => connection.publishers.length || connection.subscribers.length)
|
|
.sort((left, right) => Number(right.active) - Number(left.active) || left.topic.name.localeCompare(right.topic.name))
|
|
}, [rosGraph.topics, rosModules])
|
|
const [selectedTopicName, setSelectedTopicName] = useState<string | null>(null)
|
|
const [topicDetailOpen, setTopicDetailOpen] = useState(false)
|
|
const [topicDetailHeight, setTopicDetailHeight] = useState(320)
|
|
const selectedConnection = connections.find((connection) => connection.topic.name === selectedTopicName) ?? null
|
|
|
|
useEffect(() => {
|
|
if (!selectedTopicName) return
|
|
if (!selectedConnection) {
|
|
setSelectedTopicName(null)
|
|
setTopicDetailOpen(false)
|
|
}
|
|
}, [selectedConnection, selectedTopicName])
|
|
|
|
const localModules = runningRosModules.filter((module) => module.runtime === 'local')
|
|
const remoteModules = runningRosModules.filter((module) => module.runtime === 'remote')
|
|
const primaryRuntime = remoteWorkspace ? 'remote' : 'local'
|
|
const primaryModules = primaryRuntime === 'remote' ? remoteModules : localModules
|
|
const secondaryModules = primaryRuntime === 'remote' ? localModules : remoteModules
|
|
const runningModules = runningRosModules.length
|
|
const activeConnections = connections.filter((connection) => connection.active).length
|
|
const toggleModuleConnection = (module: ModuleRow): void => {
|
|
const key = module.folderName ?? module.name
|
|
setSelectedModuleKeys((current) => ({ ...current, [key]: current[key] === false }))
|
|
}
|
|
const setRuntimeModuleSelection = (runtime: RosModule['runtime'], selected: boolean): void => {
|
|
const runtimeModules = runtime === 'local' ? localModules : remoteModules
|
|
setSelectedModuleKeys((current) => {
|
|
const next = { ...current }
|
|
runtimeModules.forEach(({ module }) => {
|
|
next[module.folderName ?? module.name] = selected
|
|
})
|
|
return next
|
|
})
|
|
}
|
|
const startTopicDetailResize = (event: React.MouseEvent<HTMLDivElement>): void => {
|
|
event.preventDefault()
|
|
const startY = event.clientY
|
|
const startHeight = topicDetailHeight
|
|
const onMouseMove = (moveEvent: MouseEvent): void => {
|
|
const nextHeight = Math.min(560, Math.max(180, startHeight - (moveEvent.clientY - startY)))
|
|
setTopicDetailHeight(nextHeight)
|
|
}
|
|
const onMouseUp = (): void => {
|
|
window.removeEventListener('mousemove', onMouseMove)
|
|
window.removeEventListener('mouseup', onMouseUp)
|
|
}
|
|
window.addEventListener('mousemove', onMouseMove)
|
|
window.addEventListener('mouseup', onMouseUp)
|
|
}
|
|
const refreshRosGraph = async (refreshModules = false): Promise<void> => {
|
|
setRosGraphLoading(true)
|
|
try {
|
|
if (refreshModules) await onRefreshModules()
|
|
setRosGraph(await loadRosGraph())
|
|
} catch (error) {
|
|
setRosGraph({ topics: [], message: error instanceof Error ? error.message : 'ROS 그래프 조회에 실패했습니다.' })
|
|
} finally {
|
|
setRosGraphLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void refreshRosGraph()
|
|
}, [remoteWorkspace, rosDomainId])
|
|
|
|
return (
|
|
<section
|
|
data-help="workspace-ros-connections"
|
|
className={['grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
|
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
|
|
>
|
|
<div className={['flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-[#31c48d]/25 bg-[#31c48d]/10 text-[#8fe8bf]' : 'border-[#bcebd5] bg-[#effcf5] text-[#168254]'].join(' ')}>
|
|
<Activity size={13} /> 실행 모듈 {runningModules}
|
|
</span>
|
|
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
|
|
<Waypoints size={13} /> 토픽 {connections.length}
|
|
</span>
|
|
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
|
|
<CircleDot size={13} /> 활성 연결 {activeConnections}
|
|
</span>
|
|
{rosGraph.message && (
|
|
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
|
|
{rosGraph.message}
|
|
</span>
|
|
)}
|
|
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/62' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#657187]'].join(' ')}>
|
|
ROS Domain {rosDomainId}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void refreshRosGraph(true)}
|
|
disabled={rosGraphLoading}
|
|
className={['inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-semibold transition', rosGraphLoading ? 'cursor-wait opacity-60' : 'cursor-pointer', dark ? 'border-white/12 text-white/72 hover:bg-white/10 hover:text-white' : 'border-[#d5dce8] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')}
|
|
>
|
|
<RefreshCw size={13} className={rosGraphLoading ? 'animate-spin' : ''} /> 새로고침
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className="grid min-h-0"
|
|
style={{ gridTemplateRows: topicDetailOpen && selectedConnection ? `minmax(0, 1fr) ${topicDetailHeight}px` : 'minmax(0, 1fr)' }}
|
|
>
|
|
<div className="min-h-0 overflow-auto">
|
|
<div className="grid min-h-full min-w-[700px] grid-cols-[minmax(200px,0.9fr)_minmax(300px,1.35fr)_minmax(200px,0.9fr)]">
|
|
<RuntimeLane
|
|
dark={dark}
|
|
title={primaryRuntime === 'remote' ? '원격 플랫폼' : '로컬 PC'}
|
|
modules={primaryModules}
|
|
selectedModuleKeys={selectedModuleKeys}
|
|
onToggleModule={toggleModuleConnection}
|
|
onSelectAll={() => setRuntimeModuleSelection(primaryRuntime, true)}
|
|
onClearSelection={() => setRuntimeModuleSelection(primaryRuntime, false)}
|
|
/>
|
|
|
|
<section className={['min-h-0 border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}>
|
|
<div className="mb-3 flex items-center justify-between px-1">
|
|
<div>
|
|
<strong className={['block text-[12px]', dark ? 'text-white/88' : 'text-[#1f2937]'].join(' ')}>토픽 연결</strong>
|
|
<span className={['mt-0.5 block text-[10px]', dark ? 'text-white/42' : 'text-[#758196]'].join(' ')}>발행 노드에서 구독 노드로 전달</span>
|
|
</div>
|
|
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#7a8597]'].join(' ')}>{activeConnections}/{connections.length} 활성</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{connections.length ? connections.map((connection) => {
|
|
const selected = topicDetailOpen && selectedConnection?.topic.name === connection.topic.name
|
|
return (
|
|
<button
|
|
key={connection.topic.name}
|
|
type="button"
|
|
onClick={() => {
|
|
if (topicDetailOpen && selectedConnection?.topic.name === connection.topic.name) {
|
|
setTopicDetailOpen(false)
|
|
return
|
|
}
|
|
setSelectedTopicName(connection.topic.name)
|
|
setTopicDetailOpen(true)
|
|
}}
|
|
className={[
|
|
'grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_28px_minmax(0,1fr)] items-center gap-2 rounded-md border p-2.5 text-left transition',
|
|
selected
|
|
? dark ? 'border-[#60a5fa]/55 bg-[#2563eb]/14' : 'border-[#82b3ff] bg-[#edf5ff]'
|
|
: dark ? 'border-white/10 bg-[#282828] hover:border-white/22' : 'border-[#dbe2ec] bg-white hover:border-[#b8c9e5]'
|
|
].join(' ')}
|
|
>
|
|
<div className="min-w-0">
|
|
<span className={['block truncate text-[10px] font-semibold', dark ? 'text-[#7ee7d3]' : 'text-[#087f6a]'].join(' ')}>발행 {connection.publishers.length}</span>
|
|
<span className={['mt-0.5 block truncate text-[11px]', dark ? 'text-white/72' : 'text-[#4b5870]'].join(' ')} title={connection.publishers.map((endpoint) => endpoint.node).join(', ')}>{summarizeEndpoints(connection.publishers)}</span>
|
|
</div>
|
|
<span className={['grid h-7 w-7 place-items-center rounded-full border', connection.active ? (dark ? 'border-[#31c48d]/40 bg-[#31c48d]/12 text-[#7ee7b5]' : 'border-[#9ae4bf] bg-[#ebfff4] text-[#1d9b62]') : (dark ? 'border-white/12 text-white/32' : 'border-[#dce3ed] text-[#98a3b4]')].join(' ')}>
|
|
<ArrowRight size={14} />
|
|
</span>
|
|
<div className="min-w-0 text-right">
|
|
<span className={['block truncate text-[10px] font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#2563a8]'].join(' ')}>구독 {connection.subscribers.length}</span>
|
|
<span className={['mt-0.5 block truncate text-[11px]', dark ? 'text-white/72' : 'text-[#4b5870]'].join(' ')} title={connection.subscribers.map((endpoint) => endpoint.node).join(', ')}>{summarizeEndpoints(connection.subscribers)}</span>
|
|
</div>
|
|
<code className={['col-span-3 mt-1 block truncate text-center text-[10px]', dark ? 'text-white/44' : 'text-[#7e899b]'].join(' ')}>{connection.topic.name}</code>
|
|
</button>
|
|
)
|
|
}) : (
|
|
<div className={['rounded-md border border-dashed px-4 py-10 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>
|
|
실행 모듈에서 확인된 토픽이 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<RuntimeLane
|
|
dark={dark}
|
|
title={primaryRuntime === 'remote' ? '로컬 PC' : '원격 플랫폼'}
|
|
modules={secondaryModules}
|
|
selectedModuleKeys={selectedModuleKeys}
|
|
onToggleModule={toggleModuleConnection}
|
|
onSelectAll={() => setRuntimeModuleSelection(primaryRuntime === 'remote' ? 'local' : 'remote', true)}
|
|
onClearSelection={() => setRuntimeModuleSelection(primaryRuntime === 'remote' ? 'local' : 'remote', false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{topicDetailOpen && selectedConnection && (
|
|
<TopicDetailDrawer
|
|
dark={dark}
|
|
rosDomainId={rosDomainId}
|
|
connection={selectedConnection}
|
|
onClose={() => setTopicDetailOpen(false)}
|
|
onResizeStart={startTopicDetailResize}
|
|
/>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|