feat(lms): add Docker setup guidance and improve remote workspace handling

This commit is contained in:
2026-09-09 16:59:47 +09:00
parent 104c083995
commit 54ef5836b1
10 changed files with 273 additions and 21 deletions
+34 -12
View File
@@ -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 extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]): void => {
setSourceSettingsDraft((settings) => ({ ...settings, [key]: value }))
}
@@ -5778,6 +5799,7 @@ export default function App(): React.JSX.Element {
return (
<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]">
<TitleBar
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,
)
}
+1 -1
View File
@@ -187,7 +187,7 @@ export default function Sidebar({
</button>
)}
{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">
{visibleRunningSessions.map((session) => (
<div key={session.id} className="relative border-b border-white/10 last:border-b-0">
+5
View File
@@ -257,6 +257,10 @@ interface Window {
}>
}
localWorkspace: {
docker: {
status: () => Promise<{ status: 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'; platform: string }>
openGuide: () => Promise<void>
}
moduleFiles: Window['moduleFiles']
modules: Window['modules']
rosGraph: {
@@ -302,6 +306,7 @@ interface Window {
maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: 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 }>
}
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 { 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<string, 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 }))
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 : []