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)
|
||||
))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+33
-10
@@ -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)'
|
||||
}}
|
||||
>
|
||||
<WorkspaceGuidePanel
|
||||
@@ -3447,7 +3456,7 @@ function LocalPcWorkspaceMockup({
|
||||
<WorkspaceActivityHeader
|
||||
dark={dark}
|
||||
textStrong={textStrong}
|
||||
mode={settingsActive ? 'settings' : exampleActive ? 'examples' : gitActive ? 'git' : !selectedModule ? 'empty' : 'module'}
|
||||
mode={rosConnectionsActive ? 'ros-connections' : settingsActive ? 'settings' : exampleActive ? 'examples' : gitActive ? 'git' : !selectedModule ? 'empty' : 'module'}
|
||||
gitPanelTab={gitPanelTab}
|
||||
gitSubmissionCount={sourceControlSubmissionGroups.length}
|
||||
gitChangedFileCount={gitChangedFileCount}
|
||||
@@ -3466,7 +3475,7 @@ function LocalPcWorkspaceMockup({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceActive && !selectedModule && !exampleActive && !settingsActive && !gitActive ? (
|
||||
{workspaceActive && !selectedModule && !exampleActive && !settingsActive && !gitActive && !rosConnectionsActive ? (
|
||||
<WorkspaceEmptyModuleState
|
||||
dark={dark}
|
||||
textStrong={textStrong}
|
||||
@@ -3475,7 +3484,7 @@ function LocalPcWorkspaceMockup({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!logActive && !settingsActive && !gitActive && (
|
||||
{!logActive && !settingsActive && !gitActive && !rosConnectionsActive && (
|
||||
<WorkspaceFilePanel
|
||||
dark={dark}
|
||||
workspaceActive={workspaceActive}
|
||||
@@ -3550,7 +3559,7 @@ function LocalPcWorkspaceMockup({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!logActive && !settingsActive && !gitActive && (
|
||||
{!logActive && !settingsActive && !gitActive && !rosConnectionsActive && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="프로젝트 트리 크기 조절"
|
||||
@@ -3567,7 +3576,21 @@ function LocalPcWorkspaceMockup({
|
||||
)}
|
||||
|
||||
{workspaceActive ? (
|
||||
settingsActive ? (
|
||||
rosConnectionsActive ? (
|
||||
<WorkspaceRosConnectionsView
|
||||
dark={dark}
|
||||
modules={availableModules}
|
||||
remoteWorkspace={remoteWorkspace}
|
||||
platformName={platformName}
|
||||
rosDomainId={effectiveRosDomainId}
|
||||
onRefreshModules={() => loadAvailableModules({ silent: true })}
|
||||
loadRosGraph={() => (
|
||||
remoteWorkspace
|
||||
? window.remoteWorkspace.rosGraph.snapshot(getRemoteWorkspaceTarget())
|
||||
: window.localWorkspace.rosGraph.snapshot()
|
||||
)}
|
||||
/>
|
||||
) : settingsActive ? (
|
||||
<WorkspaceSettingsView
|
||||
dark={dark}
|
||||
textStrong={textStrong}
|
||||
|
||||
Vendored
+22
@@ -251,6 +251,17 @@ interface Window {
|
||||
localWorkspace: {
|
||||
moduleFiles: Window['moduleFiles']
|
||||
modules: Window['modules']
|
||||
rosGraph: {
|
||||
snapshot: () => 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 }>
|
||||
|
||||
@@ -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: '소스 제어',
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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)]'
|
||||
|
||||
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user