From 99f0451412a9887083f66e89a96f9ca9bcf7fcc2 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 13 Aug 2026 17:17:47 +0900 Subject: [PATCH] feat(lms): add ROS connection status workspace view --- package.json | 2 +- resources/slam-ros-bridge/Dockerfile | 4 +- src/main/index.ts | 159 ++++- src/preload/index.ts | 6 + src/renderer/public/slam-viewer/index.html | 43 +- src/renderer/src/App.tsx | 43 +- src/renderer/src/env.d.ts | 22 + .../workspace/WorkspaceActivityHeader.tsx | 3 +- .../src/features/workspace/WorkspaceNav.tsx | 6 +- .../features/workspace/WorkspaceQaPanel.tsx | 5 +- .../workspace/WorkspaceRosConnectionsView.tsx | 580 ++++++++++++++++++ .../workspace/WorldMapSelectorModal.tsx | 4 +- 12 files changed, 836 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/features/workspace/WorkspaceRosConnectionsView.tsx diff --git a/package.json b/package.json index 293382f..d4790a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.11", + "version": "1.1.14", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/resources/slam-ros-bridge/Dockerfile b/resources/slam-ros-bridge/Dockerfile index 7f27bd3..70ddb8f 100644 --- a/resources/slam-ros-bridge/Dockerfile +++ b/resources/slam-ros-bridge/Dockerfile @@ -1,4 +1,4 @@ -FROM ros:humble-ros-base +FROM future-nav2-gazebo:humble WORKDIR /app @@ -6,7 +6,7 @@ COPY requirements.txt /app/requirements.txt COPY bridge.py /app/bridge.py RUN apt-get update && \ - apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs ros-humble-rtabmap-msgs ros-humble-rtabmap-util && \ + apt-get install -y --no-install-recommends python3-pip && \ pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \ rm -rf /var/lib/apt/lists/* diff --git a/src/main/index.ts b/src/main/index.ts index 3bf2930..920bfbc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,7 +28,10 @@ const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime') const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda' const nav2DockerImage = 'future-nav2-gazebo:humble' const autowareRosBridgeImage = 'autoware-ros-bridge' -const slamRosBridgeImage = 'slam-ros-bridge' +// Keep the bundled bridge image revisioned. Reusing `latest` leaves an old +// Docker image in place after an LMS update, even when bridge dependencies or +// protocol code changed. +const slamRosBridgeImage = 'slam-ros-bridge:20260813-3d-map-v2' const slamSimulationRuntimeImage = 'future-nav2-gazebo:humble' const slamSimulationRuntimeContainerName = 'nav2-slam' const rosBridgePort = 8765 @@ -172,6 +175,23 @@ type CourseModuleInput = { runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null } +type RosGraphEndpoint = { + node: string + type: string +} + +type RosGraphTopic = { + name: string + types: string[] + publishers: RosGraphEndpoint[] + subscribers: RosGraphEndpoint[] +} + +type RosGraphSnapshot = { + topics: RosGraphTopic[] + message?: string +} + type CustomModuleScaffoldInput = { name: string category: string @@ -1138,6 +1158,107 @@ async function runCommand(command: string, args: string[], cwd?: string, env?: N await runCommandOutput(command, args, cwd, env, timeoutMs) } +const rosGraphMarker = '__AUTONOMY_STUDIO_ROS_GRAPH__' +const rosGraphProbeScript = [ + 'import json, rclpy', + 'from rclpy.node import Node', + 'rclpy.init()', + 'node = Node("autonomy_studio_graph_probe")', + 'try:', + ' for _ in range(4): rclpy.spin_once(node, timeout_sec=0.2)', + ' def endpoint(info):', + ' namespace = (getattr(info, "node_namespace", "") or "").rstrip("/")', + ' name = getattr(info, "node_name", "") or "unknown"', + ' return {"node": (namespace + "/" + name) if namespace else "/" + name, "type": getattr(info, "topic_type", "") or ""}', + ' topics = []', + ' for name, types in sorted(node.get_topic_names_and_types()):', + ' if name in ("/parameter_events", "/rosout"): continue', + ' topics.append({"name": name, "types": list(types), "publishers": [endpoint(item) for item in node.get_publishers_info_by_topic(name)], "subscribers": [endpoint(item) for item in node.get_subscriptions_info_by_topic(name)]})', + ' payload = {"topics": topics}', + 'except Exception as error:', + ' payload = {"topics": [], "message": str(error)}', + 'finally:', + ' node.destroy_node()', + ' rclpy.shutdown()', + `print(${JSON.stringify(rosGraphMarker)} + json.dumps(payload, separators=(",", ":")))`, +].join('\n') + +function normalizeRosGraphSnapshot(value: unknown): RosGraphSnapshot { + const snapshot = value && typeof value === 'object' ? value as Partial : {} + const topics = Array.isArray(snapshot.topics) ? snapshot.topics.flatMap((topic): RosGraphTopic[] => { + if (!topic || typeof topic !== 'object') return [] + const candidate = topic as Partial + if (typeof candidate.name !== 'string' || !candidate.name.startsWith('/')) return [] + const normalizeEndpoints = (entries: unknown): RosGraphEndpoint[] => Array.isArray(entries) + ? entries.flatMap((entry): RosGraphEndpoint[] => { + if (!entry || typeof entry !== 'object') return [] + const endpoint = entry as Partial + return typeof endpoint.node === 'string' && endpoint.node.trim() + ? [{ node: endpoint.node.trim(), type: typeof endpoint.type === 'string' ? endpoint.type : '' }] + : [] + }) + : [] + return [{ + name: candidate.name, + types: Array.isArray(candidate.types) ? candidate.types.filter((type): type is string => typeof type === 'string') : [], + publishers: normalizeEndpoints(candidate.publishers), + subscribers: normalizeEndpoints(candidate.subscribers), + }] + }) : [] + return { topics, ...(typeof snapshot.message === 'string' && snapshot.message ? { message: snapshot.message } : {}) } +} + +function parseRosGraphSnapshot(output: string): RosGraphSnapshot { + const markerIndex = output.lastIndexOf(rosGraphMarker) + if (markerIndex < 0) return { topics: [], message: 'ROS 그래프 조회 결과를 읽지 못했습니다.' } + try { + return normalizeRosGraphSnapshot(JSON.parse(output.slice(markerIndex + rosGraphMarker.length).trim())) + } catch { + return { topics: [], message: 'ROS 그래프 조회 결과 형식이 올바르지 않습니다.' } + } +} + +function pickRosGraphProbeContainer(containerNames: string[]): string | null { + const bridgeContainer = ['slam-ros-bridge', 'nav2-ros-bridge', 'autoware-ros-bridge'].find((name) => containerNames.includes(name)) + if (bridgeContainer) return bridgeContainer + return containerNames.find((name) => name.startsWith('lms-nav2-') && isAllowedRuntimeContainerName(name)) ?? null +} + +function rosGraphProbeCommand(): string { + const encodedScript = Buffer.from(rosGraphProbeScript, 'utf8').toString('base64') + return [ + 'source /opt/ros/humble/setup.bash >/dev/null 2>&1 || true', + `printf %s ${quoteShellSingle(encodedScript)} | base64 -d | python3`, + ].join(' && ') +} + +async function collectLocalRosGraph(): Promise { + try { + const names = (await runCommandOutput('docker', ['ps', '--format', '{{.Names}}'], undefined, undefined, 8_000)) + .split('\n').map((line) => line.trim()).filter(Boolean) + const containerName = pickRosGraphProbeContainer(names) + if (!containerName) return { topics: [], message: '실행 중인 ROS 컨테이너가 없습니다.' } + const output = await runCommandOutput('docker', ['exec', containerName, 'bash', '-lc', rosGraphProbeCommand()], undefined, undefined, 12_000) + return parseRosGraphSnapshot(output) + } catch (error) { + return { topics: [], message: error instanceof Error ? error.message : 'ROS 그래프 조회에 실패했습니다.' } + } +} + +async function collectRemoteRosGraph(targetInput: RemoteWorkspaceTarget): Promise { + try { + const target = normalizeRemoteTarget(targetInput) + const names = (await runSshOutput(target, 'timeout 8s docker ps --format "{{.Names}}" 2>/dev/null || true', 15_000)) + .split('\n').map((line) => line.trim()).filter(Boolean) + const containerName = pickRosGraphProbeContainer(names) + if (!containerName) return { topics: [], message: '실행 중인 ROS 컨테이너가 없습니다.' } + const output = await runSshOutput(target, `timeout 12s docker exec ${quoteShellSingle(containerName)} bash -lc ${quoteShellSingle(rosGraphProbeCommand())}`, 20_000) + return parseRosGraphSnapshot(output) + } catch (error) { + return { topics: [], message: error instanceof Error ? error.message : 'ROS 그래프 조회에 실패했습니다.' } + } +} + function safeArtifactFileName(url: string, fileName?: string | null): string { const rawName = fileName?.trim() || basename(new URL(url).pathname) || 'artifact.tar' return rawName.replace(/[^a-zA-Z0-9._-]/g, '_') @@ -2516,12 +2637,15 @@ function resolveVisualizationBridgeImage( return configuredImage } if (stack === 'slam') { - if (!configuredImage || configuredImage === slamRosBridgeImage) { + if (!configuredImage || configuredImage === 'slam-ros-bridge' || configuredImage === 'slam-ros-bridge:latest' || configuredImage === slamRosBridgeImage) { return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || slamSimulationRuntimeImage } return configuredImage } - return configuredImage || visualizationBridgeImageName(stack) + if (!configuredImage || configuredImage === 'slam-ros-bridge' || configuredImage === 'slam-ros-bridge:latest') { + return visualizationBridgeImageName(stack) + } + return configuredImage } function bridgeContainerCommand(stack: VisualizationStack): string { @@ -2565,6 +2689,22 @@ async function readContainerEnvironmentValue(containerName: string, key: string) } } +async function readContainerMountSource(containerName: string, destination: string): Promise { + try { + const output = await runCommandOutput( + 'docker', + ['inspect', '--format', `{{range .Mounts}}{{if eq .Destination ${JSON.stringify(destination)}}}{{.Source}}{{end}}{{end}}`, containerName], + undefined, + undefined, + 5000, + ) + const source = output.trim() + return source ? resolve(source) : null + } catch { + return null + } +} + async function findPlanningWorkspacePath(workspaceRoot?: string | null): Promise { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) return null @@ -2830,6 +2970,8 @@ async function startVisualizationBridge( const bridgeImage = resolveVisualizationBridgeImage(stack, bridge, resolvedProfile?.runtimeImages) const bridgePort = bridge?.port ?? rosBridgePort const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2' + const layoutWorkspaceRoot = usesSlamBridge ? expandUserPath(workspaceRoot) : null + if (layoutWorkspaceRoot) await mkdir(resolve(layoutWorkspaceRoot, 'maps'), { recursive: true }) const requestedRosDomainId = String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0' const requestedRosNetworkKey = rosNetworkEnabled(rosNetwork) ? `peer:${rosNetwork?.peer?.host?.trim() ?? ''}` @@ -2853,9 +2995,13 @@ async function startVisualizationBridge( const response = await fetch(`http://localhost:${bridgePort}/${usesSlamBridge ? 'health' : 'stack'}`).catch(() => null) const runningRosDomainId = await readContainerEnvironmentValue(containerName, 'ROS_DOMAIN_ID') const runningRosNetworkKey = await readContainerEnvironmentValue(containerName, 'AUTONOMY_STUDIO_ROS_NETWORK') + const runningWorkspaceRoot = layoutWorkspaceRoot + ? await readContainerMountSource(containerName, '/workspace') + : null if (response?.ok && (!runningRosDomainId || runningRosDomainId === requestedRosDomainId) - && runningRosNetworkKey === requestedRosNetworkKey) { + && runningRosNetworkKey === requestedRosNetworkKey + && (!layoutWorkspaceRoot || runningWorkspaceRoot === resolve(layoutWorkspaceRoot))) { return readVisualizationBridgeStatus() } await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) @@ -2913,8 +3059,7 @@ async function startVisualizationBridge( '-e', 'PYTHONUNBUFFERED=1', ] - const layoutWorkspaceRoot = expandUserPath(workspaceRoot) - if (usesSlamBridge && layoutWorkspaceRoot && existsSync(layoutWorkspaceRoot)) { + if (layoutWorkspaceRoot) { args.push( '-v', `${layoutWorkspaceRoot}:/workspace`, '-e', 'WORLD_LAYOUT_PATH=/workspace/world-layout.json', @@ -5238,6 +5383,7 @@ function registerLocalWorkspaceHandlers(): void { closeSplashAndShowMain() }) ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) + ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph()) ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('local-workspace:modules:create-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleScaffoldInput | null) => { if (!input) throw new Error('커스텀 모듈 정보가 없습니다.') @@ -5279,6 +5425,7 @@ function registerLocalWorkspaceHandlers(): void { syncWorkspaceSavedMapArtifacts(workspaceRoot, artifacts) )) ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules)) + ipcMain.handle('remote-workspace:ros-graph', (_event, target: RemoteWorkspaceTarget) => collectRemoteRosGraph(target)) ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ( readRemoteSavedMapArtifacts(target, artifacts) )) diff --git a/src/preload/index.ts b/src/preload/index.ts index 772cea2..a55cb08 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -205,6 +205,9 @@ contextBridge.exposeInMainWorld('localWorkspace', { logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName), updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams) }, + rosGraph: { + snapshot: () => ipcRenderer.invoke('local-workspace:ros-graph') + }, nav2: { setSimulatorPose: (pose: { x: number; y: number; yaw: number }) => ipcRenderer.invoke('local-workspace:nav2:set-simulator-pose', pose), addRuntimeObstacle: (obstacle: { id: string; x: number; y: number; yaw: number; length: number; depth: number; height: number }) => ipcRenderer.invoke('local-workspace:nav2:add-runtime-obstacle', obstacle), @@ -282,6 +285,9 @@ contextBridge.exposeInMainWorld('remoteWorkspace', { restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName) }, + rosGraph: { + snapshot: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:ros-graph', target) + }, visualization: { status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target), start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile, rosNetwork), diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index e2ab27c..8e40ef4 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -480,7 +480,7 @@ const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000; const ENABLE_CAMERA_PREVIEW = false; const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom'; const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world'; -const MKMINI_WORLD_SPACING_SCALE = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world' ? 1.6 : 1; +const MKMINI_WORLD_SINGLE_PILLAR = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world'; const MKMINI_RENDER_LENGTH = 0.84; const MKMINI_RENDER_WIDTH = 0.61; const MKMINI_RENDER_HEIGHT = 0.6575; @@ -1066,14 +1066,10 @@ function applySdfPose(obj, pose) { obj.rotation.set(pose.roll, pose.pitch, pose.yaw, 'XYZ'); } function applyWorldSdfPose(obj, pose) { - applySdfPose(obj, { - ...pose, - x: pose.x * MKMINI_WORLD_SPACING_SCALE, - y: pose.y * MKMINI_WORLD_SPACING_SCALE, - }); + applySdfPose(obj, pose); } function isMkMiniWorldPillar(visual) { - return MKMINI_WORLD_SPACING_SCALE > 1 + return MKMINI_WORLD_SINGLE_PILLAR && /^(one|two|three)_/.test(visual.getAttribute('name') || ''); } function shouldRenderWorldVisual(visual) { @@ -1141,9 +1137,6 @@ async function addMesh(parent, visual, geometry) { if (!uri) return; const url = gazeboUriToPath(uri); const scale = parseScale(getDirectText(meshEl, 'scale', '1 1 1')); - if (MKMINI_WORLD_SPACING_SCALE > 1 && uri.endsWith('/wall.dae')) { - scale.multiplyScalar(MKMINI_WORLD_SPACING_SCALE); - } const material = materialFromVisual(visual); if (/\.stl$/i.test(url)) { @@ -1942,7 +1935,19 @@ function updatePointCloud(payload) { // loaded navigation map after localization starts. if (frameId !== 'map' && hasGlobalMapPointCloud) return; updatePointGeometry(pointCloudGeometry, payload?.points); - if (frameId === 'map') hasGlobalMapPointCloud = true; + if (frameId === 'map') { + hasGlobalMapPointCloud = true; + // The saved 3D cloud is now available. The occupancy grid was only a + // startup fallback while the exported cloud was loading. + if (navigationMapMesh && SHOW_POINT_CLOUD) { + navigationMapRoot.remove(navigationMapMesh); + navigationMapMesh.geometry.dispose(); + navigationMapMesh.material.dispose(); + navigationMapMesh = null; + navigationMapTexture?.dispose(); + navigationMapTexture = null; + } + } const transform = payload?.transform; if (!transform || !Number.isFinite(Number(transform.x)) || !Number.isFinite(Number(transform.y))) { // A cloud already authored in `map` does not need a transform. Hide @@ -2216,7 +2221,10 @@ function drawMiniMap(state) { function updateMiniMapMetadata(map) { if (!map?.width || !map?.height) return; - if (navigationMapLocked) return; + // MAP1 and its metadata are sent as separate WebSocket frames. Accept the + // first metadata frame even when its binary payload arrived first; only a + // fully rendered saved navigation map is locked against later local maps. + if (navigationMapLocked && navigationMapMesh) return; latestMiniMapGrid = { ...(latestMiniMapGrid || {}), ...map, @@ -2314,9 +2322,10 @@ function updateRemoteNavigationRoomTransform() { function renderNavigationOccupancyMap() { const grid = latestMiniMapGrid; - // A 3D SLAM session uses RTAB-Map's accumulated point cloud as the scene - // itself. Keep the occupancy grid for the minimap only, not as a floor plane. - if (SHOW_POINT_CLOUD || NAV2_VIEW_MODE !== 'navigation' || !grid?.data?.length || !grid.width || !grid.height || !grid.resolution) return; + if (NAV2_VIEW_MODE !== 'navigation' || !grid?.data?.length || !grid.width || !grid.height || !grid.resolution || !grid.origin) return false; + // The occupancy grid is a short-lived fallback for a saved 3D map. It + // keeps the scene usable if the point-cloud export is still starting. + if (SHOW_POINT_CLOUD && hasGlobalMapPointCloud) return false; const canvas = document.createElement('canvas'); canvas.width = grid.width; @@ -2379,6 +2388,7 @@ function renderNavigationOccupancyMap() { camera.lookAt(center); navigationMapFocused = true; } + return true; } function applyMapBinary(buffer) { @@ -2410,6 +2420,9 @@ function applyMapBinary(buffer) { data, }; miniMapBitmap = null; + // Do not lock a selected navigation map before its separate metadata frame + // supplies resolution and origin. Otherwise Three.js receives valid /map + // data but cannot construct a plane from it. renderNavigationOccupancyMap(); drawMiniMap(latestSlamState); downloadMapButton.disabled = false; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b2fc07b..1977ebc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -42,6 +42,7 @@ import type { WorkspaceGuideResourceMode } from './features/workspace/WorkspaceG import WorkspaceLogView from './features/workspace/WorkspaceLogView' import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu' import ModuleListView from './features/workspace/ModuleListView' +import WorkspaceRosConnectionsView from './features/workspace/WorkspaceRosConnectionsView' import WorldEditorModal from './features/workspace/WorldEditorModal' import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal' import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel' @@ -794,6 +795,7 @@ function LocalPcWorkspaceMockup({ const logActive = activeIdeActivity === 'logs' const gitActive = activeIdeActivity === 'git' const settingsActive = activeIdeActivity === 'settings' + const rosConnectionsActive = activeIdeActivity === 'ros-connections' const terminalActive = activeIdeActivity === 'workspace' || activeIdeActivity === 'search' const studentSlug = makeStudentSlug(studentEmail) const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected` @@ -811,6 +813,11 @@ function LocalPcWorkspaceMockup({ const sessionWorldMaps = useMemo(() => { const configuredMaps = (curriculumWeek.maps ?? []) .filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2') + .map((map) => { + if (map.key === 'nav2-turtlebot3-world') return { ...map, description: '기본 장애물 환경' } + if (map.key === 'nav2-turtlebot3-house') return { ...map, description: '방과 복도로 구성된 실내 환경' } + return map + }) const customMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom') const baseMaps = configuredMaps.some((map) => map.key === 'nav2-custom') || !customMap ? [...configuredMaps] @@ -2465,10 +2472,12 @@ function LocalPcWorkspaceMockup({ artifacts.push({ name: 'database', fileName: databaseFileName, url: selectedWorldMap.databaseUrl }) databasePath = `/workspace/maps/${databaseFileName}` } - const syncToPlatformWorkspace = remoteWorkspace && visualizationBridgeLocation === 'platform' - if (syncToPlatformWorkspace) { + if (remoteWorkspace) { await window.remoteWorkspace.maps.sync(getRemoteWorkspaceTarget(), artifacts) - } else { + } + // A remote platform always needs the selected map for NAV2-Map, even + // when this PC hosts the visualization bridge for the same ROS session. + if (!remoteWorkspace || visualizationBridgeLocation === 'local') { await window.localWorkspace.maps.sync( remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview, artifacts, @@ -2686,7 +2695,7 @@ function LocalPcWorkspaceMockup({ if (restoredActiveModule) { setModuleWorkspaceSnapshots(restored.moduleSnapshots) restoreModuleSnapshot(restoredActiveModule, restored.moduleSnapshots[restoredActiveModule.name]) - setActiveIdeActivity(['search', 'logs', 'git'].includes(restored.activeIdeActivity) ? restored.activeIdeActivity : 'workspace') + setActiveIdeActivity(['search', 'logs', 'git', 'ros-connections'].includes(restored.activeIdeActivity) ? restored.activeIdeActivity : 'workspace') } restoredWorkspaceUiRef.current = null @@ -3383,7 +3392,7 @@ function LocalPcWorkspaceMockup({ className="grid h-[calc(100%-40px)]" style={{ gridTemplateColumns: `${leftPanelMinimized ? 0 : leftPanelWidth}px ${leftPanelMinimized ? 0 : 10}px 48px ${workspaceActive ? projectTreeWidth : 0}px ${workspaceActive ? 6 : 0}px minmax(0,1fr) ${rightPanelMinimized ? 0 : 10}px ${rightPanelMinimized ? 0 : rightPanelWidth}px`, - gridTemplateRows: workspaceActive ? (gitActive ? '69px minmax(0,1fr)' : (exampleActive || settingsActive || !selectedModule ? '69px minmax(0,1fr)' : '38px minmax(0,1fr)')) : 'minmax(0,1fr)' + gridTemplateRows: workspaceActive ? (gitActive ? '69px minmax(0,1fr)' : (exampleActive || settingsActive || rosConnectionsActive || !selectedModule ? '69px minmax(0,1fr)' : '38px minmax(0,1fr)')) : 'minmax(0,1fr)' }} > )} - {workspaceActive && !selectedModule && !exampleActive && !settingsActive && !gitActive ? ( + {workspaceActive && !selectedModule && !exampleActive && !settingsActive && !gitActive && !rosConnectionsActive ? ( ) : ( <> - {!logActive && !settingsActive && !gitActive && ( + {!logActive && !settingsActive && !gitActive && !rosConnectionsActive && ( )} - {!logActive && !settingsActive && !gitActive && ( + {!logActive && !settingsActive && !gitActive && !rosConnectionsActive && (
loadAvailableModules({ silent: true })} + loadRosGraph={() => ( + remoteWorkspace + ? window.remoteWorkspace.rosGraph.snapshot(getRemoteWorkspaceTarget()) + : window.localWorkspace.rosGraph.snapshot() + )} + /> + ) : settingsActive ? ( Promise<{ + topics: Array<{ + name: string + types: string[] + publishers: Array<{ node: string; type: string }> + subscribers: Array<{ node: string; type: string }> + }> + message?: string + }> + } nav2: { setSimulatorPose: (pose: { x: number; y: number; yaw: number }) => Promise<{ ok: boolean; message: string }> addRuntimeObstacle: (obstacle: { id: string; x: number; y: number; yaw: number; length: number; depth: number; height: number }) => Promise<{ ok: boolean; message: string }> @@ -312,6 +323,17 @@ interface Window { restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }> logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> } + rosGraph: { + snapshot: (target: RemoteWorkspaceTarget) => Promise<{ + topics: Array<{ + name: string + types: string[] + publishers: Array<{ node: string; type: string }> + subscribers: Array<{ node: string; type: string }> + }> + message?: string + }> + } visualization: { status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> diff --git a/src/renderer/src/features/workspace/WorkspaceActivityHeader.tsx b/src/renderer/src/features/workspace/WorkspaceActivityHeader.tsx index 1436a36..b8e907a 100644 --- a/src/renderer/src/features/workspace/WorkspaceActivityHeader.tsx +++ b/src/renderer/src/features/workspace/WorkspaceActivityHeader.tsx @@ -3,7 +3,7 @@ import type React from 'react' type WorkspaceActivityHeaderProps = { dark: boolean textStrong: string - mode: 'settings' | 'examples' | 'git' | 'empty' | 'module' + mode: 'settings' | 'examples' | 'git' | 'ros-connections' | 'empty' | 'module' gitPanelTab: 'source' | 'history' gitSubmissionCount: number gitChangedFileCount: number @@ -18,6 +18,7 @@ function getHeaderCopy( ): { title: string; subtitle: string } | null { if (mode === 'settings') return { title: '개발스튜디오 설정', subtitle: '워크스페이스 경로와 템플릿 저장소 관리' } if (mode === 'examples') return { title: '예제 코드', subtitle: '파일 조회' } + if (mode === 'ros-connections') return { title: 'ROS 연결상태', subtitle: '실행 모듈의 토픽 발행·구독 연결' } if (mode === 'git') { return { title: '소스 제어', diff --git a/src/renderer/src/features/workspace/WorkspaceNav.tsx b/src/renderer/src/features/workspace/WorkspaceNav.tsx index 2ae33e3..c05cae2 100644 --- a/src/renderer/src/features/workspace/WorkspaceNav.tsx +++ b/src/renderer/src/features/workspace/WorkspaceNav.tsx @@ -1,7 +1,7 @@ -import { Code2, FileCode2, GitBranch, Network, RefreshCw, Search, Settings, Terminal } from 'lucide-react' +import { Code2, FileCode2, GitBranch, Network, Radio, RefreshCw, Search, Settings, Terminal } from 'lucide-react' import type React from 'react' -export type WorkspaceActivity = 'modules' | 'workspace' | 'search' | 'logs' | 'examples' | 'git' | 'settings' +export type WorkspaceActivity = 'modules' | 'ros-connections' | 'workspace' | 'search' | 'logs' | 'examples' | 'git' | 'settings' type WorkspaceNavProps = { dark: boolean @@ -22,6 +22,7 @@ const WORKSPACE_NAV_ITEMS: Array<{ label: string }> = [ { id: 'modules', Icon: Network, label: '모듈 리스트' }, + { id: 'ros-connections', Icon: Radio, label: 'ROS 연결상태' }, { id: 'workspace', Icon: Code2, label: '코드 편집' }, { id: 'search', Icon: Search, label: '검색' }, { id: 'logs', Icon: Terminal, label: '로그' }, @@ -56,6 +57,7 @@ export default function WorkspaceNav({ {WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => { const active = activeActivity === id const selectable = id === 'modules' || + id === 'ros-connections' || id === 'workspace' || id === 'examples' || id === 'settings' || diff --git a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx index 1eb473e..22c3d26 100644 --- a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx @@ -180,10 +180,11 @@ export default function WorkspaceQaPanel({ }} className={[ 'relative flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold transition', + disabled ? 'cursor-not-allowed' : 'cursor-pointer', disabled ? dark - ? 'cursor-not-allowed bg-[#202020] text-white/25' - : 'cursor-not-allowed bg-[#f3f5f8] text-[#aab3c1]' + ? 'bg-[#202020] text-white/25' + : 'bg-[#f3f5f8] text-[#aab3c1]' : teacherTabFlash ? dark ? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]' diff --git a/src/renderer/src/features/workspace/WorkspaceRosConnectionsView.tsx b/src/renderer/src/features/workspace/WorkspaceRosConnectionsView.tsx new file mode 100644 index 0000000..082f7e5 --- /dev/null +++ b/src/renderer/src/features/workspace/WorkspaceRosConnectionsView.tsx @@ -0,0 +1,580 @@ +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 + loadRosGraph: () => Promise +} + +const TOPIC_TYPES: Record = { + '/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 { + 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 + 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 ( +
+
+
+ + + +
+ {title} +
+
+
+ + +
+
+
+ {modules.length ? modules.map(({ module, publishers, subscribers }) => { + const moduleKey = module.folderName ?? module.name + const selected = selectedModuleKeys[moduleKey] !== false + return ( + + ) + }) : ( +
+ 실행 중인 모듈이 없습니다. +
+ )} +
+
+ ) +} + +function TopicDetailDrawer({ + dark, + rosDomainId, + connection, + onClose, + onResizeStart, +}: { + dark: boolean + rosDomainId: string + connection: RosConnection + onClose: () => void + onResizeStart: (event: React.MouseEvent) => void +}): React.JSX.Element { + return ( +
+
+ +
+
+
+ +
+ 토픽 상세 +
+
+ +
+
+
+
+
+
+
토픽
{connection.topic.name}
+
메시지 타입
{connection.topic.type}
+
상태
{connection.active ? '발행·구독 연결됨' : '연결 대기'}
+
발행자
{connection.publishers.length}
+
구독자
{connection.subscribers.length}
+
QoS
기본값
+
Domain
{rosDomainId}
+
+
+
+
+

발행 노드

+
+ {connection.publishers.length ? connection.publishers.map((endpoint) => ( +
+ {endpoint.node} + {endpoint.type || connection.topic.type} +
+ )) :

발행 노드 없음

} +
+
+
+

구독 노드

+
+ {connection.subscribers.length ? connection.subscribers.map((endpoint) => ( +
+ {endpoint.node} + {endpoint.type || connection.topic.type} +
+ )) :

구독 노드 없음

} +
+
+
+
+
+ ) +} + +export default function WorkspaceRosConnectionsView({ + dark, + modules, + remoteWorkspace, + platformName, + rosDomainId, + onRefreshModules, + loadRosGraph, +}: WorkspaceRosConnectionsViewProps): React.JSX.Element { + const runningRosModules = useMemo(() => modules + .filter((module) => module.state === 'running') + .map((module) => ({ module, runtime: resolveRuntime(module, remoteWorkspace), ...moduleTopics(module) })), [modules, remoteWorkspace]) + const [selectedModuleKeys, setSelectedModuleKeys] = useState>({}) + const [rosGraph, setRosGraph] = useState({ topics: [] }) + const [rosGraphLoading, setRosGraphLoading] = useState(false) + + useEffect(() => { + const runningKeys = new Set(runningRosModules.map(({ module }) => module.folderName ?? module.name)) + setSelectedModuleKeys((current) => { + const next: Record = {} + 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(() => { + const selectedTopics = new Map() + 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(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): 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 => { + 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 ( +
+
+
+ + 실행 모듈 {runningModules} + + + 토픽 {connections.length} + + + 활성 연결 {activeConnections} + + {rosGraph.message && ( + + {rosGraph.message} + + )} + + ROS Domain {rosDomainId} + +
+ +
+ +
+
+
+ setRuntimeModuleSelection(primaryRuntime, true)} + onClearSelection={() => setRuntimeModuleSelection(primaryRuntime, false)} + /> + +
+
+
+ 토픽 연결 + 발행 노드에서 구독 노드로 전달 +
+ {activeConnections}/{connections.length} 활성 +
+
+ {connections.length ? connections.map((connection) => { + const selected = topicDetailOpen && selectedConnection?.topic.name === connection.topic.name + return ( + + ) + }) : ( +
+ 실행 모듈에서 확인된 토픽이 없습니다. +
+ )} +
+
+ + setRuntimeModuleSelection(primaryRuntime === 'remote' ? 'local' : 'remote', true)} + onClearSelection={() => setRuntimeModuleSelection(primaryRuntime === 'remote' ? 'local' : 'remote', false)} + /> +
+
+ + {topicDetailOpen && selectedConnection && ( + setTopicDetailOpen(false)} + onResizeStart={startTopicDetailResize} + /> + )} +
+
+ ) +} diff --git a/src/renderer/src/features/workspace/WorldMapSelectorModal.tsx b/src/renderer/src/features/workspace/WorldMapSelectorModal.tsx index c119fbc..ccf8045 100644 --- a/src/renderer/src/features/workspace/WorldMapSelectorModal.tsx +++ b/src/renderer/src/features/workspace/WorldMapSelectorModal.tsx @@ -32,7 +32,7 @@ export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [ key: 'nav2-turtlebot3-world', platformKey: 'NAV2', name: '기본 월드', - description: 'TurtleBot3 기본 장애물 환경', + description: '기본 장애물 환경', worldFile: 'turtlebot3_world.world', viewerModel: 'turtlebot3_world' }, @@ -40,7 +40,7 @@ export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [ key: 'nav2-turtlebot3-house', platformKey: 'NAV2', name: '실내 환경', - description: '방과 복도로 구성된 TurtleBot3 House', + description: '방과 복도로 구성된 실내 환경', worldFile: 'turtlebot3_house.world', viewerModel: 'turtlebot3_house' },