(fix) bug remote workspace ssh..

This commit is contained in:
2026-09-03 15:39:19 +09:00
parent b051ac75cf
commit cb1106f319
11 changed files with 271 additions and 179 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.1.18", "version": "1.1.19",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+65 -10
View File
@@ -22,6 +22,8 @@ const terminalStopTimers = new Map<string, NodeJS.Timeout>()
const intentionalTerminalStops = new Set<string>() const intentionalTerminalStops = new Set<string>()
const nav2RuntimeObstacleNames = new Set<string>() const nav2RuntimeObstacleNames = new Set<string>()
const remoteSshQueues = new Map<string, Promise<void>>() const remoteSshQueues = new Map<string, Promise<void>>()
const remoteGitModuleRoots = new Map<string, string>()
const remoteSshControlDirectory = resolve(homedir(), '.autonomy-studio', 'ssh', 'control')
const moduleCacheRoot = resolve(homedir(), '.caches') const moduleCacheRoot = resolve(homedir(), '.caches')
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts') const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime') const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
@@ -367,6 +369,19 @@ function normalizeWorldLayout(value: unknown): WorldLayout {
return { version: 1, bounds: { width: size(bounds.width, 10), depth: size(bounds.depth, 10) }, objects } return { version: 1, bounds: { width: size(bounds.width, 10), depth: size(bounds.depth, 10) }, objects }
} }
function isSavedMapCollisionObject(object: WorldLayoutObject): boolean {
return /^saved-(?:map-obstacle|3d-map-voxel)-/i.test(object.id)
}
// Saved-map collision layouts used to be copied into world-layout.json. Keep
// that file exclusively for the custom map and discard only those old generated
// objects while preserving any user-created objects that may coexist there.
function normalizeCustomWorldLayout(value: unknown): WorldLayout {
const layout = normalizeWorldLayout(value)
const objects = layout.objects.filter((object) => !isSavedMapCollisionObject(object))
return objects.length === layout.objects.length ? layout : { ...layout, objects }
}
function worldLayoutPath(workspaceRoot?: string | null): string { function worldLayoutPath(workspaceRoot?: string | null): string {
const root = expandUserPath(workspaceRoot) const root = expandUserPath(workspaceRoot)
if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
@@ -527,7 +542,6 @@ async function generateWorkspaceMapCollisionLayout(workspaceRoot: string | null
const layout = createMapCollisionLayout(await readFile(yamlFilePath, 'utf8'), await readFile(pgmFilePath)) const layout = createMapCollisionLayout(await readFile(yamlFilePath, 'utf8'), await readFile(pgmFilePath))
await mkdir(mapsDirectory, { recursive: true }) await mkdir(mapsDirectory, { recursive: true })
await writeJsonAtomically(resolve(mapsDirectory, savedMapCollisionLayoutFileName(yamlFileName)), layout) await writeJsonAtomically(resolve(mapsDirectory, savedMapCollisionLayoutFileName(yamlFileName)), layout)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout return layout
} }
@@ -681,7 +695,6 @@ async function generateWorkspacePointCloudCollisionLayout(
&& cached.source.modifiedAtMs === sourceStat.mtimeMs && cached.source.modifiedAtMs === sourceStat.mtimeMs
&& cached.layout) { && cached.layout) {
const layout = normalizeWorldLayout(cached.layout) const layout = normalizeWorldLayout(cached.layout)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout return layout
} }
} catch { } catch {
@@ -697,7 +710,6 @@ async function generateWorkspacePointCloudCollisionLayout(
source: { fileName: databaseFileName, size: sourceStat.size, modifiedAtMs: sourceStat.mtimeMs }, source: { fileName: databaseFileName, size: sourceStat.size, modifiedAtMs: sourceStat.mtimeMs },
layout, layout,
} satisfies PointCloudLayoutCache) } satisfies PointCloudLayoutCache)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout return layout
} }
@@ -1365,7 +1377,7 @@ async function collectRemoteAvailableModules(
} }
}) })
.sort((a, b) => a.sortOrder - b.sortOrder) .sort((a, b) => a.sortOrder - b.sortOrder)
const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 15000) const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 30_000)
const dockerStatuses = new Map<string, { running: boolean; exists: boolean; status: string }>() const dockerStatuses = new Map<string, { running: boolean; exists: boolean; status: string }>()
const dockerStats = new Map<string, { cpu: number; memory: number }>() const dockerStats = new Map<string, { cpu: number; memory: number }>()
const installedModules = new Map<string, { path: string; parameters: string[] }>() const installedModules = new Map<string, { path: string; parameters: string[] }>()
@@ -1843,6 +1855,7 @@ async function queueRemoteSsh<T>(target: ReturnType<typeof normalizeRemoteTarget
async function runSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs = 120_000): Promise<string> { async function runSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs = 120_000): Promise<string> {
const normalized = normalizeRemoteTarget(target) const normalized = normalizeRemoteTarget(target)
await mkdir(remoteSshControlDirectory, { recursive: true })
return queueRemoteSsh(normalized, () => runCommandOutput( return queueRemoteSsh(normalized, () => runCommandOutput(
'ssh', 'ssh',
[ [
@@ -1854,6 +1867,18 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
'StrictHostKeyChecking=accept-new', 'StrictHostKeyChecking=accept-new',
'-o', '-o',
'ConnectTimeout=5', 'ConnectTimeout=5',
'-o',
'PreferredAuthentications=publickey',
'-o',
'PasswordAuthentication=no',
'-o',
'KbdInteractiveAuthentication=no',
'-o',
'ControlMaster=auto',
'-o',
'ControlPersist=120',
'-o',
`ControlPath=${join(remoteSshControlDirectory, 'cm-%C')}`,
'-i', '-i',
normalized.identityFile, normalized.identityFile,
`${normalized.user}@${normalized.host}`, `${normalized.user}@${normalized.host}`,
@@ -2123,10 +2148,10 @@ function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModu
'set +e', 'set +e',
`ROOT=${quoteShellSingle(workspaceRoot)}`, `ROOT=${quoteShellSingle(workspaceRoot)}`,
...expandRemotePathVar('ROOT'), ...expandRemotePathVar('ROOT'),
'timeout 12s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true', 'timeout 5s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true',
// docker stats can stall on a remote Jetson. Module state must not wait // docker stats can stall on a remote Jetson. Module state must not wait
// behind a long statistics sample. // behind a long statistics sample.
'timeout 3s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true', 'timeout 2s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
...moduleFolders.map((folderName) => { ...moduleFolders.map((folderName) => {
const modulePath = remotePathJoin('$ROOT', folderName) const modulePath = remotePathJoin('$ROOT', folderName)
return [ return [
@@ -2344,6 +2369,9 @@ async function resolveRemoteGitModuleRoot(
): Promise<{ target: ReturnType<typeof normalizeRemoteTarget>; moduleRoot: string }> { ): Promise<{ target: ReturnType<typeof normalizeRemoteTarget>; moduleRoot: string }> {
const target = normalizeRemoteTarget(targetInput) const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim() || moduleName const targetFolderName = folderName?.trim() || moduleName
const cacheKey = `${target.user}@${target.host}:${target.port}:${target.workspaceRoot}:${targetFolderName}`
const cachedModuleRoot = remoteGitModuleRoots.get(cacheKey)
if (cachedModuleRoot) return { target, moduleRoot: cachedModuleRoot }
const output = await runSshOutput(target, [ const output = await runSshOutput(target, [
'set -e', 'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`, `ROOT=${quoteShellSingle(target.workspaceRoot)}`,
@@ -2356,6 +2384,7 @@ async function resolveRemoteGitModuleRoot(
].join('\n'), 8000) ].join('\n'), 8000)
const moduleRoot = output.trim() const moduleRoot = output.trim()
if (!moduleRoot) throw new Error('원격 Git 저장소를 찾을 수 없습니다.') if (!moduleRoot) throw new Error('원격 Git 저장소를 찾을 수 없습니다.')
remoteGitModuleRoots.set(cacheKey, moduleRoot)
return { target, moduleRoot } return { target, moduleRoot }
} }
@@ -3347,6 +3376,7 @@ async function startVisualizationBridge(
environmentProfile?: EnvironmentProfileConfig | null, environmentProfile?: EnvironmentProfileConfig | null,
rosNetwork?: RosNetworkConfig | null, rosNetwork?: RosNetworkConfig | null,
savedMapDatabasePath?: string | null, savedMapDatabasePath?: string | null,
savedMapWorldLayoutPath?: string | null,
): Promise<VisualizationBridgeStatus> { ): Promise<VisualizationBridgeStatus> {
const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase()) const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase())
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack) const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
@@ -3358,6 +3388,25 @@ async function startVisualizationBridge(
const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2' const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2'
const layoutWorkspaceRoot = usesSlamBridge ? expandUserPath(workspaceRoot) : null const layoutWorkspaceRoot = usesSlamBridge ? expandUserPath(workspaceRoot) : null
if (layoutWorkspaceRoot) await mkdir(resolve(layoutWorkspaceRoot, 'maps'), { recursive: true }) if (layoutWorkspaceRoot) await mkdir(resolve(layoutWorkspaceRoot, 'maps'), { recursive: true })
const savedMapWorldLayoutFileName = savedMapWorldLayoutPath
? safeSavedMapFileName(basename(savedMapWorldLayoutPath))
: null
const requestedWorldLayoutPath = savedMapWorldLayoutFileName
? `/workspace/maps/${savedMapWorldLayoutFileName}`
: '/workspace/world-layout.json'
if (layoutWorkspaceRoot && !savedMapWorldLayoutFileName) {
const customLayoutPath = worldLayoutPath(layoutWorkspaceRoot)
try {
const currentLayout = await readFile(customLayoutPath, 'utf8')
const parsedLayout = JSON.parse(currentLayout)
const normalizedLayout = normalizeCustomWorldLayout(parsedLayout)
if (JSON.stringify(normalizedLayout) !== JSON.stringify(parsedLayout)) {
await writeJsonAtomically(customLayoutPath, normalizedLayout)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
const requestedRosDomainId = String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0' const requestedRosDomainId = String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'
const requestedRosNetworkKey = rosNetworkEnabled(rosNetwork) const requestedRosNetworkKey = rosNetworkEnabled(rosNetwork)
? `peer:${rosNetwork?.peer?.host?.trim() ?? ''}` ? `peer:${rosNetwork?.peer?.host?.trim() ?? ''}`
@@ -3384,10 +3433,16 @@ async function startVisualizationBridge(
const runningWorkspaceRoot = layoutWorkspaceRoot const runningWorkspaceRoot = layoutWorkspaceRoot
? await readContainerMountSource(containerName, '/workspace') ? await readContainerMountSource(containerName, '/workspace')
: null : null
const runningWorldLayoutPath = layoutWorkspaceRoot
? await readContainerEnvironmentValue(containerName, 'WORLD_LAYOUT_PATH')
: null
if (response?.ok if (response?.ok
&& (!runningRosDomainId || runningRosDomainId === requestedRosDomainId) && (!runningRosDomainId || runningRosDomainId === requestedRosDomainId)
&& runningRosNetworkKey === requestedRosNetworkKey && runningRosNetworkKey === requestedRosNetworkKey
&& (!layoutWorkspaceRoot || runningWorkspaceRoot === resolve(layoutWorkspaceRoot))) { && (!layoutWorkspaceRoot || (
runningWorkspaceRoot === resolve(layoutWorkspaceRoot)
&& runningWorldLayoutPath === requestedWorldLayoutPath
))) {
return readVisualizationBridgeStatus() return readVisualizationBridgeStatus()
} }
await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined)
@@ -3448,7 +3503,7 @@ async function startVisualizationBridge(
if (layoutWorkspaceRoot) { if (layoutWorkspaceRoot) {
args.push( args.push(
'-v', `${layoutWorkspaceRoot}:/workspace`, '-v', `${layoutWorkspaceRoot}:/workspace`,
'-e', 'WORLD_LAYOUT_PATH=/workspace/world-layout.json', '-e', `WORLD_LAYOUT_PATH=${requestedWorldLayoutPath}`,
'-e', 'SAVED_MAPS_PATH=/workspace/maps', '-e', 'SAVED_MAPS_PATH=/workspace/maps',
'-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db', '-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
) )
@@ -5804,7 +5859,7 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('local-workspace:world-layout:read', async (_event, workspaceRoot?: string | null) => { ipcMain.handle('local-workspace:world-layout:read', async (_event, workspaceRoot?: string | null) => {
const targetPath = worldLayoutPath(workspaceRoot) const targetPath = worldLayoutPath(workspaceRoot)
try { try {
return normalizeWorldLayout(JSON.parse(await readFile(targetPath, 'utf8'))) return normalizeCustomWorldLayout(JSON.parse(await readFile(targetPath, 'utf8')))
} catch (error) { } catch (error) {
const code = (error as NodeJS.ErrnoException).code const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return defaultWorldLayout() if (code === 'ENOENT') return defaultWorldLayout()
@@ -5902,7 +5957,7 @@ function registerLocalWorkspaceHandlers(): void {
return { ok: true } return { ok: true }
}) })
ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus()) ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus())
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath)) ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath))
ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile)) ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile))
ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath)) ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath))
ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl)) ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))
+1 -1
View File
@@ -226,7 +226,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
}, },
visualization: { visualization: {
status: () => ipcRenderer.invoke('local-workspace:visualization:status'), status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath), start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath),
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile) stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
}, },
git: { git: {
+10 -2
View File
@@ -582,7 +582,15 @@ controls.mouseButtons = {
MIDDLE: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.ROTATE,
RIGHT: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.DOLLY,
}; };
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault()); renderer.domElement.addEventListener('contextmenu', (event) => {
event.preventDefault();
if (!obstaclePlacementMode) return;
obstaclePlacementMode = false;
obstacleToggleButton.classList.remove('active');
obstacleToggleButton.setAttribute('aria-pressed', 'false');
hideObstaclePreview();
focusViewer();
});
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.08; controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0); controls.target.set(0, 0, 0);
@@ -2706,7 +2714,7 @@ function createObstaclePreviewTemplate() {
yaw: (Math.floor(Math.random() * 4) * Math.PI) / 2, yaw: (Math.floor(Math.random() * 4) * Math.PI) / 2,
length: 0.7 + Math.random() * 0.9, length: 0.7 + Math.random() * 0.9,
depth: 0.16, depth: 0.16,
height: 0.52, height: 1.0,
}; };
} }
+143 -131
View File
@@ -29,6 +29,7 @@ import RecentSessionModal from './features/platforms/RecentSessionModal'
import type { RecentSessionItem } from './features/platforms/RecentSessionModal' import type { RecentSessionItem } from './features/platforms/RecentSessionModal'
import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl' import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl' import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl'
import type { PlatformConnectResponse } from './features/platforms/platformControl'
import type { EnvironmentProfile, PlatformControlActionResponse, PlatformOption, StudioSourceSettings } from './features/platforms/types' import type { EnvironmentProfile, PlatformControlActionResponse, PlatformOption, StudioSourceSettings } from './features/platforms/types'
import WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader' import WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader'
import WorkspaceEmptyModuleState from './features/workspace/WorkspaceEmptyModuleState' import WorkspaceEmptyModuleState from './features/workspace/WorkspaceEmptyModuleState'
@@ -44,7 +45,7 @@ import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleT
import ModuleListView from './features/workspace/ModuleListView' import ModuleListView from './features/workspace/ModuleListView'
import WorkspaceRosConnectionsView from './features/workspace/WorkspaceRosConnectionsView' import WorkspaceRosConnectionsView from './features/workspace/WorkspaceRosConnectionsView'
import WorldEditorModal from './features/workspace/WorldEditorModal' import WorldEditorModal from './features/workspace/WorldEditorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption, type WorkspaceMapScope } from './features/workspace/WorldMapSelectorModal' import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel' import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView' import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView' import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
@@ -803,16 +804,13 @@ function LocalPcWorkspaceMockup({
const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected` const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected`
const simulationMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:simulation` const simulationMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:simulation`
const platformMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:platform` const platformMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:platform`
const [selectedSimulationMapKey, setSelectedSimulationMapKey] = useState(() => ( const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => {
window.localStorage.getItem(simulationMapStorageKey) const stored = window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(sessionMapStorageKey) ?? window.localStorage.getItem(simulationMapStorageKey)
?? 'nav2-turtlebot3-world' ?? window.localStorage.getItem(platformMapStorageKey)
)) if (stored === 'nav2-platform-slam') return 'nav2-platform-slam-2d'
const [selectedPlatformMapKey, setSelectedPlatformMapKey] = useState(() => ( return stored?.replace(/:simulation$/, '') ?? 'nav2-turtlebot3-world'
window.localStorage.getItem(platformMapStorageKey) })
?? window.localStorage.getItem(sessionMapStorageKey)
?? 'nav2-platform-slam-2d'
))
const [savedSlamMaps, setSavedSlamMaps] = useState<WorkspaceMapOption[]>([]) const [savedSlamMaps, setSavedSlamMaps] = useState<WorkspaceMapOption[]>([])
const [savedSlamMapsLoaded, setSavedSlamMapsLoaded] = useState(false) const [savedSlamMapsLoaded, setSavedSlamMapsLoaded] = useState(false)
const [worldLayoutVersion, setWorldLayoutVersion] = useState(0) const [worldLayoutVersion, setWorldLayoutVersion] = useState(0)
@@ -861,6 +859,7 @@ function LocalPcWorkspaceMockup({
.map((map) => ({ .map((map) => ({
...map, ...map,
key: `${map.key}:simulation`, key: `${map.key}:simulation`,
selectionKey: map.key,
description: '저장된 SLAM 결과 맵', description: '저장된 SLAM 결과 맵',
worldFile: 'empty_world.world', worldFile: 'empty_world.world',
scope: 'simulation' as const, scope: 'simulation' as const,
@@ -874,6 +873,9 @@ function LocalPcWorkspaceMockup({
return true return true
}) })
}, [curriculumWeek.maps, savedSlamMaps]) }, [curriculumWeek.maps, savedSlamMaps])
const selectableWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => !map.selectionKey)
), [sessionWorldMaps])
const simulationWorldMaps = useMemo(() => ( const simulationWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => map.scope === 'simulation') sessionWorldMaps.filter((map) => map.scope === 'simulation')
), [sessionWorldMaps]) ), [sessionWorldMaps])
@@ -881,41 +883,41 @@ function LocalPcWorkspaceMockup({
sessionWorldMaps.filter((map) => map.scope === 'platform') sessionWorldMaps.filter((map) => map.scope === 'platform')
), [sessionWorldMaps]) ), [sessionWorldMaps])
useEffect(() => { useEffect(() => {
const legacyMapKey = window.localStorage.getItem(sessionMapStorageKey) const normalizeMapKey = (key: string | null): string | null => {
const normalizeMapKey = (key: string | null, scope: WorkspaceMapScope): string | null => { if (key === 'nav2-platform-slam') return 'nav2-platform-slam-2d'
if (key === 'nav2-platform-slam') return scope === 'platform' ? 'nav2-platform-slam-2d' : null return key?.replace(/:simulation$/, '') ?? null
return key
} }
const resolveSelection = ( const stored = normalizeMapKey(
scope: WorkspaceMapScope, window.localStorage.getItem(sessionMapStorageKey)
current: string, ?? window.localStorage.getItem(simulationMapStorageKey)
storageKey: string, ?? window.localStorage.getItem(platformMapStorageKey),
maps: WorkspaceMapOption[], )
fallback: string, setSelectedWorldMapKey((current) => {
): string => { const requested = normalizeMapKey(current) ?? stored
const stored = normalizeMapKey(window.localStorage.getItem(storageKey) ?? legacyMapKey, scope)
const requested = normalizeMapKey(current, scope) ?? stored
if (!savedSlamMapsLoaded && requested?.startsWith('saved-slam-')) return current if (!savedSlamMapsLoaded && requested?.startsWith('saved-slam-')) return current
const next = maps.some((map) => map.key === requested) const next = selectableWorldMaps.some((map) => map.key === requested)
? requested! ? requested!
: maps.some((map) => map.key === stored) : selectableWorldMaps.some((map) => map.key === stored)
? stored! ? stored!
: maps.find((map) => map.key === fallback)?.key ?? maps[0]?.key ?? fallback : selectableWorldMaps.find((map) => map.key === 'nav2-turtlebot3-world')?.key
window.localStorage.setItem(storageKey, next) ?? selectableWorldMaps[0]?.key
?? 'nav2-turtlebot3-world'
window.localStorage.setItem(sessionMapStorageKey, next)
return next return next
} })
setSelectedSimulationMapKey((current) => resolveSelection( }, [platformMapStorageKey, savedSlamMapsLoaded, selectableWorldMaps, sessionMapStorageKey, simulationMapStorageKey])
'simulation', current, simulationMapStorageKey, simulationWorldMaps, 'nav2-turtlebot3-world', const selectedWorldMap = useMemo(() => {
)) const localSimulationMapKey = `${selectedWorldMapKey}:simulation`
setSelectedPlatformMapKey((current) => resolveSelection( return (!remoteWorkspace
'platform', current, platformMapStorageKey, platformWorldMaps, 'nav2-platform-slam-2d', ? sessionWorldMaps.find((map) => map.key === localSimulationMapKey)
)) : undefined)
}, [platformMapStorageKey, platformWorldMaps, savedSlamMapsLoaded, sessionMapStorageKey, simulationMapStorageKey, simulationWorldMaps]) ?? sessionWorldMaps.find((map) => map.key === selectedWorldMapKey)
const selectedWorldMapKey = remoteWorkspace ? selectedPlatformMapKey : selectedSimulationMapKey ?? selectableWorldMaps.find((map) => map.key === 'nav2-turtlebot3-world')
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? selectableWorldMaps[0]
?? (remoteWorkspace ? platformWorldMaps[0] : simulationWorldMaps[0]) ?? NAV2_MAP_OPTIONS[0]
}, [remoteWorkspace, selectableWorldMaps, selectedWorldMapKey, sessionWorldMaps])
const prepareVisualizationSavedMapRef = useRef<( const prepareVisualizationSavedMapRef = useRef<(
() => Promise<{ savedMapDatabasePath?: string }> () => Promise<{ savedMapDatabasePath?: string; savedMapWorldLayoutPath?: string }>
) | null>(null) ) | null>(null)
const savedMapRunsInSimulation = !remoteWorkspace const savedMapRunsInSimulation = !remoteWorkspace
&& selectedWorldMap?.saved === true && selectedWorldMap?.saved === true
@@ -2491,7 +2493,7 @@ function LocalPcWorkspaceMockup({
saveDirtyFilesForModule, saveDirtyFilesForModule,
onRegisterStopRunningModules onRegisterStopRunningModules
}) })
const prepareSavedMap = useCallback(async (): Promise<{ savedMapYamlPath?: string; savedMapDatabasePath?: string }> => { const prepareSavedMap = useCallback(async (): Promise<{ savedMapYamlPath?: string; savedMapDatabasePath?: string; savedMapWorldLayoutPath?: string }> => {
if (!selectedWorldMap.saved) return {} if (!selectedWorldMap.saved) return {}
if (!selectedWorldMap.yamlPath || !selectedWorldMap.pgmPath || !selectedWorldMap.yamlUrl || !selectedWorldMap.pgmUrl) { if (!selectedWorldMap.yamlPath || !selectedWorldMap.pgmPath || !selectedWorldMap.yamlUrl || !selectedWorldMap.pgmUrl) {
throw new Error('이전 형식으로 저장된 맵입니다. SLAM을 다시 저장해 공용 맵으로 변환하세요.') throw new Error('이전 형식으로 저장된 맵입니다. SLAM을 다시 저장해 공용 맵으로 변환하세요.')
@@ -2511,6 +2513,7 @@ function LocalPcWorkspaceMockup({
] ]
let databasePath: string | undefined let databasePath: string | undefined
let databaseFileName: string | undefined let databaseFileName: string | undefined
let savedMapWorldLayoutPath: string | undefined
if (selectedWorldMap.databasePath || selectedWorldMap.databaseUrl) { if (selectedWorldMap.databasePath || selectedWorldMap.databaseUrl) {
databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : '' databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : ''
if (!databaseFileName.endsWith('.db') || !selectedWorldMap.databaseUrl) { if (!databaseFileName.endsWith('.db') || !selectedWorldMap.databaseUrl) {
@@ -2534,13 +2537,16 @@ function LocalPcWorkspaceMockup({
if (selectedWorldMap.runtimeKind === 'PLATFORM_3D') { if (selectedWorldMap.runtimeKind === 'PLATFORM_3D') {
if (!databaseFileName) throw new Error('시뮬레이션할 3D SLAM 데이터베이스가 없습니다.') if (!databaseFileName) throw new Error('시뮬레이션할 3D SLAM 데이터베이스가 없습니다.')
await window.localWorkspace.maps.generatePointCloudCollisionLayout(clonePathPreview, databaseFileName) await window.localWorkspace.maps.generatePointCloudCollisionLayout(clonePathPreview, databaseFileName)
savedMapWorldLayoutPath = `/workspace/maps/${databaseFileName.replace(/\.db$/i, '.gazebo-3d-layout.json')}`
} else { } else {
await window.localWorkspace.maps.generateCollisionLayout(clonePathPreview, yamlFileName, pgmFileName) await window.localWorkspace.maps.generateCollisionLayout(clonePathPreview, yamlFileName, pgmFileName)
savedMapWorldLayoutPath = `/workspace/maps/${yamlFileName.replace(/\.yaml$/i, '.gazebo-layout.json')}`
} }
} }
return { return {
savedMapYamlPath: `/workspace/maps/${yamlFileName}`, savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
savedMapDatabasePath: databasePath, savedMapDatabasePath: databasePath,
savedMapWorldLayoutPath,
} }
}, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation]) }, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
prepareVisualizationSavedMapRef.current = prepareSavedMap prepareVisualizationSavedMapRef.current = prepareSavedMap
@@ -2614,23 +2620,13 @@ function LocalPcWorkspaceMockup({
return () => onRegisterRunningModuleNames?.(null) return () => onRegisterRunningModuleNames?.(null)
}, [availableModules, onRegisterRunningModuleNames, onRunningModuleCountChange]) }, [availableModules, onRegisterRunningModuleNames, onRunningModuleCountChange])
const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => { const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => {
const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform') const mapKey = map.selectionKey ?? map.key
const storageKey = scope === 'platform' ? platformMapStorageKey : simulationMapStorageKey window.localStorage.setItem(sessionMapStorageKey, mapKey)
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace setSelectedWorldMapKey(mapKey)
window.localStorage.setItem(storageKey, map.key) if (map.custom && map.layout && !remoteWorkspace) {
if (scope === 'platform') {
setSelectedPlatformMapKey(map.key)
} else {
setSelectedSimulationMapKey(map.key)
}
if (map.custom && map.layout && scope === 'simulation' && !remoteWorkspace) {
await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout) await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout)
setWorldLayoutVersion((version) => version + 1) setWorldLayoutVersion((version) => version + 1)
} }
if (!appliesToActiveWorkspace) {
onTitlebarNotice(`[${map.name}] 맵이 ${scope === 'platform' ? '플랫폼' : '시뮬레이션'} 환경에 선택되었습니다.`, 'info')
return
}
if (map.saved) { if (map.saved) {
setNav2VisualizationMode('navigation') setNav2VisualizationMode('navigation')
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2') setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2')
@@ -3922,7 +3918,7 @@ function LocalPcWorkspaceMockup({
initialScope={remoteWorkspace ? 'platform' : 'simulation'} initialScope={remoteWorkspace ? 'platform' : 'simulation'}
simulationMaps={simulationWorldMaps} simulationMaps={simulationWorldMaps}
platformMaps={platformWorldMaps} platformMaps={platformWorldMaps}
selectedMapKeys={{ simulation: selectedSimulationMapKey, platform: selectedPlatformMapKey }} selectedMapKey={selectedWorldMapKey}
onClose={() => setWorldMapSelectorOpen(false)} onClose={() => setWorldMapSelectorOpen(false)}
onCreate={() => { onCreate={() => {
if (remoteWorkspace) { if (remoteWorkspace) {
@@ -3934,11 +3930,9 @@ function LocalPcWorkspaceMockup({
onDelete={(map) => void deleteSavedSlamMap(map)} onDelete={(map) => void deleteSavedSlamMap(map)}
onSelect={(map) => { onSelect={(map) => {
setWorldMapSelectorOpen(false) setWorldMapSelectorOpen(false)
const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform') const nextMapKey = map.selectionKey ?? map.key
const currentMapKey = scope === 'platform' ? selectedPlatformMapKey : selectedSimulationMapKey if (nextMapKey === selectedWorldMapKey) return
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace if (availableModules.some((module) => module.state === 'running')) {
if (map.key === currentMapKey) return
if (appliesToActiveWorkspace && availableModules.some((module) => module.state === 'running')) {
void applyWorldMapSelection(map) void applyWorldMapSelection(map)
setPendingWorldMapRestart(map) setPendingWorldMapRestart(map)
return return
@@ -4199,10 +4193,10 @@ export default function App(): React.JSX.Element {
: null : null
const hasCurrentUserPlatformControl = (platform: PlatformOption): boolean => { const hasCurrentUserPlatformControl = (platform: PlatformOption): boolean => {
if (platform.platformKey === 'futuredrive-local-pc') return true if (platform.platformKey === 'futuredrive-local-pc') return true
const session = platform.control?.session // The LMS API calculates ACTIVE for the requesting LMS user. Its session
// holder ID belongs to the LMS database, while authSession.user.id belongs
// to the auth service, so comparing the two client-side rejects the owner.
return platform.control?.status === 'ACTIVE' return platform.control?.status === 'ACTIVE'
&& Boolean(authSession?.user.id)
&& session?.holderUserId === authSession?.user.id
} }
useEffect(() => { useEffect(() => {
if (!publicGuideCourseActive) return if (!publicGuideCourseActive) return
@@ -4319,7 +4313,8 @@ export default function App(): React.JSX.Element {
if (options.notify !== false) showTitlebarNotice('원격 ROS 연결을 초기화했습니다.', 'info') if (options.notify !== false) showTitlebarNotice('원격 ROS 연결을 초기화했습니다.', 'info')
return true return true
} catch (error) { } catch (error) {
if (options.notify !== false) showTitlebarNotice(error instanceof Error ? error.message : '원격 ROS 연결 초기화에 실패했습니다.', 'error') const message = error instanceof Error ? error.message : '원격 ROS 연결 초기화에 실패했습니다.'
if (options.notify !== false) showTitlebarNotice(message, 'error')
return false return false
} finally { } finally {
setRemoteRosResetting(false) setRemoteRosResetting(false)
@@ -4618,48 +4613,31 @@ export default function App(): React.JSX.Element {
platforms.forEach((platform) => next.add(platform.id)) platforms.forEach((platform) => next.add(platform.id))
return next return next
}) })
const lostControlPlatformIds = new Set( const mergeRunningPlatformStatus = (platform: PlatformOption): PlatformOption => {
runningPlatformsRef.current const status = statusById.get(platform.id)
.filter((platform) => { if (!status) return platform
if (platform.platformKey === 'futuredrive-local-pc') return false
const nextControl = statusById.get(platform.id)?.control
if (!nextControl) return false
if (nextControl.status !== 'ACTIVE') return true
const currentSessionId = platform.control?.session?.id
return Boolean(
currentSessionId
&& nextControl.session?.id
&& nextControl.session.id !== currentSessionId,
)
})
.map((platform) => platform.id),
)
const remainingRunningPlatforms = runningPlatformsRef.current
.filter((platform) => !lostControlPlatformIds.has(platform.id))
.map(mergePlatformStatus)
if (lostControlPlatformIds.size) { // A status request started before startSession can return the prior
setRunningPlatforms(remainingRunningPlatforms) // AVAILABLE snapshot. Do not let that stale response replace an
setConnectedPlatform((current) => { // opened remote workspace with another running platform (usually the
if (current && !lostControlPlatformIds.has(current.id)) return mergePlatformStatus(current) // local PC). Explicit release, expiry and control-terminated socket
return remainingRunningPlatforms[0] ?? null // events remain responsible for closing a workspace.
}) if (
setLastWorkspacePlatform(remainingRunningPlatforms[0] ?? null) platform.platformKey !== 'futuredrive-local-pc'
if (!remainingRunningPlatforms.length) { && platform.control?.status === 'ACTIVE'
setLocalPcLayoutOpen(false) && status.control?.status !== 'ACTIVE'
setStudioEntered(false) ) {
setSessionStatus('ended') return {
setSessionEndedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false })) ...platform,
setWorkspaceWeekState(null) connection: status.connection ?? platform.connection,
}
} }
setCurriculumRefreshSignal((current) => current + 1)
} else { return mergePlatformStatus(platform)
setRunningPlatforms((current) => current.map(mergePlatformStatus))
setConnectedPlatform((current) => {
if (!current) return current
return mergePlatformStatus(current)
})
} }
setRunningPlatforms((current) => current.map(mergeRunningPlatformStatus))
setConnectedPlatform((current) => current ? mergeRunningPlatformStatus(current) : current)
} catch { } catch {
// Status polling is best-effort; keep the last known platform state. // Status polling is best-effort; keep the last known platform state.
} }
@@ -5469,13 +5447,54 @@ export default function App(): React.JSX.Element {
setSidebarCollapsed(true) setSidebarCollapsed(true)
setLocalPcLayoutOpen(true) setLocalPcLayoutOpen(true)
} }
const mergePlatformEnvironmentOverride = (platform: PlatformOption, override: unknown): PlatformOption => { const mergePlatformEnvironmentOverride = (platform: PlatformOption, payload: PlatformConnectResponse): PlatformOption => {
if (!override || typeof override !== 'object') return platform const overrideConfig = payload.environmentConfigOverride
const overrideConfig = override as Record<string, unknown> const gatewayConnection = payload.gateway?.connection
const gatewaySession = payload.gateway?.session
const connection = gatewayConnection
? {
registered: platform.connection?.registered ?? true,
headscaleOnline: platform.connection?.headscaleOnline ?? true,
agentHealthy: platform.connection?.agentHealthy ?? true,
lastSeenAt: platform.connection?.lastSeenAt ?? null,
message: platform.connection?.message ?? null,
...platform.connection,
agentUrl: gatewayConnection.agentUrl ?? platform.connection?.agentUrl ?? null,
sshUser: gatewayConnection.sshUser ?? platform.connection?.sshUser ?? null,
workspaceRoot: gatewayConnection.workspaceRoot ?? platform.connection?.workspaceRoot ?? null,
}
: platform.connection
const sourceSettings = gatewayConnection
? {
...(platform.sourceSettings ?? {}),
agentUrl: gatewayConnection.agentUrl ?? platform.sourceSettings?.agentUrl ?? null,
sshUser: gatewayConnection.sshUser ?? platform.sourceSettings?.sshUser ?? null,
workspaceRoot: gatewayConnection.workspaceRoot ?? platform.sourceSettings?.workspaceRoot ?? undefined,
}
: platform.sourceSettings
return { return {
...platform, ...platform,
environmentConfigOverride: overrideConfig, sourceSettings,
environmentProfiles: platform.environmentProfiles?.map((profile) => ({ connection,
environmentConfigOverride: overrideConfig ?? platform.environmentConfigOverride,
control: gatewaySession
? {
status: 'ACTIVE',
action: '반납',
warning: false,
session: {
id: gatewaySession.id,
holderUserId: gatewaySession.userId,
startedAt: gatewaySession.startedAt,
endsAt: gatewaySession.expiresAt,
warningAt: gatewaySession.expiresAt,
extensionCount: platform.control?.session?.extensionCount,
},
queue: null,
}
: platform.control,
environmentProfiles: overrideConfig ? platform.environmentProfiles?.map((profile) => ({
...profile, ...profile,
config: { config: {
...profile.config, ...profile.config,
@@ -5489,7 +5508,7 @@ export default function App(): React.JSX.Element {
...(typeof overrideConfig.bridge === 'object' && overrideConfig.bridge !== null ? overrideConfig.bridge as Record<string, unknown> : {}) ...(typeof overrideConfig.bridge === 'object' && overrideConfig.bridge !== null ? overrideConfig.bridge as Record<string, unknown> : {})
} }
} }
})) ?? platform.environmentProfiles })) ?? platform.environmentProfiles : platform.environmentProfiles
} }
} }
@@ -5499,7 +5518,7 @@ export default function App(): React.JSX.Element {
if (isPlatformUnavailable(platform)) return platform if (isPlatformUnavailable(platform)) return platform
const payload = await connectPlatformControl(fetchWithFreshAuth, platform.id) const payload = await connectPlatformControl(fetchWithFreshAuth, platform.id)
return mergePlatformEnvironmentOverride(platform, payload.environmentConfigOverride) return mergePlatformEnvironmentOverride(platform, payload)
} }
const applyPlatformControlResponse = (platformId: string, payload: PlatformControlActionResponse): void => { const applyPlatformControlResponse = (platformId: string, payload: PlatformControlActionResponse): void => {
@@ -5601,16 +5620,9 @@ export default function App(): React.JSX.Element {
}) })
setPlatformLoading(true) setPlatformLoading(true)
try { try {
// Invalidate status requests that started before this remote session.
platformStatusRequestSequenceRef.current += 1
const connected = await connectRemotePlatform(platform) const connected = await connectRemotePlatform(platform)
if (connected.platformKey !== 'futuredrive-local-pc') {
setPlatformLoadingCopy({
title: `${connected.title} ROS 연결 초기화 중`,
description: '이전 제어 세션의 모듈과 피어 연결을 정리하고 있습니다.'
})
if (!await resetRemotePlatformRosSession(connected, { notify: false })) {
throw new Error('원격 ROS 연결 초기화에 실패했습니다.')
}
}
const workspaceWeekSnapshot = selectedWeekState const workspaceWeekSnapshot = selectedWeekState
window.setTimeout(() => { window.setTimeout(() => {
setConnectedPlatform(connected) setConnectedPlatform(connected)
@@ -5620,19 +5632,19 @@ export default function App(): React.JSX.Element {
const exists = platforms.some((current) => current.id === connected.id) const exists = platforms.some((current) => current.id === connected.id)
return exists ? platforms.map((current) => current.id === connected.id ? connected : current) : [...platforms, connected] return exists ? platforms.map((current) => current.id === connected.id ? connected : current) : [...platforms, connected]
}) })
setSessionStartedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false })) setSessionStartedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }))
setSessionEndedAt(null) setSessionEndedAt(null)
setSessionStatus('running') setSessionStatus('running')
setStudioEntered(true) setStudioEntered(true)
setPlatformLoading(false) setPlatformLoading(false)
setPlatformModalOpen(false) setPlatformModalOpen(false)
setRecentSessionModalOpen(false) setRecentSessionModalOpen(false)
setSidebarCollapsed(true) setSidebarCollapsed(true)
setLocalPcLayoutOpen(true) setLocalPcLayoutOpen(true)
}, 2000) }, 2000)
} catch { } catch (error) {
setPlatformLoading(false) setPlatformLoading(false)
showTitlebarNotice('플랫폼 연결 정보를 불러오지 못했습니다', 'error') showTitlebarNotice(error instanceof Error ? error.message : '플랫폼 연결 정보를 불러오지 못했습니다', 'error')
setCurriculumRefreshSignal((current) => current + 1) setCurriculumRefreshSignal((current) => current + 1)
} }
} }
+1 -1
View File
@@ -236,7 +236,7 @@ interface Window {
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
message?: string message?: string
}> }>
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => Promise<{ start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => Promise<{
ok: boolean ok: boolean
running: boolean running: boolean
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
@@ -30,6 +30,29 @@ export type PlatformControlTerminatedPayload = {
endedAt: string endedAt: string
} }
export type PlatformConnectResponse = {
mode?: string
gateway?: {
session?: {
id: string
platformId: string
userId: string
status: string
startedAt: string
expiresAt: string
releasedAt: string | null
}
connection?: {
agentUrl?: string | null
sshUser?: string | null
workspaceRoot?: string | null
bridgeHttpUrl?: string | null
bridgeWsUrl?: string | null
}
}
environmentConfigOverride?: Record<string, unknown>
}
export function isPlatformUnavailable(platform: PlatformOption): boolean { export function isPlatformUnavailable(platform: PlatformOption): boolean {
return ( return (
platform.state === 'locked' || platform.state === 'locked' ||
@@ -60,12 +83,12 @@ export async function fetchPlatformStatuses(fetchWithFreshAuth: AuthFetch, platf
return payload.platforms ?? [] return payload.platforms ?? []
} }
export async function connectPlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<{ environmentConfigOverride?: unknown }> { export async function connectPlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<PlatformConnectResponse> {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/connect`, { const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/connect`, {
method: 'POST', method: 'POST',
}) })
if (!response.ok) throw new Error('platform-connect failed') if (!response.ok) throw new Error('platform-connect failed')
return response.json() as Promise<{ environmentConfigOverride?: unknown }> return response.json() as Promise<PlatformConnectResponse>
} }
export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<void> { export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<void> {
@@ -349,7 +349,7 @@ export default function ModuleListView({
</div> </div>
</div> </div>
</div> </div>
<div ref={floatingActionMenuRef} className="absolute -left-7 bottom-6 z-[60] h-14 w-14"> <div ref={floatingActionMenuRef} className="absolute -left-7 bottom-6 z-30 h-14 w-14">
<button <button
type="button" type="button"
aria-label="실행 방법 메뉴" aria-label="실행 방법 메뉴"
@@ -357,7 +357,7 @@ export default function ModuleListView({
onClick={() => setFloatingActionMenuOpen((open) => !open)} onClick={() => setFloatingActionMenuOpen((open) => !open)}
className={[ className={[
'grid h-14 w-14 cursor-pointer place-items-center rounded-full text-center text-[11px] font-bold shadow-[0_10px_26px_rgba(20,54,104,0.28)] transition hover:-translate-y-0.5 hover:scale-[1.05]', 'grid h-14 w-14 cursor-pointer place-items-center rounded-full text-center text-[11px] font-bold shadow-[0_10px_26px_rgba(20,54,104,0.28)] transition hover:-translate-y-0.5 hover:scale-[1.05]',
floatingActionMenuOpen ? 'bg-[#4b5563]/85 text-[#c7cfda] hover:bg-[#3f4855]/90' : 'bg-[#2563eb] text-white hover:bg-[#1d4ed8]' floatingActionMenuOpen ? 'bg-[#4b5563]/85 text-[#c7cfda] hover:bg-[#3f4855]/90' : 'bg-[#2158cc] text-white hover:bg-[#1d4ed8]'
].join(' ')} ].join(' ')}
> >
<span className="text-[24px] font-medium leading-none">+</span> <span className="text-[24px] font-medium leading-none">+</span>
@@ -6,6 +6,9 @@ export type WorkspaceMapScope = 'simulation' | 'platform'
export type WorkspaceMapOption = { export type WorkspaceMapOption = {
key: string key: string
// A saved platform map has a local Gazebo representation with a different
// runtime key. Both representations share this user-facing map selection.
selectionKey?: string
platformKey: 'NAV2' platformKey: 'NAV2'
name: string name: string
description: string description: string
@@ -198,25 +201,24 @@ export function WorkspaceMapPreview({
type WorldMapSelectorModalProps = { type WorldMapSelectorModalProps = {
weekNumber: number weekNumber: number
initialScope?: 'simulation' | 'platform' initialScope?: WorkspaceMapScope
simulationMaps: WorkspaceMapOption[] simulationMaps: WorkspaceMapOption[]
platformMaps: WorkspaceMapOption[] platformMaps: WorkspaceMapOption[]
selectedMapKeys: Record<WorkspaceMapScope, string> selectedMapKey: string
onSelect: (map: WorkspaceMapOption) => void onSelect: (map: WorkspaceMapOption) => void
onCreate?: () => void onCreate?: () => void
onDelete?: (map: WorkspaceMapOption) => void onDelete?: (map: WorkspaceMapOption) => void
onClose: () => void onClose: () => void
} }
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKeys, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element { export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const [scope, setScope] = useState<'simulation' | 'platform'>(initialScope) const [scope, setScope] = useState<WorkspaceMapScope>(initialScope)
const maps = scope === 'simulation' ? simulationMaps : platformMaps const maps = scope === 'simulation' ? simulationMaps : platformMaps
const selectedMapKey = selectedMapKeys[scope] const selectedMap = maps.find((map) => (map.selectionKey ?? map.key) === selectedMapKey) ?? null
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap) const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
useEffect(() => { useEffect(() => {
setPendingMap(maps.find((map) => map.key === selectedMapKey) ?? null) setPendingMap(maps.find((map) => (map.selectionKey ?? map.key) === selectedMapKey) ?? null)
}, [maps, selectedMapKey]) }, [maps, selectedMapKey])
useEffect(() => { useEffect(() => {
@@ -229,7 +231,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4"> <header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div> <div>
<h2 className="text-[16px] font-bold">맵 설정</h2> <h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차 학습 세션 맵</p> <p className="mt-1 text-[12px] text-white/50">{weekNumber}주차 학습 세션 공통 맵</p>
</div> </div>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white"> <button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" /> <X className="size-4" />
@@ -242,7 +244,8 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
<div className="thin-y-scrollbar min-h-[400px] max-h-[52vh] space-y-2 overflow-y-auto p-5"> <div className="thin-y-scrollbar min-h-[400px] max-h-[52vh] space-y-2 overflow-y-auto p-5">
{maps.map((map) => { {maps.map((map) => {
const pending = map.key === pendingMap?.key const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey const mapSelectionKey = map.selectionKey ?? map.key
const selected = mapSelectionKey === selectedMapKey
// "새 SLAM" is a launch target, not a saved map yet. Its 2D/3D // "새 SLAM" is a launch target, not a saved map yet. Its 2D/3D
// runtime is chosen by the active SLAM stack, so only persisted // runtime is chosen by the active SLAM stack, so only persisted
// map results receive a dimensional badge. // map results receive a dimensional badge.
@@ -279,7 +282,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
</span> </span>
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>} {map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />} {selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
{map.saved && map.key !== selectedMapKey && onDelete && ( {map.saved && !selected && onDelete && (
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100"> <span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</span> </span>
@@ -54,7 +54,6 @@ export function useWorkspaceTerminal({
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget) const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
const terminalSuppressQueueRef = useRef<string[]>([]) const terminalSuppressQueueRef = useRef<string[]>([])
const terminalOutputBufferRef = useRef<Record<string, string>>({}) const terminalOutputBufferRef = useRef<Record<string, string>>({})
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235) const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
remoteTargetResolverRef.current = getRemoteWorkspaceTarget remoteTargetResolverRef.current = getRemoteWorkspaceTarget
@@ -158,7 +157,6 @@ export function useWorkspaceTerminal({
} }
terminalElement.addEventListener('contextmenu', handleTerminalContextMenu) terminalElement.addEventListener('contextmenu', handleTerminalContextMenu)
terminalElement.addEventListener('mousedown', focusTerminal) terminalElement.addEventListener('mousedown', focusTerminal)
let terminalReceivedVisibleData = Boolean(terminalOutputBufferRef.current[terminalSessionId])
if (terminalOutputBufferRef.current[terminalSessionId]) { if (terminalOutputBufferRef.current[terminalSessionId]) {
term.write(terminalOutputBufferRef.current[terminalSessionId]) term.write(terminalOutputBufferRef.current[terminalSessionId])
} }
@@ -175,7 +173,6 @@ export function useWorkspaceTerminal({
const nextData = stripInternalTerminalCommands(data) const nextData = stripInternalTerminalCommands(data)
if (nextData) { if (nextData) {
terminalReceivedVisibleData = true
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000) terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000)
term.write(nextData) term.write(nextData)
} }
@@ -185,7 +182,6 @@ export function useWorkspaceTerminal({
}, terminalSessionId) }, terminalSessionId)
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId)) const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
let fallbackPromptTimer: number | null = null
let sizeSyncTimer: number | null = null let sizeSyncTimer: number | null = null
let startFrame: number | null = null let startFrame: number | null = null
let startNestedFrame: number | null = null let startNestedFrame: number | null = null
@@ -229,17 +225,6 @@ export function useWorkspaceTerminal({
} }
void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId) void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId)
}, 150) }, 150)
fallbackPromptTimer = window.setTimeout(() => {
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
if (terminalReceivedVisibleData || terminalOutputBufferRef.current[terminalSessionId]) return
const activeLine = term.buffer.active.getLine(term.buffer.active.cursorY)?.translateToString(true).trim()
if (activeLine || term.buffer.active.baseY > 0 || term.buffer.active.cursorX > 0) return
const fallbackPrompt = `${moduleName.replace(/[^a-zA-Z0-9_.-]+/g, '-')}: `
terminalReceivedVisibleData = true
terminalFallbackPromptShownRef.current.add(terminalSessionId)
terminalOutputBufferRef.current[terminalSessionId] = fallbackPrompt
term.write(fallbackPrompt)
}, 500)
}) })
const onResize = (): void => fitAddon.fit() const onResize = (): void => fitAddon.fit()
@@ -255,11 +240,9 @@ export function useWorkspaceTerminal({
removeDataListener() removeDataListener()
removeSuppressListener() removeSuppressListener()
inputDisposable.dispose() inputDisposable.dispose()
if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer)
if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer) if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer)
// Full-screen programs such as vi cannot be restored from a partial raw output buffer. // Full-screen programs such as vi cannot be restored from a partial raw output buffer.
delete terminalOutputBufferRef.current[terminalSessionId] delete terminalOutputBufferRef.current[terminalSessionId]
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
if (remoteWorkspace) window.remoteWorkspace.terminal.stop(terminalSessionId) if (remoteWorkspace) window.remoteWorkspace.terminal.stop(terminalSessionId)
else window.localWorkspace.terminal.stop(terminalSessionId) else window.localWorkspace.terminal.stop(terminalSessionId)
term.dispose() term.dispose()
@@ -65,7 +65,7 @@ type UseWorkspaceVisualizationOptions = {
platform?: string | null platform?: string | null
threeDimensional?: boolean threeDimensional?: boolean
savedMapDatabasePath?: string | null savedMapDatabasePath?: string | null
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string }> prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string; savedMapWorldLayoutPath?: string }>
visualizationRuntime?: 'real' | 'simulation' visualizationRuntime?: 'real' | 'simulation'
worldLayoutVersion?: number worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
@@ -255,7 +255,15 @@ export function useWorkspaceVisualization({
? bridgeTarget?.host ? bridgeTarget?.host
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork) ? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' } : { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork, bridgeSavedMapDatabasePath) : await window.localWorkspace.visualization.start(
visualizationRuntimeStack,
bridgeWorkspaceRoot ?? clonePathPreview,
rosDomainId,
localBridgeProfile,
rosNetwork,
bridgeSavedMapDatabasePath,
preparedMap?.savedMapWorldLayoutPath,
)
if (!status.ok) throw new Error(status.message ?? '시작 실패') if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running)) setVisualizationRunning(Boolean(status.running))
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d' const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'