(fix) module list status bug..
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.1.20",
|
||||
"version": "1.1.21",
|
||||
"description": "Autonomy Studio LMS Electron app",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
|
||||
+131
-14
@@ -22,6 +22,7 @@ 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 remoteDockerStatsRetryAfter = new Map<string, number>()
|
||||
const remoteGitModuleRoots = new Map<string, string>()
|
||||
const remoteSshControlDirectory = resolve(homedir(), '.autonomy-studio', 'ssh', 'control')
|
||||
const moduleCacheRoot = resolve(homedir(), '.caches')
|
||||
@@ -1377,12 +1378,15 @@ async function collectRemoteAvailableModules(
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 30_000)
|
||||
const [output, statsOutput] = await Promise.all([
|
||||
runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 30_000),
|
||||
readRemoteDockerStatsOutput(target),
|
||||
])
|
||||
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[] }>()
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
for (const line of `${output}\n${statsOutput}`.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
const [kind, first = '', second = '', third = ''] = line.split('\t')
|
||||
if (kind === 'DOCKER' && first) {
|
||||
@@ -1501,8 +1505,10 @@ function remoteRepositoryUrl(repositoryUrl: string): string {
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise<string> {
|
||||
return new Promise<string>((resolvePromise, rejectPromise) => {
|
||||
let output = ''
|
||||
let terminated = false
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1511,7 +1517,18 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
}
|
||||
})
|
||||
const timeout = setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGTERM')
|
||||
terminated = true
|
||||
if (process.platform !== 'win32' && child.pid) {
|
||||
const processId = child.pid
|
||||
// SSH/SCP spawn a child process. Kill the whole group so a timed-out
|
||||
// transfer cannot leave an SSH child alive in the background.
|
||||
try { process.kill(-processId, 'SIGTERM') } catch { /* already exited */ }
|
||||
setTimeout(() => {
|
||||
try { process.kill(-processId, 'SIGKILL') } catch { /* already exited */ }
|
||||
}, 1_000).unref()
|
||||
} else if (!child.killed) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
|
||||
}, timeoutMs)
|
||||
|
||||
@@ -1527,6 +1544,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (terminated) return
|
||||
if (code === 0) resolvePromise(output)
|
||||
else rejectPromise(new Error(output.trim() || `${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}`))
|
||||
})
|
||||
@@ -1890,10 +1908,106 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
|
||||
))
|
||||
}
|
||||
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise<string> {
|
||||
const normalized = normalizeRemoteTarget(target)
|
||||
return runCommandOutput(
|
||||
'ssh',
|
||||
[
|
||||
'-p',
|
||||
String(normalized.port),
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=accept-new',
|
||||
'-o',
|
||||
'ConnectTimeout=5',
|
||||
'-o',
|
||||
'PreferredAuthentications=publickey',
|
||||
'-o',
|
||||
'PasswordAuthentication=no',
|
||||
'-o',
|
||||
'KbdInteractiveAuthentication=no',
|
||||
'-o',
|
||||
'ControlMaster=no',
|
||||
'-i',
|
||||
normalized.identityFile,
|
||||
`${normalized.user}@${normalized.host}`,
|
||||
command,
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
timeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
async function readRemoteDockerStatsOutput(target: RemoteWorkspaceTarget): Promise<string> {
|
||||
const normalized = normalizeRemoteTarget(target)
|
||||
const key = `${normalized.user}@${normalized.host}:${normalized.port}`
|
||||
if ((remoteDockerStatsRetryAfter.get(key) ?? 0) > Date.now()) return ''
|
||||
|
||||
try {
|
||||
const output = await runUnqueuedSshOutput(
|
||||
normalized,
|
||||
'timeout -k 1s 2s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
|
||||
4_000,
|
||||
)
|
||||
remoteDockerStatsRetryAfter.delete(key)
|
||||
return output
|
||||
} catch {
|
||||
// Do not spawn another blocked Docker client on every ten-second module
|
||||
// refresh when the Jetson Docker daemon is under load.
|
||||
remoteDockerStatsRetryAfter.set(key, Date.now() + 60_000)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function runSsh(target: RemoteWorkspaceTarget, command: string, timeoutMs?: number): Promise<void> {
|
||||
await runSshOutput(target, command, timeoutMs)
|
||||
}
|
||||
|
||||
async function copyRemoteFileToLocal(
|
||||
target: RemoteWorkspaceTarget,
|
||||
remotePath: string,
|
||||
localPath: string,
|
||||
timeoutMs = 900_000,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeRemoteTarget(target)
|
||||
const host = normalized.host.includes(':') ? `[${normalized.host}]` : normalized.host
|
||||
// A large 3D map database can take several minutes over a relayed Tailscale
|
||||
// link. Keep that file transfer outside the serialized command queue so it
|
||||
// cannot prevent module state, stop, or control requests from reaching the
|
||||
// platform in the meantime.
|
||||
await runCommand(
|
||||
'scp',
|
||||
[
|
||||
'-q',
|
||||
'-P',
|
||||
String(normalized.port),
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=accept-new',
|
||||
'-o',
|
||||
'ConnectTimeout=5',
|
||||
'-o',
|
||||
'PreferredAuthentications=publickey',
|
||||
'-o',
|
||||
'PasswordAuthentication=no',
|
||||
'-o',
|
||||
'KbdInteractiveAuthentication=no',
|
||||
'-o',
|
||||
'ControlMaster=no',
|
||||
'-i',
|
||||
normalized.identityFile,
|
||||
`${normalized.user}@${host}:${remotePath}`,
|
||||
localPath,
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
timeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
async function readRemoteSavedMapArtifacts(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
artifacts: SavedMapArtifactPath[],
|
||||
@@ -1929,25 +2043,31 @@ async function copyRemoteActiveRtabmapDatabaseToLocal(
|
||||
'destination_db.close()',
|
||||
'source_db.close()',
|
||||
].join('; ')
|
||||
const remoteCommand = [
|
||||
const createSnapshotCommand = [
|
||||
'set -e',
|
||||
`SOURCE=${quoteShellSingle(sourcePath)}`,
|
||||
'test -f "$SOURCE" || { echo "3D SLAM DB를 찾을 수 없습니다: $SOURCE" >&2; exit 1; }',
|
||||
'SNAPSHOT=$(mktemp)',
|
||||
'SNAPSHOT=$(mktemp --suffix=.db)',
|
||||
`python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`,
|
||||
'base64 -w0 "$SNAPSHOT"',
|
||||
'rm -f "$SNAPSHOT"',
|
||||
'printf "%s" "$SNAPSHOT"',
|
||||
].join('\n')
|
||||
const base64 = (await runSshOutput(target, remoteCommand, 120_000)).trim()
|
||||
if (!base64) throw new Error('3D SLAM DB 스냅샷을 읽지 못했습니다.')
|
||||
|
||||
await syncWorkspaceSavedMapArtifacts(localWorkspaceRoot, [])
|
||||
const root = expandUserPath(localWorkspaceRoot)
|
||||
if (!root) throw new Error('로컬 워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
const mapsDirectory = resolve(root, 'maps')
|
||||
const destination = resolve(mapsDirectory, destinationFileName)
|
||||
if (!destination.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 3D 맵 경로가 올바르지 않습니다.')
|
||||
await writeFile(destination, Buffer.from(base64, 'base64'))
|
||||
const snapshotPath = (await runSshOutput(target, createSnapshotCommand, 120_000)).trim()
|
||||
if (!snapshotPath.startsWith('/tmp/')) throw new Error('3D SLAM DB 스냅샷을 만들지 못했습니다.')
|
||||
const temporaryDestination = `${destination}.download-${process.pid}-${Date.now()}`
|
||||
|
||||
try {
|
||||
await copyRemoteFileToLocal(target, snapshotPath, temporaryDestination)
|
||||
await rename(temporaryDestination, destination)
|
||||
} finally {
|
||||
await rm(temporaryDestination, { force: true }).catch(() => undefined)
|
||||
await runSsh(target, `rm -f ${quoteShellSingle(snapshotPath)}`, 15_000).catch(() => undefined)
|
||||
}
|
||||
return { databasePath: `/workspace/maps/${destinationFileName}` }
|
||||
}
|
||||
|
||||
@@ -2149,9 +2269,6 @@ function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModu
|
||||
`ROOT=${quoteShellSingle(workspaceRoot)}`,
|
||||
...expandRemotePathVar('ROOT'),
|
||||
'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 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 [
|
||||
|
||||
@@ -462,7 +462,7 @@ const moduleGuidePages = [
|
||||
imageAlt: '로컬 환경 모듈 가이드',
|
||||
imageSrc: './assets/images/module-guide-local.png',
|
||||
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
|
||||
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||
parameters: [
|
||||
['기본', 'sim_mode:=true, platform:=turtlebot3, robot:=turtlebot3, level:=2d'],
|
||||
['Perception', 'sim_mode:=true'],
|
||||
@@ -474,7 +474,7 @@ const moduleGuidePages = [
|
||||
imageAlt: '원격 플랫폼 환경 모듈 가이드',
|
||||
imageSrc: './assets/images/module-guide-remote-platform.png',
|
||||
slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'],
|
||||
navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||
navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||
parameters: [
|
||||
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=2d'],
|
||||
['Perception', 'sim_mode:=false'],
|
||||
@@ -486,7 +486,7 @@ const moduleGuidePages = [
|
||||
imageAlt: '원격 3D SLAM 환경 모듈 가이드',
|
||||
imageSrc: './assets/images/module-guide-remote-3d.png',
|
||||
slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'],
|
||||
navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||
navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Map', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||
parameters: [
|
||||
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=3d']
|
||||
]
|
||||
|
||||
@@ -59,6 +59,26 @@ function parseRemoteWorkspaceTarget(targetKey: string): RemoteWorkspaceTarget |
|
||||
}
|
||||
}
|
||||
|
||||
function retainRunningModuleStats(modules: ModuleRow[], previousModules: ModuleRow[]): ModuleRow[] {
|
||||
const previousByKey = new Map(
|
||||
previousModules
|
||||
.filter((module) => module.state === 'running')
|
||||
.map((module) => [module.folderName ?? module.name, module]),
|
||||
)
|
||||
|
||||
return modules.map((module) => {
|
||||
if (module.state !== 'running') return module
|
||||
const previous = previousByKey.get(module.folderName ?? module.name)
|
||||
if (!previous) return module
|
||||
|
||||
return {
|
||||
...module,
|
||||
cpu: module.cpu ?? previous.cpu,
|
||||
memory: module.memory ?? previous.memory,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkspaceModuleList({
|
||||
clonePathPreview,
|
||||
curriculumModules,
|
||||
@@ -164,8 +184,11 @@ export function useWorkspaceModuleList({
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
setModuleListError(null)
|
||||
const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
|
||||
setAvailableModules(modules)
|
||||
storeRemoteModuleCache(moduleCacheKey, modules)
|
||||
setAvailableModules((current) => {
|
||||
const nextModules = retainRunningModuleStats(modules, current)
|
||||
storeRemoteModuleCache(moduleCacheKey, nextModules)
|
||||
return nextModules
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,8 +196,11 @@ export function useWorkspaceModuleList({
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
setModuleListError(null)
|
||||
const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
|
||||
setAvailableModules(modules)
|
||||
storeRemoteModuleCache(moduleCacheKey, modules)
|
||||
setAvailableModules((current) => {
|
||||
const nextModules = retainRunningModuleStats(modules, current)
|
||||
storeRemoteModuleCache(moduleCacheKey, nextModules)
|
||||
return nextModules
|
||||
})
|
||||
} catch (error) {
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
console.error('[modules] list failed', error)
|
||||
|
||||
Reference in New Issue
Block a user