(fix) bug remote workspace ssh..
This commit is contained in:
+65
-10
@@ -22,6 +22,8 @@ const terminalStopTimers = new Map<string, NodeJS.Timeout>()
|
||||
const intentionalTerminalStops = new Set<string>()
|
||||
const nav2RuntimeObstacleNames = new Set<string>()
|
||||
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 artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
|
||||
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 }
|
||||
}
|
||||
|
||||
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 {
|
||||
const root = expandUserPath(workspaceRoot)
|
||||
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))
|
||||
await mkdir(mapsDirectory, { recursive: true })
|
||||
await writeJsonAtomically(resolve(mapsDirectory, savedMapCollisionLayoutFileName(yamlFileName)), layout)
|
||||
await writeJsonAtomically(worldLayoutPath(root), layout)
|
||||
return layout
|
||||
}
|
||||
|
||||
@@ -681,7 +695,6 @@ async function generateWorkspacePointCloudCollisionLayout(
|
||||
&& cached.source.modifiedAtMs === sourceStat.mtimeMs
|
||||
&& cached.layout) {
|
||||
const layout = normalizeWorldLayout(cached.layout)
|
||||
await writeJsonAtomically(worldLayoutPath(root), layout)
|
||||
return layout
|
||||
}
|
||||
} catch {
|
||||
@@ -697,7 +710,6 @@ async function generateWorkspacePointCloudCollisionLayout(
|
||||
source: { fileName: databaseFileName, size: sourceStat.size, modifiedAtMs: sourceStat.mtimeMs },
|
||||
layout,
|
||||
} satisfies PointCloudLayoutCache)
|
||||
await writeJsonAtomically(worldLayoutPath(root), layout)
|
||||
return layout
|
||||
}
|
||||
|
||||
@@ -1365,7 +1377,7 @@ async function collectRemoteAvailableModules(
|
||||
}
|
||||
})
|
||||
.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 dockerStats = new Map<string, { cpu: number; memory: number }>()
|
||||
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> {
|
||||
const normalized = normalizeRemoteTarget(target)
|
||||
await mkdir(remoteSshControlDirectory, { recursive: true })
|
||||
return queueRemoteSsh(normalized, () => runCommandOutput(
|
||||
'ssh',
|
||||
[
|
||||
@@ -1854,6 +1867,18 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
|
||||
'StrictHostKeyChecking=accept-new',
|
||||
'-o',
|
||||
'ConnectTimeout=5',
|
||||
'-o',
|
||||
'PreferredAuthentications=publickey',
|
||||
'-o',
|
||||
'PasswordAuthentication=no',
|
||||
'-o',
|
||||
'KbdInteractiveAuthentication=no',
|
||||
'-o',
|
||||
'ControlMaster=auto',
|
||||
'-o',
|
||||
'ControlPersist=120',
|
||||
'-o',
|
||||
`ControlPath=${join(remoteSshControlDirectory, 'cm-%C')}`,
|
||||
'-i',
|
||||
normalized.identityFile,
|
||||
`${normalized.user}@${normalized.host}`,
|
||||
@@ -2123,10 +2148,10 @@ function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModu
|
||||
'set +e',
|
||||
`ROOT=${quoteShellSingle(workspaceRoot)}`,
|
||||
...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
|
||||
// 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) => {
|
||||
const modulePath = remotePathJoin('$ROOT', folderName)
|
||||
return [
|
||||
@@ -2344,6 +2369,9 @@ async function resolveRemoteGitModuleRoot(
|
||||
): Promise<{ target: ReturnType<typeof normalizeRemoteTarget>; moduleRoot: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
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, [
|
||||
'set -e',
|
||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||
@@ -2356,6 +2384,7 @@ async function resolveRemoteGitModuleRoot(
|
||||
].join('\n'), 8000)
|
||||
const moduleRoot = output.trim()
|
||||
if (!moduleRoot) throw new Error('원격 Git 저장소를 찾을 수 없습니다.')
|
||||
remoteGitModuleRoots.set(cacheKey, moduleRoot)
|
||||
return { target, moduleRoot }
|
||||
}
|
||||
|
||||
@@ -3347,6 +3376,7 @@ async function startVisualizationBridge(
|
||||
environmentProfile?: EnvironmentProfileConfig | null,
|
||||
rosNetwork?: RosNetworkConfig | null,
|
||||
savedMapDatabasePath?: string | null,
|
||||
savedMapWorldLayoutPath?: string | null,
|
||||
): Promise<VisualizationBridgeStatus> {
|
||||
const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase())
|
||||
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
|
||||
@@ -3358,6 +3388,25 @@ async function startVisualizationBridge(
|
||||
const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2'
|
||||
const layoutWorkspaceRoot = usesSlamBridge ? expandUserPath(workspaceRoot) : null
|
||||
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 requestedRosNetworkKey = rosNetworkEnabled(rosNetwork)
|
||||
? `peer:${rosNetwork?.peer?.host?.trim() ?? ''}`
|
||||
@@ -3384,10 +3433,16 @@ async function startVisualizationBridge(
|
||||
const runningWorkspaceRoot = layoutWorkspaceRoot
|
||||
? await readContainerMountSource(containerName, '/workspace')
|
||||
: null
|
||||
const runningWorldLayoutPath = layoutWorkspaceRoot
|
||||
? await readContainerEnvironmentValue(containerName, 'WORLD_LAYOUT_PATH')
|
||||
: null
|
||||
if (response?.ok
|
||||
&& (!runningRosDomainId || runningRosDomainId === requestedRosDomainId)
|
||||
&& runningRosNetworkKey === requestedRosNetworkKey
|
||||
&& (!layoutWorkspaceRoot || runningWorkspaceRoot === resolve(layoutWorkspaceRoot))) {
|
||||
&& (!layoutWorkspaceRoot || (
|
||||
runningWorkspaceRoot === resolve(layoutWorkspaceRoot)
|
||||
&& runningWorldLayoutPath === requestedWorldLayoutPath
|
||||
))) {
|
||||
return readVisualizationBridgeStatus()
|
||||
}
|
||||
await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined)
|
||||
@@ -3448,7 +3503,7 @@ async function startVisualizationBridge(
|
||||
if (layoutWorkspaceRoot) {
|
||||
args.push(
|
||||
'-v', `${layoutWorkspaceRoot}:/workspace`,
|
||||
'-e', 'WORLD_LAYOUT_PATH=/workspace/world-layout.json',
|
||||
'-e', `WORLD_LAYOUT_PATH=${requestedWorldLayoutPath}`,
|
||||
'-e', 'SAVED_MAPS_PATH=/workspace/maps',
|
||||
'-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) => {
|
||||
const targetPath = worldLayoutPath(workspaceRoot)
|
||||
try {
|
||||
return normalizeWorldLayout(JSON.parse(await readFile(targetPath, 'utf8')))
|
||||
return normalizeCustomWorldLayout(JSON.parse(await readFile(targetPath, 'utf8')))
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') return defaultWorldLayout()
|
||||
@@ -5902,7 +5957,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
return { ok: true }
|
||||
})
|
||||
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: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))
|
||||
|
||||
@@ -226,7 +226,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
||||
},
|
||||
visualization: {
|
||||
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)
|
||||
},
|
||||
git: {
|
||||
|
||||
@@ -582,7 +582,15 @@ controls.mouseButtons = {
|
||||
MIDDLE: THREE.MOUSE.ROTATE,
|
||||
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.dampingFactor = 0.08;
|
||||
controls.target.set(0, 0, 0);
|
||||
@@ -2706,7 +2714,7 @@ function createObstaclePreviewTemplate() {
|
||||
yaw: (Math.floor(Math.random() * 4) * Math.PI) / 2,
|
||||
length: 0.7 + Math.random() * 0.9,
|
||||
depth: 0.16,
|
||||
height: 0.52,
|
||||
height: 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+143
-131
@@ -29,6 +29,7 @@ import RecentSessionModal from './features/platforms/RecentSessionModal'
|
||||
import type { RecentSessionItem } from './features/platforms/RecentSessionModal'
|
||||
import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } 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 WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader'
|
||||
import WorkspaceEmptyModuleState from './features/workspace/WorkspaceEmptyModuleState'
|
||||
@@ -44,7 +45,7 @@ import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleT
|
||||
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, 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 WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
|
||||
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
|
||||
@@ -803,16 +804,13 @@ function LocalPcWorkspaceMockup({
|
||||
const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected`
|
||||
const simulationMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:simulation`
|
||||
const platformMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:platform`
|
||||
const [selectedSimulationMapKey, setSelectedSimulationMapKey] = useState(() => (
|
||||
window.localStorage.getItem(simulationMapStorageKey)
|
||||
?? window.localStorage.getItem(sessionMapStorageKey)
|
||||
?? 'nav2-turtlebot3-world'
|
||||
))
|
||||
const [selectedPlatformMapKey, setSelectedPlatformMapKey] = useState(() => (
|
||||
window.localStorage.getItem(platformMapStorageKey)
|
||||
?? window.localStorage.getItem(sessionMapStorageKey)
|
||||
?? 'nav2-platform-slam-2d'
|
||||
))
|
||||
const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => {
|
||||
const stored = window.localStorage.getItem(sessionMapStorageKey)
|
||||
?? window.localStorage.getItem(simulationMapStorageKey)
|
||||
?? window.localStorage.getItem(platformMapStorageKey)
|
||||
if (stored === 'nav2-platform-slam') return 'nav2-platform-slam-2d'
|
||||
return stored?.replace(/:simulation$/, '') ?? 'nav2-turtlebot3-world'
|
||||
})
|
||||
const [savedSlamMaps, setSavedSlamMaps] = useState<WorkspaceMapOption[]>([])
|
||||
const [savedSlamMapsLoaded, setSavedSlamMapsLoaded] = useState(false)
|
||||
const [worldLayoutVersion, setWorldLayoutVersion] = useState(0)
|
||||
@@ -861,6 +859,7 @@ function LocalPcWorkspaceMockup({
|
||||
.map((map) => ({
|
||||
...map,
|
||||
key: `${map.key}:simulation`,
|
||||
selectionKey: map.key,
|
||||
description: '저장된 SLAM 결과 맵',
|
||||
worldFile: 'empty_world.world',
|
||||
scope: 'simulation' as const,
|
||||
@@ -874,6 +873,9 @@ function LocalPcWorkspaceMockup({
|
||||
return true
|
||||
})
|
||||
}, [curriculumWeek.maps, savedSlamMaps])
|
||||
const selectableWorldMaps = useMemo(() => (
|
||||
sessionWorldMaps.filter((map) => !map.selectionKey)
|
||||
), [sessionWorldMaps])
|
||||
const simulationWorldMaps = useMemo(() => (
|
||||
sessionWorldMaps.filter((map) => map.scope === 'simulation')
|
||||
), [sessionWorldMaps])
|
||||
@@ -881,41 +883,41 @@ function LocalPcWorkspaceMockup({
|
||||
sessionWorldMaps.filter((map) => map.scope === 'platform')
|
||||
), [sessionWorldMaps])
|
||||
useEffect(() => {
|
||||
const legacyMapKey = window.localStorage.getItem(sessionMapStorageKey)
|
||||
const normalizeMapKey = (key: string | null, scope: WorkspaceMapScope): string | null => {
|
||||
if (key === 'nav2-platform-slam') return scope === 'platform' ? 'nav2-platform-slam-2d' : null
|
||||
return key
|
||||
const normalizeMapKey = (key: string | null): string | null => {
|
||||
if (key === 'nav2-platform-slam') return 'nav2-platform-slam-2d'
|
||||
return key?.replace(/:simulation$/, '') ?? null
|
||||
}
|
||||
const resolveSelection = (
|
||||
scope: WorkspaceMapScope,
|
||||
current: string,
|
||||
storageKey: string,
|
||||
maps: WorkspaceMapOption[],
|
||||
fallback: string,
|
||||
): string => {
|
||||
const stored = normalizeMapKey(window.localStorage.getItem(storageKey) ?? legacyMapKey, scope)
|
||||
const requested = normalizeMapKey(current, scope) ?? stored
|
||||
const stored = normalizeMapKey(
|
||||
window.localStorage.getItem(sessionMapStorageKey)
|
||||
?? window.localStorage.getItem(simulationMapStorageKey)
|
||||
?? window.localStorage.getItem(platformMapStorageKey),
|
||||
)
|
||||
setSelectedWorldMapKey((current) => {
|
||||
const requested = normalizeMapKey(current) ?? stored
|
||||
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!
|
||||
: maps.some((map) => map.key === stored)
|
||||
: selectableWorldMaps.some((map) => map.key === stored)
|
||||
? stored!
|
||||
: maps.find((map) => map.key === fallback)?.key ?? maps[0]?.key ?? fallback
|
||||
window.localStorage.setItem(storageKey, next)
|
||||
: selectableWorldMaps.find((map) => map.key === 'nav2-turtlebot3-world')?.key
|
||||
?? selectableWorldMaps[0]?.key
|
||||
?? 'nav2-turtlebot3-world'
|
||||
window.localStorage.setItem(sessionMapStorageKey, next)
|
||||
return next
|
||||
}
|
||||
setSelectedSimulationMapKey((current) => resolveSelection(
|
||||
'simulation', current, simulationMapStorageKey, simulationWorldMaps, 'nav2-turtlebot3-world',
|
||||
))
|
||||
setSelectedPlatformMapKey((current) => resolveSelection(
|
||||
'platform', current, platformMapStorageKey, platformWorldMaps, 'nav2-platform-slam-2d',
|
||||
))
|
||||
}, [platformMapStorageKey, platformWorldMaps, savedSlamMapsLoaded, sessionMapStorageKey, simulationMapStorageKey, simulationWorldMaps])
|
||||
const selectedWorldMapKey = remoteWorkspace ? selectedPlatformMapKey : selectedSimulationMapKey
|
||||
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey)
|
||||
?? (remoteWorkspace ? platformWorldMaps[0] : simulationWorldMaps[0])
|
||||
})
|
||||
}, [platformMapStorageKey, savedSlamMapsLoaded, selectableWorldMaps, sessionMapStorageKey, simulationMapStorageKey])
|
||||
const selectedWorldMap = useMemo(() => {
|
||||
const localSimulationMapKey = `${selectedWorldMapKey}:simulation`
|
||||
return (!remoteWorkspace
|
||||
? sessionWorldMaps.find((map) => map.key === localSimulationMapKey)
|
||||
: undefined)
|
||||
?? sessionWorldMaps.find((map) => map.key === selectedWorldMapKey)
|
||||
?? selectableWorldMaps.find((map) => map.key === 'nav2-turtlebot3-world')
|
||||
?? selectableWorldMaps[0]
|
||||
?? NAV2_MAP_OPTIONS[0]
|
||||
}, [remoteWorkspace, selectableWorldMaps, selectedWorldMapKey, sessionWorldMaps])
|
||||
const prepareVisualizationSavedMapRef = useRef<(
|
||||
() => Promise<{ savedMapDatabasePath?: string }>
|
||||
() => Promise<{ savedMapDatabasePath?: string; savedMapWorldLayoutPath?: string }>
|
||||
) | null>(null)
|
||||
const savedMapRunsInSimulation = !remoteWorkspace
|
||||
&& selectedWorldMap?.saved === true
|
||||
@@ -2491,7 +2493,7 @@ function LocalPcWorkspaceMockup({
|
||||
saveDirtyFilesForModule,
|
||||
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.yamlPath || !selectedWorldMap.pgmPath || !selectedWorldMap.yamlUrl || !selectedWorldMap.pgmUrl) {
|
||||
throw new Error('이전 형식으로 저장된 맵입니다. SLAM을 다시 저장해 공용 맵으로 변환하세요.')
|
||||
@@ -2511,6 +2513,7 @@ function LocalPcWorkspaceMockup({
|
||||
]
|
||||
let databasePath: string | undefined
|
||||
let databaseFileName: string | undefined
|
||||
let savedMapWorldLayoutPath: string | undefined
|
||||
if (selectedWorldMap.databasePath || selectedWorldMap.databaseUrl) {
|
||||
databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : ''
|
||||
if (!databaseFileName.endsWith('.db') || !selectedWorldMap.databaseUrl) {
|
||||
@@ -2534,13 +2537,16 @@ function LocalPcWorkspaceMockup({
|
||||
if (selectedWorldMap.runtimeKind === 'PLATFORM_3D') {
|
||||
if (!databaseFileName) throw new Error('시뮬레이션할 3D SLAM 데이터베이스가 없습니다.')
|
||||
await window.localWorkspace.maps.generatePointCloudCollisionLayout(clonePathPreview, databaseFileName)
|
||||
savedMapWorldLayoutPath = `/workspace/maps/${databaseFileName.replace(/\.db$/i, '.gazebo-3d-layout.json')}`
|
||||
} else {
|
||||
await window.localWorkspace.maps.generateCollisionLayout(clonePathPreview, yamlFileName, pgmFileName)
|
||||
savedMapWorldLayoutPath = `/workspace/maps/${yamlFileName.replace(/\.yaml$/i, '.gazebo-layout.json')}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
|
||||
savedMapDatabasePath: databasePath,
|
||||
savedMapWorldLayoutPath,
|
||||
}
|
||||
}, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
|
||||
prepareVisualizationSavedMapRef.current = prepareSavedMap
|
||||
@@ -2614,23 +2620,13 @@ function LocalPcWorkspaceMockup({
|
||||
return () => onRegisterRunningModuleNames?.(null)
|
||||
}, [availableModules, onRegisterRunningModuleNames, onRunningModuleCountChange])
|
||||
const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => {
|
||||
const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform')
|
||||
const storageKey = scope === 'platform' ? platformMapStorageKey : simulationMapStorageKey
|
||||
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace
|
||||
window.localStorage.setItem(storageKey, map.key)
|
||||
if (scope === 'platform') {
|
||||
setSelectedPlatformMapKey(map.key)
|
||||
} else {
|
||||
setSelectedSimulationMapKey(map.key)
|
||||
}
|
||||
if (map.custom && map.layout && scope === 'simulation' && !remoteWorkspace) {
|
||||
const mapKey = map.selectionKey ?? map.key
|
||||
window.localStorage.setItem(sessionMapStorageKey, mapKey)
|
||||
setSelectedWorldMapKey(mapKey)
|
||||
if (map.custom && map.layout && !remoteWorkspace) {
|
||||
await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout)
|
||||
setWorldLayoutVersion((version) => version + 1)
|
||||
}
|
||||
if (!appliesToActiveWorkspace) {
|
||||
onTitlebarNotice(`[${map.name}] 맵이 ${scope === 'platform' ? '플랫폼' : '시뮬레이션'} 환경에 선택되었습니다.`, 'info')
|
||||
return
|
||||
}
|
||||
if (map.saved) {
|
||||
setNav2VisualizationMode('navigation')
|
||||
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2')
|
||||
@@ -3922,7 +3918,7 @@ function LocalPcWorkspaceMockup({
|
||||
initialScope={remoteWorkspace ? 'platform' : 'simulation'}
|
||||
simulationMaps={simulationWorldMaps}
|
||||
platformMaps={platformWorldMaps}
|
||||
selectedMapKeys={{ simulation: selectedSimulationMapKey, platform: selectedPlatformMapKey }}
|
||||
selectedMapKey={selectedWorldMapKey}
|
||||
onClose={() => setWorldMapSelectorOpen(false)}
|
||||
onCreate={() => {
|
||||
if (remoteWorkspace) {
|
||||
@@ -3934,11 +3930,9 @@ function LocalPcWorkspaceMockup({
|
||||
onDelete={(map) => void deleteSavedSlamMap(map)}
|
||||
onSelect={(map) => {
|
||||
setWorldMapSelectorOpen(false)
|
||||
const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform')
|
||||
const currentMapKey = scope === 'platform' ? selectedPlatformMapKey : selectedSimulationMapKey
|
||||
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace
|
||||
if (map.key === currentMapKey) return
|
||||
if (appliesToActiveWorkspace && availableModules.some((module) => module.state === 'running')) {
|
||||
const nextMapKey = map.selectionKey ?? map.key
|
||||
if (nextMapKey === selectedWorldMapKey) return
|
||||
if (availableModules.some((module) => module.state === 'running')) {
|
||||
void applyWorldMapSelection(map)
|
||||
setPendingWorldMapRestart(map)
|
||||
return
|
||||
@@ -4199,10 +4193,10 @@ export default function App(): React.JSX.Element {
|
||||
: null
|
||||
const hasCurrentUserPlatformControl = (platform: PlatformOption): boolean => {
|
||||
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'
|
||||
&& Boolean(authSession?.user.id)
|
||||
&& session?.holderUserId === authSession?.user.id
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!publicGuideCourseActive) return
|
||||
@@ -4319,7 +4313,8 @@ export default function App(): React.JSX.Element {
|
||||
if (options.notify !== false) showTitlebarNotice('원격 ROS 연결을 초기화했습니다.', 'info')
|
||||
return true
|
||||
} 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
|
||||
} finally {
|
||||
setRemoteRosResetting(false)
|
||||
@@ -4618,48 +4613,31 @@ export default function App(): React.JSX.Element {
|
||||
platforms.forEach((platform) => next.add(platform.id))
|
||||
return next
|
||||
})
|
||||
const lostControlPlatformIds = new Set(
|
||||
runningPlatformsRef.current
|
||||
.filter((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)
|
||||
const mergeRunningPlatformStatus = (platform: PlatformOption): PlatformOption => {
|
||||
const status = statusById.get(platform.id)
|
||||
if (!status) return platform
|
||||
|
||||
if (lostControlPlatformIds.size) {
|
||||
setRunningPlatforms(remainingRunningPlatforms)
|
||||
setConnectedPlatform((current) => {
|
||||
if (current && !lostControlPlatformIds.has(current.id)) return mergePlatformStatus(current)
|
||||
return remainingRunningPlatforms[0] ?? null
|
||||
})
|
||||
setLastWorkspacePlatform(remainingRunningPlatforms[0] ?? null)
|
||||
if (!remainingRunningPlatforms.length) {
|
||||
setLocalPcLayoutOpen(false)
|
||||
setStudioEntered(false)
|
||||
setSessionStatus('ended')
|
||||
setSessionEndedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }))
|
||||
setWorkspaceWeekState(null)
|
||||
// A status request started before startSession can return the prior
|
||||
// AVAILABLE snapshot. Do not let that stale response replace an
|
||||
// opened remote workspace with another running platform (usually the
|
||||
// local PC). Explicit release, expiry and control-terminated socket
|
||||
// events remain responsible for closing a workspace.
|
||||
if (
|
||||
platform.platformKey !== 'futuredrive-local-pc'
|
||||
&& platform.control?.status === 'ACTIVE'
|
||||
&& status.control?.status !== 'ACTIVE'
|
||||
) {
|
||||
return {
|
||||
...platform,
|
||||
connection: status.connection ?? platform.connection,
|
||||
}
|
||||
}
|
||||
setCurriculumRefreshSignal((current) => current + 1)
|
||||
} else {
|
||||
setRunningPlatforms((current) => current.map(mergePlatformStatus))
|
||||
setConnectedPlatform((current) => {
|
||||
if (!current) return current
|
||||
return mergePlatformStatus(current)
|
||||
})
|
||||
|
||||
return mergePlatformStatus(platform)
|
||||
}
|
||||
|
||||
setRunningPlatforms((current) => current.map(mergeRunningPlatformStatus))
|
||||
setConnectedPlatform((current) => current ? mergeRunningPlatformStatus(current) : current)
|
||||
} catch {
|
||||
// Status polling is best-effort; keep the last known platform state.
|
||||
}
|
||||
@@ -5469,13 +5447,54 @@ export default function App(): React.JSX.Element {
|
||||
setSidebarCollapsed(true)
|
||||
setLocalPcLayoutOpen(true)
|
||||
}
|
||||
const mergePlatformEnvironmentOverride = (platform: PlatformOption, override: unknown): PlatformOption => {
|
||||
if (!override || typeof override !== 'object') return platform
|
||||
const overrideConfig = override as Record<string, unknown>
|
||||
const mergePlatformEnvironmentOverride = (platform: PlatformOption, payload: PlatformConnectResponse): PlatformOption => {
|
||||
const overrideConfig = payload.environmentConfigOverride
|
||||
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 {
|
||||
...platform,
|
||||
environmentConfigOverride: overrideConfig,
|
||||
environmentProfiles: platform.environmentProfiles?.map((profile) => ({
|
||||
sourceSettings,
|
||||
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,
|
||||
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> : {})
|
||||
}
|
||||
}
|
||||
})) ?? platform.environmentProfiles
|
||||
})) ?? platform.environmentProfiles : platform.environmentProfiles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5499,7 +5518,7 @@ export default function App(): React.JSX.Element {
|
||||
if (isPlatformUnavailable(platform)) return platform
|
||||
|
||||
const payload = await connectPlatformControl(fetchWithFreshAuth, platform.id)
|
||||
return mergePlatformEnvironmentOverride(platform, payload.environmentConfigOverride)
|
||||
return mergePlatformEnvironmentOverride(platform, payload)
|
||||
}
|
||||
|
||||
const applyPlatformControlResponse = (platformId: string, payload: PlatformControlActionResponse): void => {
|
||||
@@ -5601,16 +5620,9 @@ export default function App(): React.JSX.Element {
|
||||
})
|
||||
setPlatformLoading(true)
|
||||
try {
|
||||
// Invalidate status requests that started before this remote session.
|
||||
platformStatusRequestSequenceRef.current += 1
|
||||
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
|
||||
window.setTimeout(() => {
|
||||
setConnectedPlatform(connected)
|
||||
@@ -5620,19 +5632,19 @@ export default function App(): React.JSX.Element {
|
||||
const exists = platforms.some((current) => current.id === connected.id)
|
||||
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 }))
|
||||
setSessionEndedAt(null)
|
||||
setSessionStatus('running')
|
||||
setStudioEntered(true)
|
||||
setPlatformLoading(false)
|
||||
setPlatformModalOpen(false)
|
||||
setRecentSessionModalOpen(false)
|
||||
setSidebarCollapsed(true)
|
||||
setLocalPcLayoutOpen(true)
|
||||
setSessionStartedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }))
|
||||
setSessionEndedAt(null)
|
||||
setSessionStatus('running')
|
||||
setStudioEntered(true)
|
||||
setPlatformLoading(false)
|
||||
setPlatformModalOpen(false)
|
||||
setRecentSessionModalOpen(false)
|
||||
setSidebarCollapsed(true)
|
||||
setLocalPcLayoutOpen(true)
|
||||
}, 2000)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
setPlatformLoading(false)
|
||||
showTitlebarNotice('플랫폼 연결 정보를 불러오지 못했습니다', 'error')
|
||||
showTitlebarNotice(error instanceof Error ? error.message : '플랫폼 연결 정보를 불러오지 못했습니다', 'error')
|
||||
setCurriculumRefreshSignal((current) => current + 1)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -236,7 +236,7 @@ interface Window {
|
||||
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
||||
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
|
||||
running: boolean
|
||||
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
||||
|
||||
@@ -30,6 +30,29 @@ export type PlatformControlTerminatedPayload = {
|
||||
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 {
|
||||
return (
|
||||
platform.state === 'locked' ||
|
||||
@@ -60,12 +83,12 @@ export async function fetchPlatformStatuses(fetchWithFreshAuth: AuthFetch, platf
|
||||
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`, {
|
||||
method: 'POST',
|
||||
})
|
||||
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> {
|
||||
|
||||
@@ -349,7 +349,7 @@ export default function ModuleListView({
|
||||
</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
|
||||
type="button"
|
||||
aria-label="실행 방법 메뉴"
|
||||
@@ -357,7 +357,7 @@ export default function ModuleListView({
|
||||
onClick={() => setFloatingActionMenuOpen((open) => !open)}
|
||||
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]',
|
||||
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(' ')}
|
||||
>
|
||||
<span className="text-[24px] font-medium leading-none">+</span>
|
||||
|
||||
@@ -6,6 +6,9 @@ export type WorkspaceMapScope = 'simulation' | 'platform'
|
||||
|
||||
export type WorkspaceMapOption = {
|
||||
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'
|
||||
name: string
|
||||
description: string
|
||||
@@ -198,25 +201,24 @@ export function WorkspaceMapPreview({
|
||||
|
||||
type WorldMapSelectorModalProps = {
|
||||
weekNumber: number
|
||||
initialScope?: 'simulation' | 'platform'
|
||||
initialScope?: WorkspaceMapScope
|
||||
simulationMaps: WorkspaceMapOption[]
|
||||
platformMaps: WorkspaceMapOption[]
|
||||
selectedMapKeys: Record<WorkspaceMapScope, string>
|
||||
selectedMapKey: string
|
||||
onSelect: (map: WorkspaceMapOption) => void
|
||||
onCreate?: () => void
|
||||
onDelete?: (map: WorkspaceMapOption) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKeys, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
|
||||
const [scope, setScope] = useState<'simulation' | 'platform'>(initialScope)
|
||||
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
|
||||
const [scope, setScope] = useState<WorkspaceMapScope>(initialScope)
|
||||
const maps = scope === 'simulation' ? simulationMaps : platformMaps
|
||||
const selectedMapKey = selectedMapKeys[scope]
|
||||
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
|
||||
const selectedMap = maps.find((map) => (map.selectionKey ?? map.key) === selectedMapKey) ?? null
|
||||
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
|
||||
|
||||
useEffect(() => {
|
||||
setPendingMap(maps.find((map) => map.key === selectedMapKey) ?? null)
|
||||
setPendingMap(maps.find((map) => (map.selectionKey ?? map.key) === selectedMapKey) ?? null)
|
||||
}, [maps, selectedMapKey])
|
||||
|
||||
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">
|
||||
<div>
|
||||
<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>
|
||||
<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" />
|
||||
@@ -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">
|
||||
{maps.map((map) => {
|
||||
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
|
||||
// runtime is chosen by the active SLAM stack, so only persisted
|
||||
// map results receive a dimensional badge.
|
||||
@@ -279,7 +282,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
|
||||
</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]" />}
|
||||
{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">
|
||||
<Trash2 className="size-3.5" />
|
||||
</span>
|
||||
|
||||
@@ -54,7 +54,6 @@ export function useWorkspaceTerminal({
|
||||
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
|
||||
const terminalSuppressQueueRef = useRef<string[]>([])
|
||||
const terminalOutputBufferRef = useRef<Record<string, string>>({})
|
||||
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
|
||||
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
|
||||
|
||||
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
|
||||
@@ -158,7 +157,6 @@ export function useWorkspaceTerminal({
|
||||
}
|
||||
terminalElement.addEventListener('contextmenu', handleTerminalContextMenu)
|
||||
terminalElement.addEventListener('mousedown', focusTerminal)
|
||||
let terminalReceivedVisibleData = Boolean(terminalOutputBufferRef.current[terminalSessionId])
|
||||
if (terminalOutputBufferRef.current[terminalSessionId]) {
|
||||
term.write(terminalOutputBufferRef.current[terminalSessionId])
|
||||
}
|
||||
@@ -175,7 +173,6 @@ export function useWorkspaceTerminal({
|
||||
|
||||
const nextData = stripInternalTerminalCommands(data)
|
||||
if (nextData) {
|
||||
terminalReceivedVisibleData = true
|
||||
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000)
|
||||
term.write(nextData)
|
||||
}
|
||||
@@ -185,7 +182,6 @@ export function useWorkspaceTerminal({
|
||||
}, terminalSessionId)
|
||||
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
|
||||
|
||||
let fallbackPromptTimer: number | null = null
|
||||
let sizeSyncTimer: number | null = null
|
||||
let startFrame: 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)
|
||||
}, 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()
|
||||
@@ -255,11 +240,9 @@ export function useWorkspaceTerminal({
|
||||
removeDataListener()
|
||||
removeSuppressListener()
|
||||
inputDisposable.dispose()
|
||||
if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer)
|
||||
if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer)
|
||||
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
|
||||
delete terminalOutputBufferRef.current[terminalSessionId]
|
||||
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
|
||||
if (remoteWorkspace) window.remoteWorkspace.terminal.stop(terminalSessionId)
|
||||
else window.localWorkspace.terminal.stop(terminalSessionId)
|
||||
term.dispose()
|
||||
|
||||
@@ -65,7 +65,7 @@ type UseWorkspaceVisualizationOptions = {
|
||||
platform?: string | null
|
||||
threeDimensional?: boolean
|
||||
savedMapDatabasePath?: string | null
|
||||
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string }>
|
||||
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string; savedMapWorldLayoutPath?: string }>
|
||||
visualizationRuntime?: 'real' | 'simulation'
|
||||
worldLayoutVersion?: number
|
||||
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
|
||||
@@ -255,7 +255,15 @@ export function useWorkspaceVisualization({
|
||||
? bridgeTarget?.host
|
||||
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
|
||||
: { 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 ?? '시작 실패')
|
||||
setVisualizationRunning(Boolean(status.running))
|
||||
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'
|
||||
|
||||
Reference in New Issue
Block a user