feat(lms): add ROS connection status workspace view
This commit is contained in:
+153
-6
@@ -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)
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user