(fix) bug remote workspace ssh..
This commit is contained in:
+1
-1
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+134
-122
@@ -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 {
|
|
||||||
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 {
|
} 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)
|
||||||
@@ -5630,9 +5642,9 @@ export default function App(): React.JSX.Element {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user