feat: download ROS bridge images from R2 and show startup progress

This commit is contained in:
2026-09-15 10:50:25 +09:00
parent 54ef5836b1
commit 978b782606
11 changed files with 162 additions and 37 deletions
+5
View File
@@ -406,6 +406,7 @@ type WorkspaceVisualizationHost = {
available: boolean
running: boolean
busy: boolean
statusMessage: string
viewerUrl: string
onStart: () => Promise<void>
onTerminate: () => Promise<void>
@@ -1052,6 +1053,7 @@ function LocalPcWorkspaceMockup({
setVisualizationStack,
visualizationRunning,
visualizationBusy,
visualizationStatusMessage,
visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
@@ -2258,6 +2260,7 @@ function LocalPcWorkspaceMockup({
available: visualizationStacks.length > 0,
running: visualizationRunning,
busy: visualizationBusy,
statusMessage: visualizationStatusMessage,
viewerUrl: visualizationViewerUrl,
onStart: () => startVisualizationBridge(),
onTerminate: async () => {
@@ -2285,6 +2288,7 @@ function LocalPcWorkspaceMockup({
textDim,
textStrong,
visualizationBusy,
visualizationStatusMessage,
visualizationRunning,
visualizationStacks.length,
visualizationViewerUrl,
@@ -6249,6 +6253,7 @@ export default function App(): React.JSX.Element {
available={workspaceVisualizationHost.available}
running={workspaceVisualizationHost.running}
busy={workspaceVisualizationHost.busy}
statusMessage={workspaceVisualizationHost.statusMessage}
viewerUrl={workspaceVisualizationHost.viewerUrl}
onDragStart={(event) => workspaceVisualizationHostRef.current?.onDragStart(event)}
onToggleLarge={() => workspaceVisualizationHostRef.current?.onToggleLarge()}
@@ -4,6 +4,8 @@ import { createPortal } from 'react-dom'
type DockerResult = Awaited<ReturnType<Window['localWorkspace']['docker']['status']>>
const DOCKER_NOTICE_SEEN_KEY = 'lms:docker-setup-notice-seen'
const messages = {
missing: 'Docker 명령을 찾을 수 없습니다.',
permission: '현재 사용자의 Docker 접근 권한을 확인해주세요.',
@@ -14,7 +16,13 @@ const messages = {
export default function DockerSetupNotice(): React.JSX.Element | null {
const [result, setResult] = useState<DockerResult | null>(null)
const [dismissed, setDismissed] = useState(false)
const [dismissed, setDismissed] = useState(() => {
try {
return localStorage.getItem(DOCKER_NOTICE_SEEN_KEY) === 'true'
} catch {
return false
}
})
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const [actionError, setActionError] = useState('')
@@ -22,6 +30,7 @@ export default function DockerSetupNotice(): React.JSX.Element | null {
const retryPending = useRef(false)
useEffect(() => {
if (dismissed) return
const docker = window.localWorkspace?.docker
if (!docker) return
let active = true
@@ -31,11 +40,17 @@ export default function DockerSetupNotice(): React.JSX.Element | null {
if (active) setResult({ status: 'error', platform: '' })
})
return () => { active = false }
}, [])
}, [dismissed])
const visible = Boolean(result && result.status !== 'ready' && !dismissed)
useEffect(() => {
if (visible) dialogRef.current?.showModal()
if (!visible || !dialogRef.current) return
dialogRef.current.showModal()
try {
localStorage.setItem(DOCKER_NOTICE_SEEN_KEY, 'true')
} catch {
// Keep the notice dismissible when browser storage is unavailable.
}
}, [visible])
useEffect(() => {
+2 -2
View File
@@ -243,7 +243,7 @@ interface Window {
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
message?: string
}>
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => Promise<{
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, onProgress?: (message: string) => void) => Promise<{
ok: boolean
running: boolean
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
@@ -354,7 +354,7 @@ interface Window {
}
visualization: {
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, onProgress?: (message: string) => void) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
}
git: {
@@ -109,7 +109,7 @@ function WorkspaceSettingsView({
'relative ml-3 rounded-md border px-3 py-2 text-[11px] font-semibold leading-relaxed',
'before:absolute before:-top-1 before:left-4 before:size-2 before:rotate-45 before:border-l before:border-t',
dark
? 'border-amber-300/20 bg-amber-300/[0.08] text-amber-100/80 before:border-amber-300/20 before:bg-[#252525]'
? 'border-amber-300/20 bg-[#363328] text-amber-100/80 before:border-amber-300/20 before:bg-[#363328]'
: 'border-amber-300/50 bg-amber-50 text-amber-900/75 before:border-amber-300/50 before:bg-amber-50'
].join(' ')}>
NVIDIA Container Toolkit과 Docker GPU 런타임이 준비된 PC에서만 실행됩니다. 준비되지 않은 경우 GPU 가속을 끄세요.
@@ -1,4 +1,4 @@
import { CirclePlay, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
import { CirclePlay, LoaderCircle, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
import type React from 'react'
import { useEffect, useState } from 'react'
@@ -16,6 +16,7 @@ type WorkspaceVisualizationPanelProps = {
available: boolean
running: boolean
busy: boolean
statusMessage: string
viewerUrl: string
onDragStart: (event: React.PointerEvent<HTMLDivElement>) => void
onToggleLarge: () => void
@@ -38,6 +39,7 @@ export default function WorkspaceVisualizationPanel({
available,
running,
busy,
statusMessage,
viewerUrl,
onDragStart,
onToggleLarge,
@@ -154,10 +156,10 @@ export default function WorkspaceVisualizationPanel({
type="button"
disabled={busy}
onClick={onStart}
className="absolute left-1/2 top-1/2 inline-flex h-28 w-40 -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 text-[15px] font-bold text-white/56 transition hover:text-white disabled:cursor-default disabled:opacity-45 disabled:hover:text-white/56"
className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default"
>
<CirclePlay size={40} />
{busy ? '처리 중' : '실행하기'}
{busy ? <LoaderCircle size={40} className="animate-spin" aria-hidden="true" /> : <CirclePlay size={40} />}
<span role="status" aria-live="polite">{busy ? statusMessage : '실행하기'}</span>
</button>
<div className="absolute inset-x-0 top-[70%] px-5 text-center">
<p className="text-[13px] font-bold text-white/72">지도와 차량 상태가 표시됩니다.</p>
@@ -77,6 +77,7 @@ type UseWorkspaceVisualizationResult = {
setVisualizationStack: (stack: VisualizationStack) => void
visualizationRunning: boolean
visualizationBusy: boolean
visualizationStatusMessage: string
visualizationViewerUrl: string
nav2VisualizationMode: Nav2VisualizationMode
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
@@ -117,7 +118,7 @@ export function useWorkspaceVisualization({
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>(fallbackStack)
const [nav2VisualizationMode, setNav2VisualizationMode] = useState<Nav2VisualizationMode>('slam')
const [visualizationRunning, setVisualizationRunning] = useState(false)
const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationStatusMessage, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => {
@@ -253,7 +254,7 @@ export function useWorkspaceVisualization({
const bridgeSavedMapDatabasePath = preparedMap?.savedMapDatabasePath ?? savedMapDatabasePath
const status = bridgeRunsOnPlatform
? bridgeTarget?.host
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork, setVisualizationStatusMessage)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.start(
visualizationRuntimeStack,
@@ -263,6 +264,7 @@ export function useWorkspaceVisualization({
rosNetwork,
bridgeSavedMapDatabasePath,
preparedMap?.savedMapWorldLayoutPath,
setVisualizationStatusMessage,
)
if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running))
@@ -310,6 +312,7 @@ export function useWorkspaceVisualization({
setVisualizationStack: selectVisualizationStack,
visualizationRunning,
visualizationBusy,
visualizationStatusMessage,
visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,