feat(lms): add ROS connection status workspace view

This commit is contained in:
2026-08-13 17:17:47 +09:00
parent 56db72a054
commit 99f0451412
12 changed files with 836 additions and 41 deletions
+153 -6
View File
@@ -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<RosGraphSnapshot> : {}
const topics = Array.isArray(snapshot.topics) ? snapshot.topics.flatMap((topic): RosGraphTopic[] => {
if (!topic || typeof topic !== 'object') return []
const candidate = topic as Partial<RosGraphTopic>
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<RosGraphEndpoint>
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<RosGraphSnapshot> {
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<RosGraphSnapshot> {
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<string | null> {
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<string | null> {
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)
))