feat(lms): add Docker setup guidance and improve remote workspace handling
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.23",
|
"version": "1.1.26",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
+52
-4
@@ -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 type { WebContents } from 'electron'
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import * as pty from 'node-pty'
|
import * as pty from 'node-pty'
|
||||||
@@ -2140,8 +2141,8 @@ async function readRemoteDockerStatsOutput(target: RemoteWorkspaceTarget): Promi
|
|||||||
try {
|
try {
|
||||||
const output = await runUnqueuedSshOutput(
|
const output = await runUnqueuedSshOutput(
|
||||||
normalized,
|
normalized,
|
||||||
'timeout -k 1s 2s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
|
'timeout -k 1s 8s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
|
||||||
4_000,
|
15_000,
|
||||||
)
|
)
|
||||||
remoteDockerStatsRetryAfter.delete(key)
|
remoteDockerStatsRetryAfter.delete(key)
|
||||||
return output
|
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(
|
async function copyRemoteActiveRtabmapDatabaseToLocal(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
localWorkspaceRoot: string | null | undefined,
|
localWorkspaceRoot: string | null | undefined,
|
||||||
@@ -5612,7 +5654,8 @@ async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, modu
|
|||||||
' esac',
|
' esac',
|
||||||
'done'
|
'done'
|
||||||
].join('\n')
|
].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<string, string> = {}
|
const files: Record<string, string> = {}
|
||||||
const folders: string[] = []
|
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:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules))
|
||||||
ipcMain.handle('local-workspace:modules:docker-images', () => listLocalDockerImages())
|
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: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: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) => {
|
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) => (
|
ipcMain.handle('remote-workspace:maps:copy-active-rtabmap-database', (_event, target: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, destinationPath: string) => (
|
||||||
copyRemoteActiveRtabmapDatabaseToLocal(target, localWorkspaceRoot, destinationPath)
|
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[]) => (
|
ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => (
|
||||||
syncRemoteSavedMapArtifacts(target, artifacts)
|
syncRemoteSavedMapArtifacts(target, artifacts)
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -182,6 +182,10 @@ contextBridge.exposeInMainWorld('visualization', {
|
|||||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('localWorkspace', {
|
contextBridge.exposeInMainWorld('localWorkspace', {
|
||||||
|
docker: {
|
||||||
|
status: () => ipcRenderer.invoke('local-workspace:docker:status'),
|
||||||
|
openGuide: () => ipcRenderer.invoke('local-workspace:docker:guide'),
|
||||||
|
},
|
||||||
moduleFiles: {
|
moduleFiles: {
|
||||||
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName),
|
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),
|
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: {
|
maps: {
|
||||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
|
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),
|
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)
|
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
|
|||||||
+35
-13
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { io } from 'socket.io-client'
|
import { io } from 'socket.io-client'
|
||||||
import type { Socket } from 'socket.io-client'
|
import type { Socket } from 'socket.io-client'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
|
import DockerSetupNotice from './components/DockerSetupNotice'
|
||||||
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
|
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
|
||||||
import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||||
import TitleBar from './components/TitleBar'
|
import TitleBar from './components/TitleBar'
|
||||||
@@ -1220,6 +1221,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSavedSlamMaps()
|
void loadSavedSlamMaps()
|
||||||
}, [loadSavedSlamMaps])
|
}, [loadSavedSlamMaps])
|
||||||
|
const mapSaveInProgressRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleViewerMessage = (event: MessageEvent) => {
|
const handleViewerMessage = (event: MessageEvent) => {
|
||||||
const payload = event.data as {
|
const payload = event.data as {
|
||||||
@@ -1290,25 +1292,41 @@ function LocalPcWorkspaceMockup({
|
|||||||
const savedMapDatabasePath = payload.map?.databasePath ?? null
|
const savedMapDatabasePath = payload.map?.databasePath ?? null
|
||||||
const savedMapPreviewPgm = payload.map?.previewPgm ?? null
|
const savedMapPreviewPgm = payload.map?.previewPgm ?? null
|
||||||
if (payload.type !== 'map-saved' || typeof yamlPath !== 'string' || typeof pgmPath !== 'string') return
|
if (payload.type !== 'map-saved' || typeof yamlPath !== 'string' || typeof pgmPath !== 'string') return
|
||||||
|
if (mapSaveInProgressRef.current) {
|
||||||
|
onTitlebarNotice('이전 맵을 저장하고 있습니다. 완료 후 다시 저장해 주세요.', 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mapSaveInProgressRef.current = true
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
let databasePath = savedMapDatabasePath
|
let databasePath = savedMapDatabasePath
|
||||||
const savesThreeDimensionalMap = remoteWorkspace
|
let databaseUploadId: string | null = null
|
||||||
&& visualizationBridgeLocation === 'local'
|
const remoteDatabase = remoteWorkspace && (
|
||||||
&& selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||||
if (savesThreeDimensionalMap && !databasePath) {
|
|| (visualizationBridgeLocation === 'platform' && Boolean(databasePath))
|
||||||
const databaseFileName = yamlPath.replace(/\.yaml$/i, '.db')
|
|
||||||
const copied = await window.remoteWorkspace.maps.copyActiveRtabmapDatabaseToLocal(
|
|
||||||
getRemoteWorkspaceTarget(),
|
|
||||||
localVisualizationWorkspaceRoot,
|
|
||||||
databaseFileName,
|
|
||||||
)
|
)
|
||||||
databasePath = copied.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(),
|
||||||
|
upload.uploadUrl,
|
||||||
|
visualizationBridgeLocation === 'platform' ? savedMapDatabasePath : null,
|
||||||
|
)
|
||||||
|
databaseUploadId = upload.databaseUploadId
|
||||||
}
|
}
|
||||||
const sourceArtifacts = [
|
const sourceArtifacts = [
|
||||||
{ name: 'yaml' as const, path: yamlPath },
|
{ name: 'yaml' as const, path: yamlPath },
|
||||||
{ name: 'pgm' as const, path: pgmPath },
|
{ 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.
|
// A remote ROS session can still use a bridge running on this PC.
|
||||||
// Read saved artifacts from the bridge host, not merely from the
|
// Read saved artifacts from the bridge host, not merely from the
|
||||||
@@ -1331,6 +1349,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
form.set('pgmPath', pgmPath)
|
form.set('pgmPath', pgmPath)
|
||||||
if (savedMapPreviewPgm) form.set('previewPgm', savedMapPreviewPgm)
|
if (savedMapPreviewPgm) form.set('previewPgm', savedMapPreviewPgm)
|
||||||
if (databasePath) form.set('databasePath', databasePath)
|
if (databasePath) form.set('databasePath', databasePath)
|
||||||
|
if (databaseUploadId) form.set('databaseUploadId', databaseUploadId)
|
||||||
artifacts.forEach((artifact) => {
|
artifacts.forEach((artifact) => {
|
||||||
const binary = atob(artifact.base64)
|
const binary = atob(artifact.base64)
|
||||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||||
@@ -1352,6 +1371,8 @@ function LocalPcWorkspaceMockup({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[studio] saved SLAM map record failed', error)
|
console.warn('[studio] saved SLAM map record failed', error)
|
||||||
onTitlebarNotice('맵 저장 정보 등록에 실패했습니다.', 'error')
|
onTitlebarNotice('맵 저장 정보 등록에 실패했습니다.', 'error')
|
||||||
|
} finally {
|
||||||
|
mapSaveInProgressRef.current = false
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
@@ -2365,7 +2386,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedModule) return
|
if (!selectedModule || (activeIdeActivity !== 'workspace' && activeIdeActivity !== 'search')) return
|
||||||
|
|
||||||
const refreshOnFocus = (): void => {
|
const refreshOnFocus = (): void => {
|
||||||
void loadModuleFiles(selectedModule)
|
void loadModuleFiles(selectedModule)
|
||||||
@@ -2373,7 +2394,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
|
|
||||||
window.addEventListener('focus', refreshOnFocus)
|
window.addEventListener('focus', refreshOnFocus)
|
||||||
return () => window.removeEventListener('focus', refreshOnFocus)
|
return () => window.removeEventListener('focus', refreshOnFocus)
|
||||||
}, [selectedModule, clonePathPreview])
|
}, [selectedModule, clonePathPreview, activeIdeActivity, remoteWorkspace])
|
||||||
const updateSourceSetting = <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]): void => {
|
const updateSourceSetting = <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]): void => {
|
||||||
setSourceSettingsDraft((settings) => ({ ...settings, [key]: value }))
|
setSourceSettingsDraft((settings) => ({ ...settings, [key]: value }))
|
||||||
}
|
}
|
||||||
@@ -5778,6 +5799,7 @@ export default function App(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={appRootRef} className="relative h-screen w-screen overflow-hidden bg-[#0a0b0e]">
|
<div ref={appRootRef} className="relative h-screen w-screen overflow-hidden bg-[#0a0b0e]">
|
||||||
|
<DockerSetupNotice />
|
||||||
<div className="flex h-full w-full min-w-[1180px] flex-col overflow-hidden bg-[#0a0b0e]">
|
<div className="flex h-full w-full min-w-[1180px] flex-col overflow-hidden bg-[#0a0b0e]">
|
||||||
<TitleBar
|
<TitleBar
|
||||||
user={authSession?.user ?? null}
|
user={authSession?.user ?? null}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Check, Copy, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
type DockerResult = Awaited<ReturnType<Window['localWorkspace']['docker']['status']>>
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
missing: 'Docker 명령을 찾을 수 없습니다.',
|
||||||
|
permission: '현재 사용자의 Docker 접근 권한을 확인해주세요.',
|
||||||
|
unavailable: 'Docker 데몬에 연결할 수 없습니다.',
|
||||||
|
error: 'Docker 상태를 확인하지 못했습니다.',
|
||||||
|
ready: 'Docker를 사용할 수 있습니다.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DockerSetupNotice(): React.JSX.Element | null {
|
||||||
|
const [result, setResult] = useState<DockerResult | null>(null)
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [actionError, setActionError] = useState('')
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(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<void> {
|
||||||
|
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(
|
||||||
|
<dialog ref={dialogRef} aria-labelledby="docker-setup-title" onCancel={() => 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">
|
||||||
|
<header className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
||||||
|
<h2 id="docker-setup-title" className="text-[14px] font-bold">로컬 Docker 설정</h2>
|
||||||
|
<button type="button" autoFocus title="나중에" aria-label="나중에" onClick={() => setDismissed(true)} className="flex h-7 w-7 cursor-pointer items-center justify-center rounded hover:bg-white/10"><X size={16} /></button>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-4 px-5 py-4 text-[13px] leading-relaxed">
|
||||||
|
<p className="font-semibold">{messages[result.status]}</p>
|
||||||
|
<p className="text-white/65">Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.</p>
|
||||||
|
{result.status === 'missing' ? (
|
||||||
|
<p>공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.</p>
|
||||||
|
) : <>
|
||||||
|
{linux && result.status === 'permission' && <p>일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.</p>}
|
||||||
|
{linux && result.status === 'unavailable' && <p>일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.</p>}
|
||||||
|
<div className="relative rounded-md bg-black/25 p-3 pr-12">
|
||||||
|
<pre className="whitespace-pre-wrap break-all text-[12px]">{command}</pre>
|
||||||
|
<button type="button" title={copied ? '복사됨' : '명령어 복사'} aria-label={copied ? '복사됨' : '명령어 복사'}
|
||||||
|
className="absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded hover:bg-white/10"
|
||||||
|
onClick={() => { void navigator.clipboard.writeText(command).then(() => { setCopied(true); setActionError('') }).catch(() => setActionError('복사하지 못했습니다. 명령어를 직접 선택해 복사해주세요.')) }}>
|
||||||
|
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
<button type="button" className="inline-flex cursor-pointer items-center gap-2 text-[#9dbdff] hover:underline"
|
||||||
|
onClick={() => { void window.localWorkspace.docker.openGuide().catch(() => setActionError('설치 안내: https://docs.docker.com/engine/install/')) }}>
|
||||||
|
<ExternalLink size={14} />Docker 공식 설치 안내
|
||||||
|
</button>
|
||||||
|
{actionError && <p role="status" className="break-words text-[#f2b86b]">{actionError}</p>}
|
||||||
|
</div>
|
||||||
|
<footer className="flex justify-end gap-2 border-t border-white/10 px-5 py-3">
|
||||||
|
<button type="button" onClick={() => setDismissed(true)} className="h-8 cursor-pointer rounded-md border border-white/20 px-3 text-[12px] hover:bg-white/10">나중에</button>
|
||||||
|
<button type="button" disabled={busy} onClick={() => void retry()} className="flex h-8 cursor-pointer items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[12px] disabled:cursor-wait disabled:opacity-50 hover:bg-[#1d4ed8]">
|
||||||
|
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />{busy ? '확인 중' : '다시 확인'}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</dialog>, document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -187,7 +187,7 @@ export default function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{collapsed && sessionPopoverOpen && (
|
{collapsed && sessionPopoverOpen && (
|
||||||
<div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#111520] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]">
|
<div className="absolute bottom-0 left-[62px] z-50 w-[240px] rounded-md border border-white/10 bg-[#111520] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]">
|
||||||
<div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1">
|
<div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1">
|
||||||
{visibleRunningSessions.map((session) => (
|
{visibleRunningSessions.map((session) => (
|
||||||
<div key={session.id} className="relative border-b border-white/10 last:border-b-0">
|
<div key={session.id} className="relative border-b border-white/10 last:border-b-0">
|
||||||
|
|||||||
Vendored
+5
@@ -257,6 +257,10 @@ interface Window {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
localWorkspace: {
|
localWorkspace: {
|
||||||
|
docker: {
|
||||||
|
status: () => Promise<{ status: 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'; platform: string }>
|
||||||
|
openGuide: () => Promise<void>
|
||||||
|
}
|
||||||
moduleFiles: Window['moduleFiles']
|
moduleFiles: Window['moduleFiles']
|
||||||
modules: Window['modules']
|
modules: Window['modules']
|
||||||
rosGraph: {
|
rosGraph: {
|
||||||
@@ -302,6 +306,7 @@ interface Window {
|
|||||||
maps: {
|
maps: {
|
||||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
||||||
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
|
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 }>
|
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
modules: {
|
modules: {
|
||||||
|
|||||||
@@ -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 { ModuleRow } from './types'
|
||||||
import type { EditorFilePath } from './workspaceFiles'
|
import type { EditorFilePath } from './workspaceFiles'
|
||||||
import type { NewFileDraftState, TreeClipboardState, TreeContextMenuState } from './useWorkspaceTreeState'
|
import type { NewFileDraftState, TreeClipboardState, TreeContextMenuState } from './useWorkspaceTreeState'
|
||||||
@@ -114,11 +114,28 @@ export function useWorkspaceFileActions({
|
|||||||
selectedGitTarget,
|
selectedGitTarget,
|
||||||
refreshGitStatuses
|
refreshGitStatuses
|
||||||
}: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult {
|
}: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult {
|
||||||
|
const pendingFileReads = useRef(new Map<string, Promise<void>>())
|
||||||
|
|
||||||
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
|
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
|
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
|
||||||
try {
|
try {
|
||||||
const result = remoteWorkspace
|
const result = target
|
||||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
? await window.remoteWorkspace.moduleFiles.read(target, module.name, module.folderName)
|
||||||
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||||
const nextFiles = result.files
|
const nextFiles = result.files
|
||||||
const nextFolders = result.folders.length ? result.folders : []
|
const nextFolders = result.folders.length ? result.folders : []
|
||||||
|
|||||||
Reference in New Issue
Block a user