diff --git a/package.json b/package.json index 6bd04a9..c50fb58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.23", + "version": "1.1.26", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/src/main/dockerStatus.test.ts b/src/main/dockerStatus.test.ts new file mode 100644 index 0000000..4c10bed --- /dev/null +++ b/src/main/dockerStatus.test.ts @@ -0,0 +1,19 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { classifyDockerError } from './dockerStatus' + +test('missing CLI is distinguished from an unavailable daemon', () => { + assert.equal(classifyDockerError('ENOENT', 'spawn docker ENOENT'), 'missing') + assert.equal(classifyDockerError(1, 'Cannot connect to the Docker daemon'), 'unavailable') + assert.equal(classifyDockerError(1, 'dial unix /run/docker.sock: connect: connection refused'), 'unavailable') +}) + +test('permission errors take precedence over connection errors', () => { + assert.equal(classifyDockerError(1, 'Cannot connect: permission denied'), 'permission') + assert.equal(classifyDockerError('EACCES', 'spawn docker EACCES'), 'permission') +}) + +test('timeouts and unknown failures do not imply Docker needs installing', () => { + assert.equal(classifyDockerError(null, 'Command failed: docker info'), 'error') + assert.equal(classifyDockerError(1, 'context not found'), 'error') +}) diff --git a/src/main/dockerStatus.ts b/src/main/dockerStatus.ts new file mode 100644 index 0000000..6a06619 --- /dev/null +++ b/src/main/dockerStatus.ts @@ -0,0 +1,27 @@ +import { execFile } from 'node:child_process' + +export type DockerStatus = 'ready' | 'missing' | 'permission' | 'unavailable' | 'error' + +export function classifyDockerError(code: string | number | null | undefined, message: string): DockerStatus { + if (code === 'ENOENT') return 'missing' + if (/permission denied|access is denied|EACCES/i.test(message)) return 'permission' + if (/cannot connect|connection refused|is the docker daemon running/i.test(message)) return 'unavailable' + return 'error' +} + +export function checkDockerStatus(): Promise<{ status: DockerStatus; platform: string }> { + return new Promise((resolve) => { + execFile('docker', ['info', '--format', '{{.ServerVersion}}'], { + timeout: 5000, + maxBuffer: 256 * 1024, + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + }, (error, stdout, stderr) => { + resolve({ + status: error + ? classifyDockerError(error.code, `${error.message}\n${stderr}`) + : stdout.trim() ? 'ready' : 'error', + platform: process.platform, + }) + }) + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 6774fc0..bb173ce 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,5 @@ -import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen } from 'electron' +import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' +import { checkDockerStatus } from './dockerStatus' import type { WebContents } from 'electron' import { spawn } from 'child_process' import * as pty from 'node-pty' @@ -2140,8 +2141,8 @@ async function readRemoteDockerStatsOutput(target: RemoteWorkspaceTarget): Promi 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, + 'timeout -k 1s 8s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true', + 15_000, ) remoteDockerStatsRetryAfter.delete(key) return output @@ -2218,6 +2219,47 @@ async function readRemoteSavedMapArtifacts( })) } +async function uploadRemoteRtabmapDatabase( + targetInput: RemoteWorkspaceTarget, + uploadUrl: string, + databasePath?: string | null, +): Promise<{ ok: boolean }> { + const target = normalizeRemoteTarget(targetInput) + const url = new URL(uploadUrl) + if (url.protocol !== 'https:' || !url.hostname.endsWith('.r2.cloudflarestorage.com') + || url.username || url.password || url.port || !url.searchParams.has('X-Amz-Signature')) { + throw new Error('R2 업로드 URL이 올바르지 않습니다.') + } + const fileName = databasePath ? savedMapArtifactFileName('database', databasePath) : 'rtabmap-active.db' + const sourcePath = `${target.workspaceRoot}/maps/${fileName}` + const snapshotScript = [ + 'import sqlite3, sys', + 'source = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True)', + 'destination = sqlite3.connect(sys.argv[2])', + 'source.backup(destination)', + 'destination.close()', + 'source.close()', + ].join('; ') + const script = [ + 'set -e', + `SOURCE=${quoteShellSingle(sourcePath)}`, + 'test -f "$SOURCE"', + 'command -v curl >/dev/null', + 'SNAPSHOT=$(mktemp --suffix=.db)', + 'trap \'rm -f "$SNAPSHOT"\' EXIT', + `timeout -k 2s 120s python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`, + `curl --fail --silent --show-error --connect-timeout 15 --max-time 900 --header 'Content-Type: application/octet-stream' --upload-file "$SNAPSHOT" --output /dev/null ${quoteShellSingle(uploadUrl)}`, + ].join('\n') + try { + // Only commands cross SSH; the database bytes go from the platform to R2. + await runUnqueuedSshOutput(target, script, 1_100_000) + } catch { + // The command contains a signed URL; do not expose it in renderer errors. + throw new Error('플랫폼의 3D DB R2 업로드에 실패했습니다. DB 파일, curl 설치 및 인터넷 연결을 확인해 주세요.') + } + return { ok: true } +} + async function copyRemoteActiveRtabmapDatabaseToLocal( targetInput: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, @@ -5612,7 +5654,8 @@ async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, modu ' esac', 'done' ].join('\n') - const output = await runSshOutput(target, script, 30000) + // Source transfers must not hold up module start/stop on the control queue. + const output = await runUnqueuedSshOutput(target, script, 30000) const files: Record = {} const folders: string[] = [] @@ -6198,6 +6241,8 @@ function registerLocalWorkspaceHandlers(): void { }) ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) ipcMain.handle('local-workspace:modules:docker-images', () => listLocalDockerImages()) + ipcMain.handle('local-workspace:docker:status', () => checkDockerStatus()) + ipcMain.handle('local-workspace:docker:guide', () => shell.openExternal('https://docs.docker.com/engine/install/')) ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph()) ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('local-workspace:modules:create-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleScaffoldInput | null) => { @@ -6258,6 +6303,9 @@ function registerLocalWorkspaceHandlers(): void { ipcMain.handle('remote-workspace:maps:copy-active-rtabmap-database', (_event, target: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, destinationPath: string) => ( copyRemoteActiveRtabmapDatabaseToLocal(target, localWorkspaceRoot, destinationPath) )) + ipcMain.handle('remote-workspace:maps:upload-rtabmap-database', (_event, target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => ( + uploadRemoteRtabmapDatabase(target, uploadUrl, databasePath) + )) ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ( syncRemoteSavedMapArtifacts(target, artifacts) )) diff --git a/src/preload/index.ts b/src/preload/index.ts index 7fde938..41cfa8c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -182,6 +182,10 @@ contextBridge.exposeInMainWorld('visualization', { stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile) }) contextBridge.exposeInMainWorld('localWorkspace', { + docker: { + status: () => ipcRenderer.invoke('local-workspace:docker:status'), + openGuide: () => ipcRenderer.invoke('local-workspace:docker:guide'), + }, moduleFiles: { read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName), image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:image', moduleName, path, workspaceRoot, folderName), @@ -275,6 +279,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', { maps: { readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts), copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath), + uploadRtabmapDatabase: (target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => ipcRenderer.invoke('remote-workspace:maps:upload-rtabmap-database', target, uploadUrl, databasePath), sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts) }, modules: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 15ab630..46276bf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { io } from 'socket.io-client' import type { Socket } from 'socket.io-client' import Sidebar from './components/Sidebar' +import DockerSetupNotice from './components/DockerSetupNotice' import PlatformLoadingOverlay from './components/PlatformLoadingOverlay' import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals' import TitleBar from './components/TitleBar' @@ -1220,6 +1221,7 @@ function LocalPcWorkspaceMockup({ useEffect(() => { void loadSavedSlamMaps() }, [loadSavedSlamMaps]) + const mapSaveInProgressRef = useRef(false) useEffect(() => { const handleViewerMessage = (event: MessageEvent) => { const payload = event.data as { @@ -1290,25 +1292,41 @@ function LocalPcWorkspaceMockup({ const savedMapDatabasePath = payload.map?.databasePath ?? null const savedMapPreviewPgm = payload.map?.previewPgm ?? null if (payload.type !== 'map-saved' || typeof yamlPath !== 'string' || typeof pgmPath !== 'string') return + if (mapSaveInProgressRef.current) { + onTitlebarNotice('이전 맵을 저장하고 있습니다. 완료 후 다시 저장해 주세요.', 'info') + return + } + mapSaveInProgressRef.current = true void (async () => { try { let databasePath = savedMapDatabasePath - const savesThreeDimensionalMap = remoteWorkspace - && visualizationBridgeLocation === 'local' - && selectedWorldMap.runtimeKind === 'PLATFORM_3D' - if (savesThreeDimensionalMap && !databasePath) { - const databaseFileName = yamlPath.replace(/\.yaml$/i, '.db') - const copied = await window.remoteWorkspace.maps.copyActiveRtabmapDatabaseToLocal( + let databaseUploadId: string | null = null + const remoteDatabase = remoteWorkspace && ( + selectedWorldMap.runtimeKind === 'PLATFORM_3D' + || (visualizationBridgeLocation === 'platform' && Boolean(databasePath)) + ) + if (remoteDatabase) { + databasePath = databasePath || yamlPath.replace(/\.yaml$/i, '.db') + onTitlebarNotice('플랫폼의 3D 맵을 업로드하고 있습니다.', 'info') + const prepared = await fetch(`${LMS_API_BASE_URL}/studio/maps/database-upload`, { + method: 'POST', + headers: { Authorization: `Bearer ${await getFreshAccessToken()}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ courseId: courseKey, week: weekNumber, databasePath }), + }) + if (!prepared.ok) throw new Error(`database_upload_prepare_failed_${prepared.status}`) + const upload = await prepared.json() as { uploadUrl: string; databaseUploadId: string } + if (!upload.uploadUrl || !upload.databaseUploadId) throw new Error('database_upload_response_invalid') + await window.remoteWorkspace.maps.uploadRtabmapDatabase( getRemoteWorkspaceTarget(), - localVisualizationWorkspaceRoot, - databaseFileName, + upload.uploadUrl, + visualizationBridgeLocation === 'platform' ? savedMapDatabasePath : null, ) - databasePath = copied.databasePath + databaseUploadId = upload.databaseUploadId } const sourceArtifacts = [ { name: 'yaml' as const, path: yamlPath }, { name: 'pgm' as const, path: pgmPath }, - ...(databasePath ? [{ name: 'database' as const, path: databasePath }] : []), + ...(databasePath && !databaseUploadId ? [{ name: 'database' as const, path: databasePath }] : []), ] // A remote ROS session can still use a bridge running on this PC. // Read saved artifacts from the bridge host, not merely from the @@ -1331,6 +1349,7 @@ function LocalPcWorkspaceMockup({ form.set('pgmPath', pgmPath) if (savedMapPreviewPgm) form.set('previewPgm', savedMapPreviewPgm) if (databasePath) form.set('databasePath', databasePath) + if (databaseUploadId) form.set('databaseUploadId', databaseUploadId) artifacts.forEach((artifact) => { const binary = atob(artifact.base64) const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) @@ -1352,6 +1371,8 @@ function LocalPcWorkspaceMockup({ } catch (error) { console.warn('[studio] saved SLAM map record failed', error) onTitlebarNotice('맵 저장 정보 등록에 실패했습니다.', 'error') + } finally { + mapSaveInProgressRef.current = false } })() } @@ -2365,7 +2386,7 @@ function LocalPcWorkspaceMockup({ } } useEffect(() => { - if (!selectedModule) return + if (!selectedModule || (activeIdeActivity !== 'workspace' && activeIdeActivity !== 'search')) return const refreshOnFocus = (): void => { void loadModuleFiles(selectedModule) @@ -2373,7 +2394,7 @@ function LocalPcWorkspaceMockup({ window.addEventListener('focus', refreshOnFocus) return () => window.removeEventListener('focus', refreshOnFocus) - }, [selectedModule, clonePathPreview]) + }, [selectedModule, clonePathPreview, activeIdeActivity, remoteWorkspace]) const updateSourceSetting = (key: Key, value: StudioSourceSettings[Key]): void => { setSourceSettingsDraft((settings) => ({ ...settings, [key]: value })) } @@ -5778,6 +5799,7 @@ export default function App(): React.JSX.Element { return (
+
> + +const messages = { + missing: 'Docker 명령을 찾을 수 없습니다.', + permission: '현재 사용자의 Docker 접근 권한을 확인해주세요.', + unavailable: 'Docker 데몬에 연결할 수 없습니다.', + error: 'Docker 상태를 확인하지 못했습니다.', + ready: 'Docker를 사용할 수 있습니다.', +} + +export default function DockerSetupNotice(): React.JSX.Element | null { + const [result, setResult] = useState(null) + const [dismissed, setDismissed] = useState(false) + const [busy, setBusy] = useState(false) + const [copied, setCopied] = useState(false) + const [actionError, setActionError] = useState('') + const dialogRef = useRef(null) + const retryPending = useRef(false) + + useEffect(() => { + const docker = window.localWorkspace?.docker + if (!docker) return + let active = true + void docker.status().then((value) => { + if (active) setResult(value) + }).catch(() => { + if (active) setResult({ status: 'error', platform: '' }) + }) + return () => { active = false } + }, []) + + const visible = Boolean(result && result.status !== 'ready' && !dismissed) + useEffect(() => { + if (visible) dialogRef.current?.showModal() + }, [visible]) + + useEffect(() => { + if (!copied) return + const timer = window.setTimeout(() => setCopied(false), 1500) + return () => window.clearTimeout(timer) + }, [copied]) + + if (!visible || !result) return null + const linux = result.platform === 'linux' + const command = linux && result.status === 'permission' + ? 'sudo groupadd -f docker\nsudo usermod -aG docker "$(id -un)"' + : linux && result.status === 'unavailable' + ? 'sudo systemctl start docker\ndocker info' + : 'docker --version\ndocker context show\ndocker info' + + async function retry(): Promise { + if (retryPending.current) return + retryPending.current = true + setBusy(true) + setActionError('') + setCopied(false) + try { + setResult(await window.localWorkspace.docker.status()) + } catch { + setResult({ status: 'error', platform: result?.platform ?? '' }) + } finally { + retryPending.current = false + setBusy(false) + } + } + + return createPortal( + setDismissed(true)} + className="fixed inset-0 m-auto max-h-[calc(100dvh-32px)] w-[520px] max-w-[calc(100vw-32px)] overflow-y-auto rounded-lg border border-white/10 bg-[#252525] p-0 text-white shadow-2xl backdrop:bg-black/60"> +
+

로컬 Docker 설정

+ +
+
+

{messages[result.status]}

+

Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.

+ {result.status === 'missing' ? ( +

공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.

+ ) : <> + {linux && result.status === 'permission' &&

일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.

} + {linux && result.status === 'unavailable' &&

일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.

} +
+
{command}
+ +
+ } + + {actionError &&

{actionError}

} +
+
+ + +
+
, document.body, + ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 14871bc..129521b 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -187,7 +187,7 @@ export default function Sidebar({ )} {collapsed && sessionPopoverOpen && ( -
+
{visibleRunningSessions.map((session) => (
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 948c963..b5df336 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -257,6 +257,10 @@ interface Window { }> } localWorkspace: { + docker: { + status: () => Promise<{ status: 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'; platform: string }> + openGuide: () => Promise + } moduleFiles: Window['moduleFiles'] modules: Window['modules'] rosGraph: { @@ -302,6 +306,7 @@ interface Window { maps: { readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise> copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }> + uploadRtabmapDatabase: (target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => Promise<{ ok: boolean }> sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }> } modules: { diff --git a/src/renderer/src/features/workspace/useWorkspaceFileActions.ts b/src/renderer/src/features/workspace/useWorkspaceFileActions.ts index 789bc66..0ddc596 100644 --- a/src/renderer/src/features/workspace/useWorkspaceFileActions.ts +++ b/src/renderer/src/features/workspace/useWorkspaceFileActions.ts @@ -1,4 +1,4 @@ -import type { Dispatch, SetStateAction } from 'react' +import { useRef, type Dispatch, type SetStateAction } from 'react' import type { ModuleRow } from './types' import type { EditorFilePath } from './workspaceFiles' import type { NewFileDraftState, TreeClipboardState, TreeContextMenuState } from './useWorkspaceTreeState' @@ -114,11 +114,28 @@ export function useWorkspaceFileActions({ selectedGitTarget, refreshGitStatuses }: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult { + const pendingFileReads = useRef(new Map>()) + const loadModuleFiles = async (module: ModuleRow): Promise => { + const target = remoteWorkspace ? getRemoteWorkspaceTarget() : null + const key = JSON.stringify([target, clonePathPreview, module.name, module.folderName]) + const pending = pendingFileReads.current.get(key) + if (pending) return pending + + const request = readModuleFiles(module, target) + pendingFileReads.current.set(key, request) + try { + await request + } finally { + pendingFileReads.current.delete(key) + } + } + + const readModuleFiles = async (module: ModuleRow, target: RemoteWorkspaceTarget | null): Promise => { setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true })) try { - const result = remoteWorkspace - ? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName) + const result = target + ? await window.remoteWorkspace.moduleFiles.read(target, module.name, module.folderName) : await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName) const nextFiles = result.files const nextFolders = result.folders.length ? result.folders : []