version up!

This commit is contained in:
2026-08-04 13:12:35 +09:00
parent c7de438d57
commit 9cbf4ac7e9
15 changed files with 662 additions and 39 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.1.4", "version": "1.1.5",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+274 -6
View File
@@ -121,6 +121,7 @@ type CachedModuleFiles = {
} }
type CachedModuleInfo = { type CachedModuleInfo = {
id?: string
state: 'running' | 'stopped' | 'uninstalled' state: 'running' | 'stopped' | 'uninstalled'
sortOrder: number sortOrder: number
category: string category: string
@@ -131,6 +132,9 @@ type CachedModuleInfo = {
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
sourceType?: string | null sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
dockerImage?: string | null
packageName?: string | null
launchFile?: string | null
parameters: string[] parameters: string[]
cpu: number | null cpu: number | null
memory: number | null memory: number | null
@@ -138,6 +142,7 @@ type CachedModuleInfo = {
} }
type CourseModuleInput = { type CourseModuleInput = {
id?: string
sortOrder?: number | null sortOrder?: number | null
category: string category: string
name: string name: string
@@ -145,6 +150,18 @@ type CourseModuleInput = {
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
sourceType?: string | null sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
dockerImage?: string | null
packageName?: string | null
launchFile?: string | null
}
type CustomModuleScaffoldInput = {
name: string
category: string
folderName: string
dockerImage?: string | null
packageName: string
launchFile: string
} }
type ModulePrebuiltArtifact = { type ModulePrebuiltArtifact = {
@@ -364,6 +381,173 @@ function isGazeboNav2ModuleFolder(folderName?: string | null): boolean {
return /^\[lms-nav2-gazebo-[^\]]+\].+_v[^/]+_ws$/.test(folderName?.trim() ?? '') return /^\[lms-nav2-gazebo-[^\]]+\].+_v[^/]+_ws$/.test(folderName?.trim() ?? '')
} }
function isCustomNav2ModuleFolder(folderName?: string | null): boolean {
return /^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/]+_ws$/.test(folderName?.trim() ?? '')
}
function validateCustomModuleScaffold(input: CustomModuleScaffoldInput): CustomModuleScaffoldInput {
const name = input.name?.trim()
const category = input.category?.trim()
const folderName = input.folderName?.trim()
const packageName = input.packageName?.trim()
const launchFile = input.launchFile?.trim()
const dockerImage = input.dockerImage?.trim() || null
if (!name || !category || !folderName || !packageName || !launchFile) throw new Error('커스텀 모듈 정보가 올바르지 않습니다.')
if (!isCustomNav2ModuleFolder(folderName) || !/^[a-z][a-z0-9_]*$/.test(packageName) || !/^[a-zA-Z0-9_.-]+\.launch\.py$/.test(launchFile)) {
throw new Error('커스텀 모듈의 패키지명 또는 Launch 파일명이 올바르지 않습니다.')
}
if (dockerImage && (!/^[a-zA-Z0-9_./:@-]+$/.test(dockerImage) || dockerImage.length > 180)) {
throw new Error('실행 이미지명이 올바르지 않습니다.')
}
return { name, category, folderName, packageName, launchFile, dockerImage }
}
function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record<string, string> {
const module = validateCustomModuleScaffold(input)
const executable = `${module.packageName}_node`
const nodePath = `${module.packageName}/node.py`
return {
'.autonomy-studio-module.json': `${JSON.stringify({
sourceType: 'CUSTOM',
name: module.name,
category: module.category,
dockerImage: module.dockerImage,
packageName: module.packageName,
launchFile: module.launchFile,
}, null, 2)}\n`,
'.gitignore': 'build/\ninstall/\nlog/\n__pycache__/\n*.py[cod]\n',
'package.xml': [
'<?xml version="1.0"?>',
'<package format="3">',
` <name>${module.packageName}</name>`,
' <version>0.0.1</version>',
` <description>${module.name}</description>`,
' <maintainer email="student@autonomy-studio.local">Autonomy Studio Student</maintainer>',
' <license>Apache-2.0</license>',
' <buildtool_depend>ament_python</buildtool_depend>',
' <exec_depend>rclpy</exec_depend>',
' <exec_depend>launch</exec_depend>',
' <exec_depend>launch_ros</exec_depend>',
' <export><build_type>ament_python</build_type></export>',
'</package>',
'',
].join('\n'),
'setup.cfg': `[develop]\nscript_dir=$base/lib/${module.packageName}\n[install]\ninstall_scripts=$base/lib/${module.packageName}\n`,
'setup.py': [
'from setuptools import setup',
'',
`package_name = '${module.packageName}'`,
'',
'setup(',
' name=package_name,',
" version='0.0.1',",
` packages=['${module.packageName}'],`,
` data_files=[('share/ament_index/resource_index/packages', ['resource/${module.packageName}']), ('share/${module.packageName}/launch', ['launch/${module.launchFile}']), ('share/${module.packageName}', ['package.xml'])],`,
" install_requires=['setuptools'],",
' zip_safe=True,',
" maintainer='Autonomy Studio Student',",
" maintainer_email='student@autonomy-studio.local',",
` description='${module.name}',`,
" license='Apache-2.0',",
` entry_points={{'console_scripts': ['${executable} = ${module.packageName}.node:main']}},`,
')',
'',
].join('\n'),
[`resource/${module.packageName}`]: '',
[`${module.packageName}/__init__.py`]: '',
[nodePath]: [
'import rclpy',
'from rclpy.node import Node',
'',
'class CustomModuleNode(Node):',
' def __init__(self):',
` super().__init__('${module.packageName}')`,
` self.get_logger().info('${module.name} started')`,
'',
'def main(args=None):',
' rclpy.init(args=args)',
' node = CustomModuleNode()',
' try:',
' rclpy.spin(node)',
' finally:',
' node.destroy_node()',
' rclpy.shutdown()',
'',
"if __name__ == '__main__':",
' main()',
'',
].join('\n'),
[`launch/${module.launchFile}`]: [
'from launch import LaunchDescription',
'from launch_ros.actions import Node',
'',
'def generate_launch_description():',
' return LaunchDescription([',
` Node(package='${module.packageName}', executable='${executable}', name='${module.packageName}', output='screen'),`,
' ])',
'',
].join('\n'),
}
}
async function createCustomWorkspaceModule(
workspaceRoot: string | null | undefined,
input: CustomModuleScaffoldInput,
): Promise<{ ok: boolean; workspacePath: string }> {
const module = validateCustomModuleScaffold(input)
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
const moduleRoot = resolve(resolvedWorkspaceRoot, module.folderName)
const files = buildCustomModuleFiles(module)
await mkdir(moduleRoot, { recursive: true })
for (const [relativePath, content] of Object.entries(files)) {
const targetPath = resolve(moduleRoot, relativePath)
if (targetPath !== moduleRoot && !targetPath.startsWith(`${moduleRoot}/`)) throw new Error('허용되지 않은 모듈 파일 경로입니다.')
await mkdir(dirname(targetPath), { recursive: true })
await writeFile(targetPath, content, { encoding: 'utf8', flag: 'wx' }).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'EEXIST') throw error
})
}
await runCommand('git', ['init', '--initial-branch=main'], moduleRoot, undefined, 15_000).catch(() => undefined)
return { ok: true, workspacePath: moduleRoot }
}
async function createRemoteCustomWorkspaceModule(
targetInput: RemoteWorkspaceTarget,
input: CustomModuleScaffoldInput,
): Promise<{ ok: boolean; workspacePath: string }> {
const target = normalizeRemoteTarget(targetInput)
const module = validateCustomModuleScaffold(input)
const files = buildCustomModuleFiles(module)
const payload = encodeBase64(JSON.stringify({ folderName: module.folderName, files }))
const output = await runSshOutput(target, [
'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
...expandRemotePathVar('ROOT'),
`PAYLOAD=$(printf %s ${quoteShellSingle(payload)} | base64 -d)`,
'python3 - "$ROOT" "$PAYLOAD" <<\'PY\'',
'import json, os, sys',
'root = os.path.abspath(os.path.expanduser(sys.argv[1]))',
'payload = json.loads(sys.argv[2])',
'folder = payload["folderName"]',
'target = os.path.abspath(os.path.join(root, folder))',
'if target == root or not target.startswith(root + os.sep): raise SystemExit("invalid module path")',
'os.makedirs(target, exist_ok=True)',
'for relative, content in payload["files"].items():',
' path = os.path.abspath(os.path.join(target, relative))',
' if not path.startswith(target + os.sep): raise SystemExit("invalid module file path")',
' os.makedirs(os.path.dirname(path), exist_ok=True)',
' try:',
' with open(path, "x", encoding="utf-8") as fp: fp.write(content)',
' except FileExistsError:',
' pass',
'print(target)',
'PY',
`git -C "$ROOT/${module.folderName}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || git -C "$ROOT/${module.folderName}" init -b main >/dev/null`,
].join('\n'), 30_000)
return { ok: true, workspacePath: output.trim().split('\n')[0] || remotePathJoin(target.workspaceRoot, module.folderName) }
}
function resolveNav2RuntimeImage(runtimeImages?: RuntimeImageArtifact[] | null, folderName?: string | null): string { function resolveNav2RuntimeImage(runtimeImages?: RuntimeImageArtifact[] | null, folderName?: string | null): string {
const gazeboModule = isGazeboNav2ModuleFolder(folderName) const gazeboModule = isGazeboNav2ModuleFolder(folderName)
const gazeboImage = runtimeImages const gazeboImage = runtimeImages
@@ -384,6 +568,16 @@ function resolveNav2RuntimeImage(runtimeImages?: RuntimeImageArtifact[] | null,
return nav2Image || nav2DockerImage return nav2Image || nav2DockerImage
} }
async function resolveCustomModuleRuntimeImage(moduleRoot: string, fallbackImage: string): Promise<string> {
try {
const config = JSON.parse(await readFile(resolve(moduleRoot, '.autonomy-studio-module.json'), 'utf8')) as { dockerImage?: unknown }
const image = typeof config.dockerImage === 'string' ? config.dockerImage.trim() : ''
return image && /^[a-zA-Z0-9_./:@-]+$/.test(image) ? image : fallbackImage
} catch {
return fallbackImage
}
}
async function resolveRemoteNav2RuntimeImage( async function resolveRemoteNav2RuntimeImage(
runtimeImages?: RuntimeImageArtifact[] | null, runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<string> { ): Promise<string> {
@@ -398,6 +592,25 @@ async function resolveRemoteNav2RuntimeImage(
throw new Error('원격 플랫폼 NAV2 런타임 이미지가 설정되지 않았습니다.') throw new Error('원격 플랫폼 NAV2 런타임 이미지가 설정되지 않았습니다.')
} }
async function resolveRemoteCustomModuleRuntimeImage(
target: RemoteWorkspaceTarget,
folderName: string,
fallbackImage: string,
): Promise<string> {
const output = await runSshOutput(target, [
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
...expandRemotePathVar('ROOT'),
`CONFIG="$ROOT/${folderName}/.autonomy-studio-module.json"`,
'if [ -f "$CONFIG" ]; then python3 - "$CONFIG" <<\'PY\'',
'import json, re, sys',
'image = json.load(open(sys.argv[1], encoding="utf-8")).get("dockerImage")',
'print(image.strip() if isinstance(image, str) and re.fullmatch(r"[A-Za-z0-9_./:@-]+", image.strip()) else "")',
'PY',
'fi',
].join('\n'), 8_000).catch(() => '')
return output.trim() || fallbackImage
}
function isAllowedContainerName(containerKey: string): boolean { function isAllowedContainerName(containerKey: string): boolean {
return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(containerKey) return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(containerKey)
} }
@@ -492,6 +705,7 @@ async function collectAvailableModules(
const rosParams = installed && workspacePath ? await readModuleRosParams(workspacePath) : '' const rosParams = installed && workspacePath ? await readModuleRosParams(workspacePath) : ''
return { return {
id: module.id,
state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled', state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled',
sortOrder: module.sortOrder, sortOrder: module.sortOrder,
category, category,
@@ -500,8 +714,11 @@ async function collectAvailableModules(
sourcePath, sourcePath,
workspacePath, workspacePath,
templateRepositoryUrl: module.templateRepositoryUrl ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null,
sourceType: module.sourceType ?? null, sourceType: module.sourceType ?? null,
prebuiltArtifacts: module.prebuiltArtifacts ?? null, prebuiltArtifacts: module.prebuiltArtifacts ?? null,
dockerImage: module.dockerImage ?? null,
packageName: module.packageName ?? null,
launchFile: module.launchFile ?? null,
parameters: splitRosParams(rosParams), parameters: splitRosParams(rosParams),
cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null,
memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null,
@@ -567,6 +784,7 @@ async function collectRemoteAvailableModules(
const dockerStat = dockerInfo ? dockerStats.get(dockerInfo.containerKey) : null const dockerStat = dockerInfo ? dockerStats.get(dockerInfo.containerKey) : null
return { return {
id: module.id,
state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled', state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled',
sortOrder: module.sortOrder, sortOrder: module.sortOrder,
category: module.category || parsed.category, category: module.category || parsed.category,
@@ -575,6 +793,11 @@ async function collectRemoteAvailableModules(
sourcePath: module.templateRepositoryUrl ?? '', sourcePath: module.templateRepositoryUrl ?? '',
workspacePath: installed?.path ?? null, workspacePath: installed?.path ?? null,
templateRepositoryUrl: module.templateRepositoryUrl ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null,
sourceType: module.sourceType ?? null,
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
dockerImage: module.dockerImage ?? null,
packageName: module.packageName ?? null,
launchFile: module.launchFile ?? null,
parameters: installed?.parameters ?? [], parameters: installed?.parameters ?? [],
cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null,
memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null,
@@ -2667,6 +2890,16 @@ function buildNav2DockerRunScript(): string {
'done', 'done',
'set -- "${filtered_args[@]}"', 'set -- "${filtered_args[@]}"',
'', '',
'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then',
' MODULE_CONFIG="$MODULE_ROOT/$MODULE_FOLDER/.autonomy-studio-module.json"',
' test -f "$MODULE_CONFIG" || { echo "Custom module configuration is missing: $MODULE_FOLDER" >&2; exit 64; }',
' read -r PACKAGE LAUNCH < <(python3 - "$MODULE_CONFIG" <<\'PY\'',
'import json, sys',
'config = json.load(open(sys.argv[1], encoding="utf-8"))',
'print(config["packageName"], config["launchFile"])',
'PY',
' )',
'else',
'case "$MODULE_FOLDER" in', 'case "$MODULE_FOLDER" in',
' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;',
' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;', ' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;',
@@ -2689,6 +2922,7 @@ function buildNav2DockerRunScript(): string {
' *nav2-system*) PACKAGE=nav2_turtlebot_system; LAUNCH=system.launch.py ;;', ' *nav2-system*) PACKAGE=nav2_turtlebot_system; LAUNCH=system.launch.py ;;',
' *) echo "Unknown Nav2 module: $MODULE_FOLDER" >&2; exit 64 ;;', ' *) echo "Unknown Nav2 module: $MODULE_FOLDER" >&2; exit 64 ;;',
'esac', 'esac',
'fi',
'', '',
'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* ]]; then', 'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* ]]; then',
' mkdir -p "$MODULE_ROOT/maps"', ' mkdir -p "$MODULE_ROOT/maps"',
@@ -2742,6 +2976,15 @@ function buildNav2DockerBuildScript(): string {
'IMAGE=$3', 'IMAGE=$3',
'ROS_DOMAIN_ID_VALUE=${4:-0}', 'ROS_DOMAIN_ID_VALUE=${4:-0}',
'', '',
'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then',
' MODULE_CONFIG="$MODULE_ROOT/$MODULE_FOLDER/.autonomy-studio-module.json"',
' test -f "$MODULE_CONFIG" || { echo "Custom module configuration is missing: $MODULE_FOLDER" >&2; exit 64; }',
' TARGET=$(python3 - "$MODULE_CONFIG" <<\'PY\'',
'import json, sys',
'print(json.load(open(sys.argv[1], encoding="utf-8"))["packageName"])',
'PY',
' )',
'else',
'case "$MODULE_FOLDER" in', 'case "$MODULE_FOLDER" in',
' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;', ' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;',
' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;', ' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;',
@@ -2764,6 +3007,7 @@ function buildNav2DockerBuildScript(): string {
' *nav2-system*) TARGET=nav2_turtlebot_system ;;', ' *nav2-system*) TARGET=nav2_turtlebot_system ;;',
' *) echo "Unknown Nav2 module: $MODULE_FOLDER" >&2; exit 64 ;;', ' *) echo "Unknown Nav2 module: $MODULE_FOLDER" >&2; exit 64 ;;',
'esac', 'esac',
'fi',
'', '',
'MODULE_PATH="$MODULE_ROOT/$MODULE_FOLDER"', 'MODULE_PATH="$MODULE_ROOT/$MODULE_FOLDER"',
'test -d "$MODULE_PATH/src" || { echo "Module source is missing: $MODULE_FOLDER" >&2; exit 1; }', 'test -d "$MODULE_PATH/src" || { echo "Module source is missing: $MODULE_FOLDER" >&2; exit 1; }',
@@ -3075,6 +3319,10 @@ async function startWorkspaceModule(
await ensureNav2RuntimeAssets() await ensureNav2RuntimeAssets()
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
const scriptPath = resolveNav2DockerRunScript() const scriptPath = resolveNav2DockerRunScript()
const runtimeImage = await resolveCustomModuleRuntimeImage(
moduleRoot,
resolveNav2RuntimeImage(runtimeImages, targetFolderName),
)
await runCommand( await runCommand(
'bash', 'bash',
[ [
@@ -3082,7 +3330,7 @@ async function startWorkspaceModule(
dockerInfo.containerKey, dockerInfo.containerKey,
resolvedWorkspaceRoot, resolvedWorkspaceRoot,
targetFolderName, targetFolderName,
resolveNav2RuntimeImage(runtimeImages, targetFolderName), runtimeImage,
String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0', String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0',
...splitRosParams(rosParams) ...splitRosParams(rosParams)
], ],
@@ -3149,13 +3397,17 @@ async function buildWorkspaceModule(
await prepareRuntimeImages(runtimeImages) await prepareRuntimeImages(runtimeImages)
if (isNav2ModuleFolder(targetFolderName)) { if (isNav2ModuleFolder(targetFolderName)) {
await ensureNav2RuntimeAssets() await ensureNav2RuntimeAssets()
const runtimeImage = await resolveCustomModuleRuntimeImage(
moduleRoot,
resolveNav2RuntimeImage(runtimeImages, targetFolderName),
)
await runCommand( await runCommand(
'bash', 'bash',
[ [
resolveNav2DockerBuildScript(), resolveNav2DockerBuildScript(),
resolvedWorkspaceRoot, resolvedWorkspaceRoot,
targetFolderName, targetFolderName,
resolveNav2RuntimeImage(runtimeImages, targetFolderName), runtimeImage,
String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0' String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'
], ],
undefined, undefined,
@@ -3220,7 +3472,11 @@ async function startRemoteWorkspaceModule(
const nav2Module = isNav2ModuleFolder(targetFolderName) const nav2Module = isNav2ModuleFolder(targetFolderName)
const runtimeImage = nav2Module const runtimeImage = nav2Module
? await resolveRemoteNav2RuntimeImage(runtimeImages) ? await resolveRemoteCustomModuleRuntimeImage(
target,
targetFolderName,
await resolveRemoteNav2RuntimeImage(runtimeImages),
)
: null : null
const runtimeRoot = nav2Module const runtimeRoot = nav2Module
? await ensureRemoteNav2RuntimeAssets(target) ? await ensureRemoteNav2RuntimeAssets(target)
@@ -3343,7 +3599,11 @@ async function buildRemoteWorkspaceModule(
if (isNav2ModuleFolder(targetFolderName)) { if (isNav2ModuleFolder(targetFolderName)) {
const runtimeRoot = await ensureRemoteNav2RuntimeAssets(target) const runtimeRoot = await ensureRemoteNav2RuntimeAssets(target)
const runtimeImage = await resolveRemoteNav2RuntimeImage(runtimeImages) const runtimeImage = await resolveRemoteCustomModuleRuntimeImage(
target,
targetFolderName,
await resolveRemoteNav2RuntimeImage(runtimeImages),
)
await runSsh(target, [ await runSsh(target, [
'set -e', 'set -e',
`RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`,
@@ -4231,6 +4491,10 @@ 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: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) => {
if (!input) throw new Error('커스텀 모듈 정보가 없습니다.')
return createCustomWorkspaceModule(workspaceRoot, input)
})
ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages))
@@ -4262,6 +4526,10 @@ function registerLocalWorkspaceHandlers(): void {
}) })
ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules)) ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules))
ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
ipcMain.handle('remote-workspace:modules:create-custom', (_event, target: RemoteWorkspaceTarget, input?: CustomModuleScaffoldInput | null) => {
if (!input) throw new Error('커스텀 모듈 정보가 없습니다.')
return createRemoteCustomWorkspaceModule(target, input)
})
ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages))
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName)) ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName))
+2
View File
@@ -170,6 +170,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
modules: { modules: {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules), list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input),
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName), delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName),
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages), build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages),
@@ -236,6 +237,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
modules: { modules: {
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules), list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options), install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input),
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages), build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages),
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName), stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName),
+2 -1
View File
@@ -420,7 +420,8 @@ let remoteNavigationSplatScene = null;
let remoteNavigationSplatTransform = null; let remoteNavigationSplatTransform = null;
let remoteNavigationSplatEnabled = false; let remoteNavigationSplatEnabled = false;
let remoteNavigationSplatLoading = null; let remoteNavigationSplatLoading = null;
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.4822845855746674, 1.3107954323633903, -0.8021416967574936); // 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다.
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.7322845855746674, 1.3107954323633903, -0.8021416967574936);
const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469); const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469);
const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587); const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587);
const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null; const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null;
+163 -19
View File
@@ -7,7 +7,7 @@ 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 PlatformLoadingOverlay from './components/PlatformLoadingOverlay' import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
import { ControlExtensionConfirmModal, 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'
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig' import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession' import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
@@ -17,6 +17,7 @@ import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal' import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
import MaterialsModal from './features/curriculum/MaterialsModal' import MaterialsModal from './features/curriculum/MaterialsModal'
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi' import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi'
import type { CustomModuleInput } from './features/workspace/ModuleListView'
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types' import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
import { getContextHelpSteps } from './features/help/contextHelp' import { getContextHelpSteps } from './features/help/contextHelp'
import AiGuideView from './features/AiGuideView' import AiGuideView from './features/AiGuideView'
@@ -391,6 +392,7 @@ function LocalPcWorkspaceMockup({
onRequestControlExtension, onRequestControlExtension,
onTitlebarNotice, onTitlebarNotice,
onRegisterStopRunningModules, onRegisterStopRunningModules,
onRegisterRunningModuleNames,
canReleaseControl, canReleaseControl,
canExtendControl, canExtendControl,
controlEndsAt, controlEndsAt,
@@ -421,6 +423,7 @@ function LocalPcWorkspaceMockup({
onRequestControlExtension: () => void onRequestControlExtension: () => void
onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void
onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void
onRegisterRunningModuleNames?: (handler: (() => string[]) | null) => void
canReleaseControl: boolean canReleaseControl: boolean
canExtendControl: boolean canExtendControl: boolean
controlEndsAt?: string | null controlEndsAt?: string | null
@@ -818,13 +821,41 @@ function LocalPcWorkspaceMockup({
if (visualizationStack !== 'nav2') setVisualizationStack('nav2') if (visualizationStack !== 'nav2') setVisualizationStack('nav2')
if (nav2VisualizationMode !== 'navigation') setNav2VisualizationMode('navigation') if (nav2VisualizationMode !== 'navigation') setNav2VisualizationMode('navigation')
}, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack]) }, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack])
const runtimeModules = useMemo(() => resolveRuntimeModules( const [customModules, setCustomModules] = useState<NonNullable<CurriculumWeek['modules']>>(() => (
curriculumWeek.modules, (curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM')
remoteWorkspace ))
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase())) const [deletedCustomModuleIds, setDeletedCustomModuleIds] = useState<Set<string>>(() => new Set())
: activeEnvironmentProfile, useEffect(() => {
remoteWorkspace ? 'platform' : 'local' setCustomModules((current) => {
), [activeEnvironmentProfile, curriculumWeek.modules, remoteWorkspace, visualizationProfiles]) const fromCurriculum = (curriculumWeek.modules ?? []).filter((module) => (
module.sourceType === 'CUSTOM' && !deletedCustomModuleIds.has(module.id)
))
const byId = new Map(current.map((module) => [module.id, module]))
fromCurriculum.forEach((module) => byId.set(module.id, module))
return Array.from(byId.values())
})
}, [curriculumWeek.modules, deletedCustomModuleIds])
const curriculumModulesWithCustom = useMemo(() => {
const baseModules = (curriculumWeek.modules ?? []).filter((module) => (
module.sourceType !== 'CUSTOM' || !deletedCustomModuleIds.has(module.id)
))
const knownIds = new Set(baseModules.map((module) => module.id))
return [...baseModules, ...customModules.filter((module) => !knownIds.has(module.id))]
}, [curriculumWeek.modules, customModules, deletedCustomModuleIds])
const runtimeModules = useMemo(() => {
const resolvedModules = resolveRuntimeModules(
curriculumModulesWithCustom,
remoteWorkspace
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
: activeEnvironmentProfile,
remoteWorkspace ? 'platform' : 'local'
) ?? []
const knownIds = new Set(resolvedModules.map((module) => module.id))
return [
...resolvedModules,
...customModules.filter((module) => !knownIds.has(module.id)),
].sort((left, right) => left.sortOrder - right.sortOrder)
}, [activeEnvironmentProfile, curriculumModulesWithCustom, customModules, remoteWorkspace, visualizationProfiles])
const moduleRuntimeImages = useMemo(() => ( const moduleRuntimeImages = useMemo(() => (
resolveModuleRuntimeImages(visualizationProfiles, remoteWorkspace, activeRuntimeImages) resolveModuleRuntimeImages(visualizationProfiles, remoteWorkspace, activeRuntimeImages)
), [activeRuntimeImages, remoteWorkspace, visualizationProfiles]) ), [activeRuntimeImages, remoteWorkspace, visualizationProfiles])
@@ -1875,6 +1906,12 @@ function LocalPcWorkspaceMockup({
}, },
onTitlebarNotice onTitlebarNotice
}) })
useEffect(() => {
onRegisterRunningModuleNames?.(() => availableModules
.filter((module) => module.state === 'running')
.map((module) => module.name))
return () => onRegisterRunningModuleNames?.(null)
}, [availableModules, onRegisterRunningModuleNames])
const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => { const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => {
if (map.custom && map.layout && !remoteWorkspace) { if (map.custom && map.layout && !remoteWorkspace) {
await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout) await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout)
@@ -1921,6 +1958,16 @@ function LocalPcWorkspaceMockup({
} else { } else {
await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName) await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName)
} }
if (module.sourceType === 'CUSTOM') {
if (!module.id) throw new Error('커스텀 모듈 식별자가 없습니다.')
const response = await fetchWithFreshAuth(
`${LMS_API_BASE_URL}/curriculum/custom-modules/${encodeURIComponent(module.id)}?courseId=${encodeURIComponent(courseKey)}`,
{ method: 'DELETE' },
)
if (!response.ok) throw new Error(await response.text())
setCustomModules((modules) => modules.filter((item) => item.id !== module.id))
setDeletedCustomModuleIds((ids) => new Set([...ids, module.id!]))
}
if (options.deleteRepository) { if (options.deleteRepository) {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/repositories`, { const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/repositories`, {
method: 'DELETE', method: 'DELETE',
@@ -2519,6 +2566,51 @@ function LocalPcWorkspaceMockup({
openEditorFile(editorPath) openEditorFile(editorPath)
setActiveIdeActivity('workspace') setActiveIdeActivity('workspace')
} }
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ courseId: courseKey, week: weekNumber, ...input }),
})
const payload = await response.json().catch(() => null) as {
message?: string
id?: string
sortOrder?: number
category?: string
name?: string
folderName?: string
dockerImage?: string | null
packageName?: string
launchFile?: string
} | null
if (!response.ok || !payload?.id || !payload.folderName || !payload.name || !payload.category) {
throw new Error(payload?.message ?? '모듈을 추가하지 못했습니다.')
}
const customModule = {
id: payload.id,
sortOrder: payload.sortOrder ?? 10_000 + customModules.length,
category: payload.category,
name: payload.name,
folderName: payload.folderName,
templateRepositoryUrl: null,
sourceType: 'CUSTOM',
prebuiltArtifacts: [],
defaultParameters: [],
dockerImage: payload.dockerImage ?? null,
packageName: payload.packageName ?? input.packageName,
launchFile: payload.launchFile ?? input.launchFile,
} as const
if (remoteWorkspace) {
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule)
} else {
await window.localWorkspace.modules.createCustom(clonePathPreview, customModule)
}
setCustomModules((current) => [
...current,
{ ...customModule, sortOrder: payload.sortOrder ?? 10_000 + current.length },
])
onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
}
const controlEndTime = controlEndsAt ? new Date(controlEndsAt).getTime() : Number.NaN const controlEndTime = controlEndsAt ? new Date(controlEndsAt).getTime() : Number.NaN
const controlRemainingMs = Number.isNaN(controlEndTime) ? null : Math.max(0, controlEndTime - controlCountdownNow) const controlRemainingMs = Number.isNaN(controlEndTime) ? null : Math.max(0, controlEndTime - controlCountdownNow)
const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000) const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000)
@@ -3067,6 +3159,7 @@ function LocalPcWorkspaceMockup({
onOpenWorldEditor={() => { onOpenWorldEditor={() => {
setWorldMapSelectorOpen(true) setWorldMapSelectorOpen(true)
}} }}
onCreateCustomModule={createCustomModule}
/> />
</div> </div>
)} )}
@@ -3182,6 +3275,7 @@ export default function App(): React.JSX.Element {
const previousWeekStateRef = useRef<Record<number, CurriculumWeek['state']>>({}) const previousWeekStateRef = useRef<Record<number, CurriculumWeek['state']>>({})
const controlExpiryHandledRef = useRef<Set<string>>(new Set()) const controlExpiryHandledRef = useRef<Set<string>>(new Set())
const stopRunningWorkspaceModulesRef = useRef<(() => Promise<void>) | null>(null) const stopRunningWorkspaceModulesRef = useRef<(() => Promise<void>) | null>(null)
const getRunningWorkspaceModuleNamesRef = useRef<(() => string[]) | null>(null)
const restoredStudioSession = restoredStudioSessionRef.current const restoredStudioSession = restoredStudioSessionRef.current
const [curriculumWeeks, setCurriculumWeeks] = useState<CurriculumWeek[]>([]) const [curriculumWeeks, setCurriculumWeeks] = useState<CurriculumWeek[]>([])
const [courseKey, setCourseKey] = useState('default-course') const [courseKey, setCourseKey] = useState('default-course')
@@ -3238,6 +3332,10 @@ export default function App(): React.JSX.Element {
const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined) const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined)
const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null) const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null)
const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null) const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null)
const [moduleShutdownConfirm, setModuleShutdownConfirm] = useState<{
action: 'disconnect' | 'release'
moduleNames: string[]
} | null>(null)
const [controlExtensionConfirmOpen, setControlExtensionConfirmOpen] = useState(false) const [controlExtensionConfirmOpen, setControlExtensionConfirmOpen] = useState(false)
const [controlExtensionSubmitting, setControlExtensionSubmitting] = useState(false) const [controlExtensionSubmitting, setControlExtensionSubmitting] = useState(false)
const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null) const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null)
@@ -3999,6 +4097,11 @@ export default function App(): React.JSX.Element {
setSessionConfirmAction(null) setSessionConfirmAction(null)
return return
} }
if (moduleShutdownConfirm) {
event.preventDefault()
setModuleShutdownConfirm(null)
return
}
if (runningPlatformCloseTarget) { if (runningPlatformCloseTarget) {
event.preventDefault() event.preventDefault()
setRunningPlatformCloseTarget(null) setRunningPlatformCloseTarget(null)
@@ -4053,7 +4156,7 @@ export default function App(): React.JSX.Element {
window.addEventListener('keydown', handleEscape) window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape)
}, [completedCurriculumNotice, controlWarningNotice, courseChangeBlockedOpen, instructorStudioModalOpen, materialModalOpen, platformModalMode, platformModalOpen, recentSessionModalOpen, runningPlatformCloseTarget, sessionConfirmAction, weekSwitchBlockedOpen]) }, [completedCurriculumNotice, controlWarningNotice, courseChangeBlockedOpen, instructorStudioModalOpen, materialModalOpen, moduleShutdownConfirm, platformModalMode, platformModalOpen, recentSessionModalOpen, runningPlatformCloseTarget, sessionConfirmAction, weekSwitchBlockedOpen])
const removePlatformSession = (platformToRemove: PlatformOption, options: { clearWorkspace: boolean }): void => { const removePlatformSession = (platformToRemove: PlatformOption, options: { clearWorkspace: boolean }): void => {
if (options.clearWorkspace) { if (options.clearWorkspace) {
const studentSlug = makeStudentSlug(authSession?.user.email) const studentSlug = makeStudentSlug(authSession?.user.email)
@@ -4165,7 +4268,7 @@ export default function App(): React.JSX.Element {
} }
await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' })) await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' }))
} }
const handleDisconnectSession = async (): Promise<void> => { const handleDisconnectSession = async (options?: { stopModules?: boolean }): Promise<void> => {
const platformToDisconnect = connectedPlatform const platformToDisconnect = connectedPlatform
if (!platformToDisconnect) { if (!platformToDisconnect) {
closeLocalPcWorkspace() closeLocalPcWorkspace()
@@ -4180,7 +4283,9 @@ export default function App(): React.JSX.Element {
}) })
setPlatformLoading(true) setPlatformLoading(true)
const loadingStartedAt = Date.now() const loadingStartedAt = Date.now()
await stopConnectedWorkspaceModules() if (options?.stopModules !== false) {
await stopConnectedWorkspaceModules()
}
await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' })) await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' }))
const remainingMs = Math.max(0, 2000 - (Date.now() - loadingStartedAt)) const remainingMs = Math.max(0, 2000 - (Date.now() - loadingStartedAt))
window.setTimeout(() => { window.setTimeout(() => {
@@ -4189,7 +4294,7 @@ export default function App(): React.JSX.Element {
showTitlebarNotice(`${platformToDisconnect.title} 개발 스튜디오가 종료되었습니다`, 'info', true) showTitlebarNotice(`${platformToDisconnect.title} 개발 스튜디오가 종료되었습니다`, 'info', true)
}, remainingMs) }, remainingMs)
} }
const handleReleaseControl = async (options?: { forceClose?: boolean; expired?: boolean }): Promise<void> => { const handleReleaseControl = async (options?: { forceClose?: boolean; expired?: boolean; stopModules?: boolean }): Promise<void> => {
if (!connectedPlatform) { if (!connectedPlatform) {
setSessionConfirmAction(null) setSessionConfirmAction(null)
return return
@@ -4207,7 +4312,9 @@ export default function App(): React.JSX.Element {
setPlatformLoading(true) setPlatformLoading(true)
let released = false let released = false
try { try {
await stopConnectedWorkspaceModules() if (options?.stopModules !== false) {
await stopConnectedWorkspaceModules()
}
await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' })) await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' }))
if (authSession?.accessToken) { if (authSession?.accessToken) {
await releasePlatformControl(fetchWithFreshAuth, platformToRelease.id) await releasePlatformControl(fetchWithFreshAuth, platformToRelease.id)
@@ -4234,6 +4341,28 @@ export default function App(): React.JSX.Element {
} }
} }
const handleManualSessionEnd = (action: 'disconnect' | 'release', stopModules: boolean): void => {
if (action === 'release') {
void handleReleaseControl({ stopModules })
return
}
void handleDisconnectSession({ stopModules })
}
const confirmManualSessionEnd = (): void => {
const action = sessionConfirmAction
if (!action) return
const moduleNames = getRunningWorkspaceModuleNamesRef.current?.() ?? []
setSessionConfirmAction(null)
if (moduleNames.length > 0) {
setModuleShutdownConfirm({ action, moduleNames })
return
}
handleManualSessionEnd(action, false)
}
const handleExtendControl = async (): Promise<void> => { const handleExtendControl = async (): Promise<void> => {
if (!connectedPlatform || !canExtendConnectedPlatform || controlExtensionSubmitting) return if (!connectedPlatform || !canExtendConnectedPlatform || controlExtensionSubmitting) return
@@ -4714,6 +4843,9 @@ export default function App(): React.JSX.Element {
onRegisterStopRunningModules={(handler) => { onRegisterStopRunningModules={(handler) => {
stopRunningWorkspaceModulesRef.current = handler stopRunningWorkspaceModulesRef.current = handler
}} }}
onRegisterRunningModuleNames={(handler) => {
getRunningWorkspaceModuleNamesRef.current = handler
}}
canReleaseControl={canReleaseConnectedPlatform} canReleaseControl={canReleaseConnectedPlatform}
canExtendControl={canExtendConnectedPlatform} canExtendControl={canExtendConnectedPlatform}
controlEndsAt={connectedPlatformControlEndsAt} controlEndsAt={connectedPlatformControlEndsAt}
@@ -4960,12 +5092,24 @@ export default function App(): React.JSX.Element {
action={sessionConfirmAction} action={sessionConfirmAction}
platformName={activePlatformName} platformName={activePlatformName}
onCancel={() => setSessionConfirmAction(null)} onCancel={() => setSessionConfirmAction(null)}
onConfirm={() => { onConfirm={confirmManualSessionEnd}
if (sessionConfirmAction === 'release') { />
void handleReleaseControl() )}
} else { {moduleShutdownConfirm && (
void handleDisconnectSession() <ModuleShutdownConfirmModal
} overlayClassName={fullAppModalOverlayClass}
dark={appModalDark}
action={moduleShutdownConfirm.action}
moduleNames={moduleShutdownConfirm.moduleNames}
onKeepRunning={() => {
const { action } = moduleShutdownConfirm
setModuleShutdownConfirm(null)
handleManualSessionEnd(action, false)
}}
onStopModules={() => {
const { action } = moduleShutdownConfirm
setModuleShutdownConfirm(null)
handleManualSessionEnd(action, true)
}} }}
/> />
)} )}
@@ -204,6 +204,40 @@ export function SessionConfirmModal({
) )
} }
export function ModuleShutdownConfirmModal({
overlayClassName,
dark,
action,
moduleNames,
onKeepRunning,
onStopModules,
}: AppModalTheme & {
action: 'disconnect' | 'release'
moduleNames: string[]
onKeepRunning: () => void
onStopModules: () => void
}): React.JSX.Element {
const release = action === 'release'
return (
<ConfirmStatusModal
overlayClassName={overlayClassName}
dark={dark}
icon={<AlertCircle size={15} className="text-[#f59e0b]" />}
title="실행 중인 모듈"
message={`${moduleNames.length}개의 모듈이 실행 중입니다. 모듈을 종료할까요?`}
detail={moduleNames.join(', ')}
cancelLabel="아니오"
confirmLabel="예"
onCancel={onKeepRunning}
onConfirm={onStopModules}
>
<p className={['mt-3 text-[11px] font-medium', dimClass(dark)].join(' ')}>
{release ? '아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.' : '아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.'}
</p>
</ConfirmStatusModal>
)
}
export function ControlExtensionConfirmModal({ export function ControlExtensionConfirmModal({
overlayClassName, overlayClassName,
dark, dark,
@@ -248,17 +282,21 @@ function ConfirmStatusModal({
title, title,
message, message,
detail, detail,
cancelLabel = '취소',
confirmLabel, confirmLabel,
onCancel, onCancel,
onConfirm onConfirm,
children
}: AppModalTheme & { }: AppModalTheme & {
icon: React.ReactNode icon: React.ReactNode
title: string title: string
message: string message: string
detail: string detail: string
cancelLabel?: string
confirmLabel: string confirmLabel: string
onCancel: () => void onCancel: () => void
onConfirm: () => void onConfirm: () => void
children?: React.ReactNode
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
<div className={overlayClassName}> <div className={overlayClassName}>
@@ -272,8 +310,9 @@ function ConfirmStatusModal({
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}> <p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
{detail} {detail}
</p> </p>
{children}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>취소</button> <button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{cancelLabel}</button>
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{confirmLabel}</button> <button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{confirmLabel}</button>
</div> </div>
</div> </div>
+2
View File
@@ -103,6 +103,7 @@ interface Window {
runtime: string runtime: string
} }
}> }>
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
@@ -255,6 +256,7 @@ interface Window {
runtime: string runtime: string
} }
}> }>
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
@@ -1,4 +1,4 @@
import { BookOpen, Clock3, FileText, Play, Upload } from 'lucide-react' import { BookOpen, Car, Clock3, FileText, Play, Upload } from 'lucide-react'
import type React from 'react' import type React from 'react'
type LearningInfoWeek = { type LearningInfoWeek = {
@@ -59,8 +59,9 @@ export default function LearningInfoCard({
</div> </div>
</div> </div>
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff]"> <div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] pb-2 pt-2">
{[ {[
{ label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car },
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 }, { label: '예상 학습 시간', value: week.duration, Icon: Clock3 },
...(week.detail.requiresSourceSubmission ...(week.detail.requiresSourceSubmission
? [{ ? [{
@@ -82,7 +83,7 @@ export default function LearningInfoCard({
<Icon size={14} className="shrink-0 text-[#6d7788]" /> <Icon size={14} className="shrink-0 text-[#6d7788]" />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</span> </span>
<strong className={['shrink-0 text-right text-[13px] font-bold', valueClassName ?? 'text-[#151827]'].join(' ')}> <strong className={['shrink-0 text-right text-[12px] font-bold', valueClassName ?? 'text-[#151827]'].join(' ')}>
{value} {value}
</strong> </strong>
</div> </div>
@@ -67,6 +67,8 @@ export type CurriculumWeek = {
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
defaultParameters?: string[] | null defaultParameters?: string[] | null
dockerImage?: string | null dockerImage?: string | null
packageName?: string | null
launchFile?: string | null
}> }>
environmentProfiles?: EnvironmentProfile[] environmentProfiles?: EnvironmentProfile[]
maps?: Array<{ maps?: Array<{
@@ -6,6 +6,7 @@ export type ModuleListRowItem = {
state: 'running' | 'stopped' | 'uninstalled' state: 'running' | 'stopped' | 'uninstalled'
category: string category: string
name: string name: string
sourceType?: string | null
parameters: string[] parameters: string[]
cpu: number | null cpu: number | null
memory: number | null memory: number | null
@@ -135,7 +136,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
] ]
: [] : []
const installAction = module.state === 'uninstalled' const installAction = module.state === 'uninstalled'
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone } ? { label: module.sourceType === 'CUSTOM' ? '소스 생성' : '설치', Icon: Download, tone: 'install' as ModuleActionTone }
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone } : { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped') const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
@@ -180,8 +181,13 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
</span> </span>
<span className={['text-center', dark ? 'text-white/52' : 'text-[#687284]'].join(' ')}>{module.category}</span> <span className={['text-center', dark ? 'text-white/52' : 'text-[#687284]'].join(' ')}>{module.category}</span>
<span className="min-w-0"> <span className="min-w-0">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span> <span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span>
{module.sourceType === 'CUSTOM' && (
<span className={['shrink-0 rounded border px-1.5 py-[1px] text-[9px] font-bold', dark ? 'border-[#a78bfa]/35 bg-[#a78bfa]/12 text-[#c4b5fd]' : 'border-[#8b5cf6]/22 bg-[#f3efff] text-[#7350c8]'].join(' ')}>
커스텀
</span>
)}
</span> </span>
<button <button
type="button" type="button"
@@ -1,3 +1,4 @@
import { CirclePlus, X } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type React from 'react' import type React from 'react'
import ModuleDeleteConfirm from './ModuleDeleteConfirm' import ModuleDeleteConfirm from './ModuleDeleteConfirm'
@@ -32,6 +33,7 @@ type ModuleListViewProps = {
onStopAll: () => void onStopAll: () => void
onReorderModules: (source: ModuleRow, target: ModuleRow) => void onReorderModules: (source: ModuleRow, target: ModuleRow) => void
onOpenWorldEditor: () => void onOpenWorldEditor: () => void
onCreateCustomModule: (input: CustomModuleInput) => Promise<void>
} }
export default function ModuleListView({ export default function ModuleListView({
@@ -56,11 +58,13 @@ export default function ModuleListView({
onStartAll, onStartAll,
onStopAll, onStopAll,
onReorderModules, onReorderModules,
onOpenWorldEditor onOpenWorldEditor,
onCreateCustomModule
}: ModuleListViewProps): React.JSX.Element { }: ModuleListViewProps): React.JSX.Element {
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all') const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null) const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null) const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null) const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null) const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter) const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter)
@@ -82,12 +86,17 @@ export default function ModuleListView({
if (deleteConfirmModule) { if (deleteConfirmModule) {
event.preventDefault() event.preventDefault()
setDeleteConfirmModule(null) setDeleteConfirmModule(null)
return
}
if (moduleAddDialogOpen) {
event.preventDefault()
setModuleAddDialogOpen(false)
} }
} }
window.addEventListener('keydown', handleEscape) window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape)
}, [deleteConfirmModule, parameterDialog]) }, [deleteConfirmModule, moduleAddDialogOpen, parameterDialog])
return ( return (
<section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}> <section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
@@ -169,6 +178,18 @@ export default function ModuleListView({
onRestartModule={onRestartModule} onRestartModule={onRestartModule}
/> />
))} ))}
{!loading && (
<div className={['flex justify-center pb-4 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
<button
type="button"
onClick={() => setModuleAddDialogOpen(true)}
className={['inline-flex cursor-pointer items-center gap-1.5 text-[12px] font-bold underline-offset-4 transition hover:underline', dark ? 'text-[#8ab8ff] hover:text-white' : 'text-[#2563eb] hover:text-[#1d4ed8]'].join(' ')}
>
<CirclePlus size={14} aria-hidden="true" />
모듈 추가
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -197,6 +218,112 @@ export default function ModuleListView({
}} }}
/> />
)} )}
{moduleAddDialogOpen && (
<ModuleAddDialog
dark={dark}
onClose={() => setModuleAddDialogOpen(false)}
onCreate={async (input) => {
await onCreateCustomModule(input)
setModuleAddDialogOpen(false)
}}
/>
)}
</section> </section>
) )
} }
export type CustomModuleInput = {
name: string
category: string
dockerImage: string
packageName: string
launchFile: string
}
function ModuleAddDialog({
dark,
onClose,
onCreate
}: {
dark: boolean
onClose: () => void
onCreate: (input: CustomModuleInput) => Promise<void>
}): React.JSX.Element {
const [name, setName] = useState('')
const [category, setCategory] = useState('')
const [dockerImage, setDockerImage] = useState('')
const [packageName, setPackageName] = useState('')
const [launchFile, setLaunchFile] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const inputClassName = [
'h-9 w-full rounded-md border px-2.5 text-[12px] font-medium outline-none transition',
dark ? 'border-white/12 bg-[#1f1f1f] text-white/82 placeholder:text-white/28 focus:border-[#2f8dff]/55' : 'border-[#cfd6e4] bg-white text-[#111520] placeholder:text-[#9aa3b2] focus:border-[#2f8dff]/55'
].join(' ')
return (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 정보 입력">
<section className={['w-full max-w-[440px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'].join(' ')}>
<header className={['flex items-center justify-between border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
<div>
<h2 className="text-[14px] font-bold">모듈 정보 입력</h2>
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>추가할 ROS2 모듈의 기본 정보를 입력합니다.</p>
</div>
<button type="button" onClick={onClose} className={['grid h-7 w-7 place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label="닫기">
<X size={15} aria-hidden="true" />
</button>
</header>
<form className="grid gap-3 p-4" onSubmit={(event) => {
event.preventDefault()
if (submitting) return
setSubmitting(true)
setError(null)
void onCreate({ name: name.trim(), category, dockerImage: dockerImage.trim(), packageName: packageName.trim(), launchFile: launchFile.trim() })
.catch((reason) => setError(reason instanceof Error ? reason.message : '모듈을 추가하지 못했습니다.'))
.finally(() => setSubmitting(false))
}}>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
모듈 이름
<input value={name} onChange={(event) => setName(event.target.value)} className={inputClassName} placeholder="예: NAV2-MyPlanner" autoFocus />
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
카테고리
<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName}>
<option value="">선택</option>
<option value="Vehicle">Vehicle</option>
<option value="Sensing">Sensing</option>
<option value="Map">Map</option>
<option value="Perception">Perception</option>
<option value="Localization">Localization</option>
<option value="Planning">Planning</option>
<option value="Control">Control</option>
<option value="System">System</option>
<option value="Simulator">Simulator</option>
<option value="Gui">Gui</option>
</select>
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
실행 이미지
<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder="예: future-nav2:humble" />
</label>
<div className="grid grid-cols-2 gap-2">
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
패키지명
<input value={packageName} onChange={(event) => setPackageName(event.target.value)} className={inputClassName} placeholder="my_planner_pkg" />
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
Launch 파일
<input value={launchFile} onChange={(event) => setLaunchFile(event.target.value)} className={inputClassName} placeholder="planner.launch.py" />
</label>
</div>
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{error}</p>}
<footer className="mt-1 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 rounded-md border px-3 text-[12px] font-bold transition', dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>취소</button>
<button type="submit" disabled={submitting || !name.trim() || !category || !packageName.trim() || !launchFile.trim()} className="h-8 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? '추가 중' : '확인'}</button>
</footer>
</form>
</section>
</div>
)
}
@@ -138,7 +138,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
))} ))}
</div> </div>
</div> </div>
<div className="min-h-0 flex-1 overflow-auto overscroll-contain p-3"> <div className="thin-y-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3">
{resourceMode === 'material' ? ( {resourceMode === 'material' ? (
<div className="grid gap-3"> <div className="grid gap-3">
{materialItems.length ? materialItems.map((item, index) => { {materialItems.length ? materialItems.map((item, index) => {
@@ -178,7 +178,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
].join(' ')}>{index + 1}</span> ].join(' ')}>{index + 1}</span>
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}> <div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
<div className="flex min-w-0 items-center justify-between gap-2"> <div className="flex min-w-0 items-center justify-between gap-2">
<strong className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong> <strong title={item.title} className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
<span className="flex shrink-0 items-center gap-1.5"> <span className="flex shrink-0 items-center gap-1.5">
{active && ( {active && (
<span className="rounded border border-[#60a5fa]/24 bg-[#2563eb]/16 px-2 py-0.5 text-[10px] font-bold text-[#bfdbfe]"> <span className="rounded border border-[#60a5fa]/24 bg-[#2563eb]/16 px-2 py-0.5 text-[10px] font-bold text-[#bfdbfe]">
@@ -5,6 +5,7 @@ export type ModuleParameterPair = {
} }
export type ModuleRow = { export type ModuleRow = {
id?: string
state: 'running' | 'stopped' | 'uninstalled' state: 'running' | 'stopped' | 'uninstalled'
sortOrder: number sortOrder: number
category: string category: string
@@ -15,6 +16,9 @@ export type ModuleRow = {
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
sourceType?: string | null sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
dockerImage?: string | null
packageName?: string | null
launchFile?: string | null
parameters: string[] parameters: string[]
cpu: number | null cpu: number | null
memory: number | null memory: number | null
@@ -113,7 +113,30 @@ export function useWorkspaceModuleControl({
return parameters.join(' ') return parameters.join(' ')
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile]) }, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile])
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
if (!module.folderName || !module.packageName || !module.launchFile) {
throw new Error('커스텀 모듈의 패키지명 또는 Launch 파일 정보가 없습니다.')
}
const input = {
name: module.name,
category: module.category,
folderName: module.folderName,
dockerImage: module.dockerImage ?? null,
packageName: module.packageName,
launchFile: module.launchFile,
}
if (remoteWorkspace) {
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), input)
return
}
await window.localWorkspace.modules.createCustom(clonePathPreview, input)
}, [clonePathPreview, getRemoteWorkspaceTarget, remoteWorkspace])
const installModuleOnce = useCallback(async (module: ModuleRow, requestAccessToken: string): Promise<void> => { const installModuleOnce = useCallback(async (module: ModuleRow, requestAccessToken: string): Promise<void> => {
if (module.sourceType === 'CUSTOM') {
await createCustomModuleSource(module)
return
}
const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, { const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -160,7 +183,7 @@ export function useWorkspaceModuleControl({
prepared.repositoryUrlWithToken, prepared.repositoryUrlWithToken,
installOptions, installOptions,
) )
}, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber]) }, [activeRuntimeImages, clonePathPreview, courseKey, createCustomModuleSource, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => { const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
await prepareRemoteRuntimeModule(module) await prepareRemoteRuntimeModule(module)
@@ -8,6 +8,7 @@ export const compareModuleShutdownPriority = (a: ModuleRow, b: ModuleRow): numbe
export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['modules']): ModuleRow[] { export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['modules']): ModuleRow[] {
return (curriculumModules ?? []) return (curriculumModules ?? [])
.map((module) => ({ .map((module) => ({
id: module.id,
state: 'uninstalled' as const, state: 'uninstalled' as const,
sortOrder: module.sortOrder, sortOrder: module.sortOrder,
category: module.category, category: module.category,
@@ -18,6 +19,9 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod
templateRepositoryUrl: module.templateRepositoryUrl ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null,
sourceType: module.sourceType ?? null, sourceType: module.sourceType ?? null,
prebuiltArtifacts: module.prebuiltArtifacts ?? null, prebuiltArtifacts: module.prebuiltArtifacts ?? null,
dockerImage: module.dockerImage ?? null,
packageName: module.packageName ?? null,
launchFile: module.launchFile ?? null,
parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [], parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [],
cpu: null, cpu: null,
memory: null, memory: null,