feat: enable hybrid local and remote module execution

This commit is contained in:
2026-08-06 16:18:32 +09:00
parent 9cbf4ac7e9
commit 2ab7e279eb
37 changed files with 3267 additions and 744 deletions
+76
View File
@@ -25,10 +25,12 @@ from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
from nav2_msgs.action import NavigateToPose
from pydantic import BaseModel
from rclpy.action import ActionClient
from rclpy.duration import Duration
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.parameter import Parameter
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import LaserScan, PointCloud2
from tf2_msgs.msg import TFMessage
import uvicorn
@@ -149,6 +151,7 @@ class SlamNode(Node):
self.odom_topic = os.environ.get("ODOM_TOPIC", "/odom")
self.scan_topic = os.environ.get("SCAN_TOPIC", "/scan")
self.amcl_pose_topic = os.environ.get("AMCL_POSE_TOPIC", "/amcl_pose")
self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map")
self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered")
self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom")
@@ -181,6 +184,7 @@ class SlamNode(Node):
self._odom: Optional[Odometry] = None
self._scan: Optional[LaserScan] = None
self._amcl_pose: Optional[PoseWithCovarianceStamped] = None
self._point_cloud: Optional[PointCloud2] = None
self._fallback_point_cloud: Optional[PointCloud2] = None
self._icp_odom: Optional[Odometry] = None
@@ -189,6 +193,7 @@ class SlamNode(Node):
self._tf = {}
self._odom_count = 0
self._scan_count = 0
self._amcl_pose_count = 0
self._point_cloud_count = 0
self._fallback_point_cloud_count = 0
self._icp_odom_count = 0
@@ -196,10 +201,15 @@ class SlamNode(Node):
self._tf_count = 0
self._last_odom_wall_time = None
self._last_scan_wall_time = None
self._last_amcl_pose_wall_time = None
self._last_point_cloud_wall_time = None
self._last_fallback_point_cloud_wall_time = None
self._last_icp_odom_wall_time = None
self._last_map_wall_time = None
self._plan_count = 0
self._last_plan_wall_time = None
self._topic_status_cache = {}
self._topic_status_last_refresh = 0.0
self._navigation = {"status": "idle", "goal_id": None}
self._active_goal_handle = None
self._lock = threading.Lock()
@@ -208,6 +218,8 @@ class SlamNode(Node):
self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos)
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data)
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, reliable_qos)
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, reliable_qos)
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, best_effort_qos)
self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos)
if self.fallback_point_cloud_topic != self.point_cloud_topic:
self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data)
@@ -233,7 +245,18 @@ class SlamNode(Node):
self._last_odom_wall_time = time.time()
def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> None:
# AMCL transforms the initial pose at the message timestamp. On a real
# platform the bridge clock can be a few milliseconds ahead of the
# latest odom/TF sample, which makes AMCL reject the pose as a future
# extrapolation. Anchor it to the latest received odometry instead.
with self._lock:
odom_stamp = self._odom.header.stamp if self._odom is not None else None
message = PoseWithCovarianceStamped()
if odom_stamp:
# The odom callback can lead the TF buffer by a few milliseconds.
# Keep a small margin so AMCL can always transform map -> odom.
message.header.stamp = (Time.from_msg(odom_stamp) - Duration(seconds=0.1)).to_msg()
else:
message.header.stamp = self.get_clock().now().to_msg()
message.header.frame_id = frame_id
message.pose.pose.position.x = x
@@ -355,6 +378,12 @@ class SlamNode(Node):
self._scan_count += 1
self._last_scan_wall_time = time.time()
def _on_amcl_pose(self, msg: PoseWithCovarianceStamped):
with self._lock:
self._amcl_pose = msg
self._amcl_pose_count += 1
self._last_amcl_pose_wall_time = time.time()
def _on_point_cloud(self, msg: PointCloud2):
with self._lock:
self._point_cloud = msg
@@ -393,6 +422,51 @@ class SlamNode(Node):
def _on_plan(self, msg: NavPath):
with self._lock:
self._plan = msg
self._plan_count += 1
self._last_plan_wall_time = time.time()
def topic_status(self) -> dict:
now = time.time()
with self._lock:
if now - self._topic_status_last_refresh < 1.0:
return dict(self._topic_status_cache)
scan_last = self._last_scan_wall_time
odom_last = self._last_odom_wall_time
amcl_pose_last = self._last_amcl_pose_wall_time
point_cloud_last = self._last_point_cloud_wall_time
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
map_count = self._map_count
plan_count = self._plan_count
def received_recently(last_received: Optional[float]) -> bool:
return last_received is not None and now - last_received <= 3.0
try:
plan_available = plan_count > 0 or self.count_publishers(self.plan_topic) > 0
cmd_vel_available = self.count_subscribers(self.cmd_vel_topic) > 0
navigation_available = self._navigate_to_pose_client.server_is_ready()
except Exception:
plan_available = plan_count > 0
cmd_vel_available = False
navigation_available = False
status = {
"scan": {"name": self.scan_topic, "available": received_recently(scan_last)},
"odom": {"name": self.odom_topic, "available": received_recently(odom_last)},
"map": {"name": self.map_topic, "available": map_count > 0},
"amcl_pose": {"name": self.amcl_pose_topic, "available": received_recently(amcl_pose_last)},
"point_cloud": {
"name": self.point_cloud_topic,
"available": received_recently(point_cloud_last) or received_recently(fallback_point_cloud_last),
},
"plan": {"name": self.plan_topic, "available": plan_available},
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
"navigate_to_pose": {"name": self.navigate_to_pose_action, "available": navigation_available},
}
with self._lock:
self._topic_status_cache = status
self._topic_status_last_refresh = now
return dict(status)
@staticmethod
def _has_populated_map(grid: Optional[OccupancyGrid]) -> bool:
@@ -738,6 +812,7 @@ class SlamNode(Node):
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
},
"navigation": navigation,
"topic_status": self.topic_status(),
"plan": {
"frame_id": plan.header.frame_id,
"poses": [
@@ -904,6 +979,7 @@ def health():
"has_point_cloud": bool(snap.get("point_cloud")),
"has_map": bool(snap.get("map")),
"topics": snap.get("topics", {}),
"topic_status": snap.get("topic_status", {}),
}
+661 -41
View File
File diff suppressed because it is too large Load Diff
+51 -14
View File
@@ -21,6 +21,7 @@ type ModulePrebuiltArtifact = {
fileName?: string | null
checksumSha256?: string | null
extractPath?: string | null
architecture?: string | null
required?: boolean | null
}
@@ -37,6 +38,24 @@ type ModuleInstallOptions = {
runtimeImages?: RuntimeImageArtifact[] | null
}
type RosNetworkConfig = {
enabled?: boolean | null
peer?: RemoteWorkspaceTarget | null
}
type SavedMapArtifactName = 'yaml' | 'pgm' | 'database'
type SavedMapArtifactPath = {
name: SavedMapArtifactName
path: string
}
type SavedMapArtifactDownload = {
name: SavedMapArtifactName
fileName: string
url: string
}
const decodeArgValue = (prefix: string): string | null => {
const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
if (!rawValue) return null
@@ -94,6 +113,9 @@ contextBridge.exposeInMainWorld('titlebar', {
openDevTools: () => ipcRenderer.invoke('titlebar-open-devtools'),
close: () => ipcRenderer.send('titlebar-close')
})
contextBridge.exposeInMainWorld('appDiagnostics', {
read: () => ipcRenderer.invoke('app-diagnostics:read')
})
contextBridge.exposeInMainWorld('appLifecycle', {
quit: () => ipcRenderer.send('app-quit')
})
@@ -113,18 +135,20 @@ contextBridge.exposeInMainWorld('modules', {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('modules:list', workspaceRoot, modules),
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName),
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('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('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages),
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams),
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName),
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot),
stopRunning: () => ipcRenderer.invoke('modules:stop-running'),
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:logs', moduleName, workspaceRoot, folderName),
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
})
contextBridge.exposeInMainWorld('git', {
status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('git:status', moduleName, workspaceRoot, folderName, selectedFilePath),
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId),
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('git:team-publish', moduleName, workspaceRoot, folderName, message, authorName, authorEmail),
teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('git:team-pull', moduleName, workspaceRoot, folderName),
submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('git:submissions', modules, workspaceRoot),
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
})
@@ -154,7 +178,7 @@ contextBridge.exposeInMainWorld('terminal', {
})
contextBridge.exposeInMainWorld('visualization', {
status: () => ipcRenderer.invoke('visualization:status'),
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile),
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
})
contextBridge.exposeInMainWorld('localWorkspace', {
@@ -172,12 +196,12 @@ contextBridge.exposeInMainWorld('localWorkspace', {
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),
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),
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams),
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName),
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot),
stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'),
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName),
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
},
@@ -190,14 +214,20 @@ contextBridge.exposeInMainWorld('localWorkspace', {
read: (workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:world-layout:read', workspaceRoot),
write: (workspaceRoot: string | undefined, layout: unknown) => ipcRenderer.invoke('local-workspace:world-layout:write', workspaceRoot, layout)
},
maps: {
readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('local-workspace:maps:read-artifacts', workspaceRoot, artifacts),
sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('local-workspace:maps:sync', workspaceRoot, artifacts)
},
visualization: {
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile),
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
},
git: {
status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('local-workspace:git:status', moduleName, workspaceRoot, folderName, selectedFilePath),
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('local-workspace:git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId),
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('local-workspace:git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('local-workspace:git:team-publish', moduleName, workspaceRoot, folderName, message, authorName, authorEmail),
teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:git:team-pull', moduleName, workspaceRoot, folderName),
restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('local-workspace:git:restore-file', moduleName, workspaceRoot, folderName, filePath, status),
submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:git:submissions', modules, workspaceRoot),
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
@@ -234,26 +264,33 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName),
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
},
maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
},
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),
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),
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:transfer', target, sourceWorkspaceRoot, moduleName, folderName),
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
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),
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules),
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules),
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName)
},
visualization: {
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile),
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile, rosNetwork),
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
},
git: {
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('remote-workspace:git:status', target, moduleName, folderName, selectedFilePath),
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId),
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
teamPublish: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('remote-workspace:git:team-publish', target, moduleName, folderName, message, authorName, authorEmail),
teamPull: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:git:team-pull', target, moduleName, folderName),
restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('remote-workspace:git:restore-file', target, moduleName, folderName, filePath, status),
submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:git:submissions', target, modules),
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash)
+229 -58
View File
@@ -24,7 +24,7 @@
#miniMapToggle {
position:absolute;
right:16px;
bottom:236px;
bottom:240px;
z-index:11;
min-height:28px;
padding:0 10px;
@@ -76,6 +76,65 @@
backdrop-filter:blur(8px);
box-shadow:0 10px 28px rgba(0,0,0,.16);
}
#rosTopicStatusWrap {
position:absolute;
left:16px;
bottom:54px;
z-index:10;
width:178px;
}
#rosTopicStatus {
overflow:hidden;
border:1px solid rgba(255,255,255,.24);
background:rgba(17,24,39,.48);
color:rgba(255,255,255,.88);
font:11px sans-serif;
backdrop-filter:blur(8px);
box-shadow:0 10px 28px rgba(0,0,0,.16);
}
#rosTopicStatusToggle {
position:absolute;
left:0;
top:-28px;
display:grid;
width:30px;
height:28px;
place-items:center;
padding:0;
border:1px solid rgba(255,255,255,.22);
background:rgba(17,24,39,.42);
color:rgba(255,255,255,.9);
font:11px sans-serif;
font-weight:800;
cursor:pointer;
backdrop-filter:blur(8px);
box-shadow:0 10px 28px rgba(0,0,0,.16);
}
#rosTopicStatusToggle:hover { color:#fff; background:rgba(255,255,255,.06); }
#rosTopicStatusWrap.is-collapsed #rosTopicStatus { display:none; }
#rosTopicStatusChevron { font-size:13px; line-height:1; }
#rosTopicStatusWrap.is-collapsed #rosTopicStatusChevron { transform:rotate(180deg); }
#rosTopicStatusTitle {
min-height:30px;
display:flex;
align-items:center;
padding:0 9px;
border-bottom:1px solid rgba(255,255,255,.14);
font-weight:800;
}
#rosTopicStatusList { padding:4px 0; }
.ros-topic-row {
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
min-height:23px;
padding:0 9px;
}
.ros-topic-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ros-topic-state { flex:0 0 auto; font-size:12px; font-weight:900; }
.ros-topic-state.is-ok { color:#5eead4; }
.ros-topic-state.is-error { color:#fca5a5; }
#responseWaitOverlay {
position:fixed;
inset:0;
@@ -250,14 +309,16 @@
.editor-mode #topRightControls,
.editor-mode #miniMap,
.editor-mode #miniMapToggle,
.editor-mode #keyGuide {
.editor-mode #keyGuide,
.editor-mode #rosTopicStatusWrap {
display:none;
}
#splatToggle { display:none; }
.nav2-slam #goalToggle, .nav2-slam #poseToggle, .nav2-slam #sensorToggle { display:none; }
.nav2-slam #goalToggle, .nav2-slam #poseToggle, .nav2-slam #sensorToggle, .nav2-slam #initToggle { display:none; }
.nav2-navigation #downloadWrap { display:none; }
.nav2-navigation #topLeftControls { display:block; }
.nav2-navigation #keyGuide { display:none; }
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.nav2-navigation.real-runtime #splatToggle { display:inline-flex; }
.real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; }
@@ -288,6 +349,15 @@
<div id="keyGuide">
<div>i 전진 · , 후진 · j/l 회전 · k 정지</div>
</div>
<div id="rosTopicStatusWrap">
<button id="rosTopicStatusToggle" type="button" title="ROS 상태 접기/펼치기" aria-label="ROS 상태 접기" aria-expanded="true">
<span id="rosTopicStatusChevron" aria-hidden="true">▾</span>
</button>
<aside id="rosTopicStatus" aria-label="ROS 상태">
<div id="rosTopicStatusTitle">ROS 상태</div>
<div id="rosTopicStatusList"></div>
</aside>
</div>
<div id="responseWaitOverlay" role="status" aria-live="polite">
<div class="response-wait-dialog">
<span class="response-wait-spinner" aria-hidden="true"></span>
@@ -315,9 +385,17 @@ import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
const viewerMapKey = new URLSearchParams(window.location.search).get('mapKey');
const params = new URLSearchParams(location.search);
const REAL_RUNTIME = params.get('runtime') === 'real';
const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
// Keep the prior real-platform default when an older LMS URL has no platform.
const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom';
const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world';
const MKMINI_WORLD_SPACING_SCALE = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world' ? 1.6 : 1;
const MKMINI_RENDER_LENGTH = 1.05;
const DEFAULT_SIMULATION_SPAWN = USE_MKMINI_MODEL
? { x: -1.4, y: -0.5 }
: { x: -2.0, y: -0.5 };
const MODEL_ROOT = `./turtlebot3_world_model/${activeWorldModel}`;
const MODEL_SDF = `${MODEL_ROOT}/model.sdf`;
const miniMap = document.getElementById('miniMap');
@@ -333,6 +411,10 @@ const goalToggleButton = document.getElementById('goalToggle');
const obstacleToggleButton = document.getElementById('obstacleToggle');
const initToggleButton = document.getElementById('initToggle');
const cameraPresetSelect = document.getElementById('cameraPresetSelect');
const rosTopicStatusWrap = document.getElementById('rosTopicStatusWrap');
const rosTopicStatus = document.getElementById('rosTopicStatus');
const rosTopicStatusToggle = document.getElementById('rosTopicStatusToggle');
const rosTopicStatusList = document.getElementById('rosTopicStatusList');
const EDITOR_MODE = params.get('editor') === '1';
const NAV2_VIEW_MODE = params.get('mode') === 'navigation' ? 'navigation' : 'slam';
const SHOW_REMOTE_NAVIGATION_ROOM = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation';
@@ -347,6 +429,44 @@ const cameraPresetSelect = document.getElementById('cameraPresetSelect');
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
let visualizationResponseReceived = EDITOR_MODE;
const ROS_TOPIC_STATUS_KEYS = NAV2_VIEW_MODE === 'navigation'
? ['scan', 'odom', 'map', 'amcl_pose', 'plan', 'cmd_vel', 'navigate_to_pose', ...(SHOW_POINT_CLOUD ? ['point_cloud'] : [])]
: ['scan', 'odom', 'map'];
const ROS_TOPIC_STATUS_FALLBACK_NAMES = {
scan: '/scan',
odom: '/odom',
map: '/map',
amcl_pose: '/amcl_pose',
plan: '/plan',
cmd_vel: '/cmd_vel',
navigate_to_pose: '/navigate_to_pose',
point_cloud: '/cloud_map',
};
function updateRosTopicStatus(topicStatus = {}) {
if (!rosTopicStatusList) return;
rosTopicStatusList.replaceChildren(...ROS_TOPIC_STATUS_KEYS.map((key) => {
const status = topicStatus?.[key] ?? {};
const row = document.createElement('div');
row.className = 'ros-topic-row';
const name = document.createElement('span');
name.className = 'ros-topic-name';
name.textContent = status.name || ROS_TOPIC_STATUS_FALLBACK_NAMES[key] || key;
const indicator = document.createElement('span');
const available = status.available === true;
indicator.className = `ros-topic-state ${available ? 'is-ok' : 'is-error'}`;
indicator.textContent = available ? 'O' : 'X';
row.append(name, indicator);
return row;
}));
}
rosTopicStatusToggle?.addEventListener('click', () => {
const collapsed = rosTopicStatusWrap?.classList.toggle('is-collapsed') ?? false;
rosTopicStatusToggle.setAttribute('aria-expanded', String(!collapsed));
rosTopicStatusToggle.setAttribute('aria-label', collapsed ? 'ROS 상태 펼치기' : 'ROS 상태 접기');
});
updateRosTopicStatus();
function markVisualizationResponseReceived() {
if (visualizationResponseReceived) return;
@@ -421,7 +541,7 @@ let remoteNavigationSplatTransform = null;
let remoteNavigationSplatEnabled = false;
let remoteNavigationSplatLoading = null;
// 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다.
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.7322845855746674, 1.3107954323633903, -0.8021416967574936);
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.6822845855746674, 1.3107954323633903, -0.8021416967574936);
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 pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null;
@@ -651,6 +771,13 @@ let obstaclePreviewTemplate = null;
const navigationRaycaster = new THREE.Raycaster();
const navigationGroundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
// A saved map, the remote room model, and AMCL's pose all use `map`.
// Keep the real-platform navigation scene in that frame. Local Gazebo still
// needs odom coordinates for its pose service, so its conversion remains.
function navigationSceneUsesMapFrame() {
return REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation';
}
function syncGoalToggleAvailability() {
const disabled = NAV2_VIEW_MODE === 'navigation' && !navigationInitialPoseSet;
goalToggleButton.disabled = disabled;
@@ -694,6 +821,17 @@ function applySdfPose(obj, pose) {
obj.position.set(pose.x, pose.y, pose.z);
obj.rotation.set(pose.roll, pose.pitch, pose.yaw, 'XYZ');
}
function applyWorldSdfPose(obj, pose) {
applySdfPose(obj, {
...pose,
x: pose.x * MKMINI_WORLD_SPACING_SCALE,
y: pose.y * MKMINI_WORLD_SPACING_SCALE,
});
}
function isMkMiniWorldPillar(visual) {
return MKMINI_WORLD_SPACING_SCALE > 1
&& /^(one|two|three)_/.test(visual.getAttribute('name') || '');
}
function getFirst(el, tag) { return el?.getElementsByTagName(tag)?.[0] || null; }
function directChild(el, tag) { return Array.from(el.children || []).find(c => c.tagName === tag) || null; }
function getDirectText(el, tag, fallback = '') {
@@ -754,6 +892,9 @@ async function addMesh(parent, visual, geometry) {
if (!uri) return;
const url = gazeboUriToPath(uri);
const scale = parseScale(getDirectText(meshEl, 'scale', '1 1 1'));
if (MKMINI_WORLD_SPACING_SCALE > 1 && uri.endsWith('/wall.dae')) {
scale.multiplyScalar(MKMINI_WORLD_SPACING_SCALE);
}
const material = materialFromVisual(visual);
if (/\.stl$/i.test(url)) {
@@ -801,7 +942,8 @@ function addBox(parent, visual, geometry) {
function addCylinder(parent, visual, geometry) {
const cyl = directChild(geometry, 'cylinder');
const radius = Number(getDirectText(cyl, 'radius', '0.1')) || .1;
const length = Number(getDirectText(cyl, 'length', '1')) || 1;
const sourceLength = Number(getDirectText(cyl, 'length', '1')) || 1;
const length = isMkMiniWorldPillar(visual) && sourceLength === .5 ? .8 : sourceLength;
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 32), materialFromVisual(visual));
// Three cylinder 기본 축은 Y, SDF cylinder 기본 축은 Z입니다.
mesh.rotation.x = Math.PI / 2;
@@ -827,7 +969,7 @@ async function buildFromSdf() {
const model = doc.querySelector('model');
const modelGroup = new THREE.Group();
modelGroup.name = model?.getAttribute('name') || 'turtlebot3_world';
applySdfPose(modelGroup, parsePose(getDirectText(model, 'pose', '0 0 0 0 0 0')));
applyWorldSdfPose(modelGroup, parsePose(getDirectText(model, 'pose', '0 0 0 0 0 0')));
sdfRoot.add(modelGroup);
let loaded = 0;
@@ -835,19 +977,20 @@ async function buildFromSdf() {
const nestedModelGroup = new THREE.Group();
let directGeometryCount = 0;
nestedModelGroup.name = sdfModel.getAttribute('name') || 'model';
applySdfPose(nestedModelGroup, parsePose(getDirectText(sdfModel, 'pose', '0 0 0 0 0 0')));
applyWorldSdfPose(nestedModelGroup, parsePose(getDirectText(sdfModel, 'pose', '0 0 0 0 0 0')));
parent.add(nestedModelGroup);
for (const link of Array.from(sdfModel.querySelectorAll(':scope > link'))) {
const linkGroup = new THREE.Group();
linkGroup.name = link.getAttribute('name') || 'link';
applySdfPose(linkGroup, parsePose(getDirectText(link, 'pose', '0 0 0 0 0 0')));
applyWorldSdfPose(linkGroup, parsePose(getDirectText(link, 'pose', '0 0 0 0 0 0')));
nestedModelGroup.add(linkGroup);
for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) {
const visualGroup = new THREE.Group();
visualGroup.name = visual.getAttribute('name') || 'visual';
applySdfPose(visualGroup, parsePose(getDirectText(visual, 'pose', '0 0 0 0 0 0')));
applyWorldSdfPose(visualGroup, parsePose(getDirectText(visual, 'pose', '0 0 0 0 0 0')));
if (isMkMiniWorldPillar(visual)) visualGroup.position.z += 0.15;
linkGroup.add(visualGroup);
const geometry = directChild(visual, 'geometry');
@@ -983,7 +1126,8 @@ async function loadMkMiniModel(sdfPose = { x:0, y:0, z:0, roll:0, pitch:0, yaw:0
const initialBounds = new THREE.Box3().setFromObject(model);
const initialSize = initialBounds.getSize(new THREE.Vector3());
const largestDimension = Math.max(initialSize.x, initialSize.y, initialSize.z, 0.001);
model.scale.setScalar(0.66 / largestDimension);
// Match the Gazebo MKMini collision body's 1.05 m longest dimension.
model.scale.setScalar(MKMINI_RENDER_LENGTH / largestDimension);
model.updateMatrixWorld(true);
const normalizedBounds = new THREE.Box3().setFromObject(model);
@@ -1083,14 +1227,15 @@ async function setRemoteNavigationSplatVisible(visible) {
}
const LASER_HOLE_R = 0.14;
const LASER_OFFSET_X = REAL_RUNTIME ? 0.1 : -0.032;
const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.1 : -0.032;
const LASER_OFFSET_Y = 0;
// Calibrated to the MKMini GLB at the current display scale.
const LASER_Z = REAL_RUNTIME ? 0.5 : 0.181;
const LASER_Z = USE_MKMINI_MODEL ? 0.5 : 0.181;
// The MKMini ROS frame reports os_lidar at Z=0.6 m while the bundled GLB
// places the visible LiDAR head at approximately Z=0.5 m. Keep ROS/map
// coordinates untouched and correct only the rendered scan origin.
const MKMINI_LASER_RENDER_Z_OFFSET = 0.1;
const MKMINI_LASER_RENDER_FORWARD_OFFSET = 0.10;
function setRobotPose(pose) {
if (!pose) return;
@@ -1234,7 +1379,7 @@ function setNavigationSensorVisible(visible) {
function drawLaserScan(scan, scanPose = (
NAV2_VIEW_MODE === 'navigation'
? latestSlamState?.scan_pose
? (navigationSceneUsesMapFrame() ? latestSlamState?.scan_pose_map : latestSlamState?.scan_pose)
: latestSlamState?.scan_pose_map
) || null) {
if (NAV2_VIEW_MODE === 'navigation' && (!navigationInitialPoseSet || !navigationSensorVisible)) {
@@ -1245,8 +1390,13 @@ function drawLaserScan(scan, scanPose = (
clearLaserScan();
const sourcePose = scanPose || currentRobotPose;
const renderPose = REAL_RUNTIME && scanPose
? { ...sourcePose, z: (sourcePose.z || 0) - MKMINI_LASER_RENDER_Z_OFFSET }
const renderPose = USE_MKMINI_MODEL && scanPose
? {
...sourcePose,
x: sourcePose.x + Math.cos(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_OFFSET,
y: sourcePose.y + Math.sin(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_OFFSET,
z: (sourcePose.z || 0) - MKMINI_LASER_RENDER_Z_OFFSET,
}
: sourcePose;
const origin = scanPose
? new THREE.Vector3(renderPose.x, renderPose.y, renderPose.z || 0)
@@ -1268,31 +1418,24 @@ function drawLaserScan(scan, scanPose = (
const start = origin.clone().addScaledVector(dir, startD);
const point = origin.clone().addScaledVector(dir, d);
endpoints.push({ start, point, hit: valid });
linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z);
// A range-max value represents open space, not an actual reflected beam.
if (valid) linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z);
}
const fillPoints = [];
const openFillPoints = [];
for (let i = 0; i < endpoints.length; i++) {
const curr = endpoints[i];
const next = endpoints[(i + 1) % endpoints.length];
const targetPoints = curr.hit && next.hit ? fillPoints : openFillPoints;
targetPoints.push(curr.start.x, curr.start.y, curr.start.z);
targetPoints.push(curr.point.x, curr.point.y, curr.point.z);
targetPoints.push(next.point.x, next.point.y, next.point.z);
targetPoints.push(curr.start.x, curr.start.y, curr.start.z);
targetPoints.push(next.point.x, next.point.y, next.point.z);
targetPoints.push(next.start.x, next.start.y, next.start.z);
if (!curr.hit || !next.hit) continue;
fillPoints.push(curr.start.x, curr.start.y, curr.start.z);
fillPoints.push(curr.point.x, curr.point.y, curr.point.z);
fillPoints.push(next.point.x, next.point.y, next.point.z);
fillPoints.push(curr.start.x, curr.start.y, curr.start.z);
fillPoints.push(next.point.x, next.point.y, next.point.z);
fillPoints.push(next.start.x, next.start.y, next.start.z);
}
const grp = new THREE.Group();
const openFillMat = new THREE.MeshBasicMaterial({
color: 0x3390ff,
transparent: true,
opacity: 0.18,
side: THREE.DoubleSide,
depthWrite: false,
});
const fillMat = new THREE.MeshBasicMaterial({
color: 0x005cff,
transparent: true,
@@ -1302,11 +1445,6 @@ function drawLaserScan(scan, scanPose = (
});
const lineMat = new THREE.LineBasicMaterial({ color: 0x3366ff, transparent: true, opacity: 0.5 });
if (openFillPoints.length) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(openFillPoints, 3));
grp.add(new THREE.Mesh(geo, openFillMat));
}
if (fillPoints.length) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(fillPoints, 3));
@@ -1318,7 +1456,7 @@ function drawLaserScan(scan, scanPose = (
const frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc)));
const front = endpoints[frontIndex];
if (front) {
if (front?.hit) {
const delta = new THREE.Vector3(
front.point.x - front.start.x,
front.point.y - front.start.y,
@@ -1549,6 +1687,12 @@ function updateMiniMapMetadata(map) {
function updateNavigationMapTransform() {
if (NAV2_VIEW_MODE !== 'navigation') return;
if (navigationSceneUsesMapFrame()) {
navigationMapRoot.position.set(0, 0, 0);
navigationMapRoot.rotation.set(0, 0, 0);
updateRemoteNavigationRoomTransform();
return;
}
const transform = navigationMapToOdom || { x:0, y:0, yaw:0 };
const yaw = transform.yaw || 0;
const c = Math.cos(yaw);
@@ -1567,8 +1711,19 @@ function updateNavigationMapTransform() {
function updateRemoteNavigationRoomTransform() {
if (!remoteNavigationSplatEnabled || !remoteNavigationSplatScene || NAV2_VIEW_MODE !== 'navigation') return;
if (navigationSceneUsesMapFrame()) {
if (remoteNavigationSplatTransform?.frame === 'map') return;
remoteNavigationSplatScene.position.copy(remoteNavigationSplatBasePosition);
remoteNavigationSplatScene.quaternion.copy(remoteNavigationSplatBaseQuaternion);
remoteNavigationSplatScene.scale.copy(remoteNavigationSplatBaseScale);
remoteNavigationSplatViewer.splatMesh.updateTransforms();
remoteNavigationSplatTransform = { frame: 'map' };
return;
}
const transform = navigationMapToOdom || { x:0, y:0, yaw:0 };
if (remoteNavigationSplatTransform
&& remoteNavigationSplatTransform.frame === 'odom'
&& Math.abs(remoteNavigationSplatTransform.x - (transform.x || 0)) < 0.03
&& Math.abs(remoteNavigationSplatTransform.y - (transform.y || 0)) < 0.03
&& Math.abs(remoteNavigationSplatTransform.yaw - (transform.yaw || 0)) < 0.01) {
@@ -1600,6 +1755,7 @@ function updateRemoteNavigationRoomTransform() {
);
remoteNavigationSplatViewer.splatMesh.updateTransforms();
remoteNavigationSplatTransform = {
frame: 'odom',
x: transform.x || 0,
y: transform.y || 0,
yaw,
@@ -1656,8 +1812,10 @@ function renderNavigationOccupancyMap() {
updateNavigationMapTransform();
if (!navigationMapFocused) {
const odomCenter = mapToOdom({ x:centerX, y:centerY, yaw:originYaw });
const center = sdfPointToWorld(odomCenter.x, odomCenter.y, 0);
const sceneCenter = navigationSceneUsesMapFrame()
? { x:centerX, y:centerY }
: mapToOdom({ x:centerX, y:centerY, yaw:originYaw });
const center = sdfPointToWorld(sceneCenter.x, sceneCenter.y, 0);
const distance = Math.max(width, height) * 1.1;
controls.target.copy(center);
camera.position.set(center.x + distance * .62, center.y + distance * .86, center.z + distance * .62);
@@ -2040,7 +2198,7 @@ function placeNavigationGoalMarker(x, y, yaw = 0) {
function updateNavigationGoalMarker() {
if (!navigationGoalMarker || !navigationGoalPose) return;
const markerPose = NAV2_VIEW_MODE === 'navigation'
const markerPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame()
? mapToOdom(navigationGoalPose)
: navigationGoalPose;
navigationGoalMarker.position.set(markerPose.x, markerPose.y, 0.05);
@@ -2055,9 +2213,9 @@ function setNavigationGoalPreview(pose) {
);
sdfRoot.add(navigationGoalPreview);
}
const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose;
navigationGoalPreview.position.set(odomPose.x, odomPose.y, 0.06);
navigationGoalPreview.rotation.z = odomPose.yaw || 0;
const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
navigationGoalPreview.position.set(scenePose.x, scenePose.y, 0.06);
navigationGoalPreview.rotation.z = scenePose.yaw || 0;
navigationGoalPreview.visible = true;
}
@@ -2098,8 +2256,8 @@ function setObstaclePreview(pose) {
);
sdfRoot.add(obstaclePreview);
}
const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose;
obstaclePreview.position.set(odomPose.x, odomPose.y, obstaclePreviewTemplate.height / 2);
const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
obstaclePreview.position.set(scenePose.x, scenePose.y, obstaclePreviewTemplate.height / 2);
obstaclePreview.rotation.z = obstaclePreviewTemplate.yaw;
obstaclePreview.visible = true;
}
@@ -2122,8 +2280,8 @@ function updateNavigationPlan(plan) {
}
if (poses.length < 2) return;
const points = poses.map((pose) => {
const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose;
return new THREE.Vector3(odomPose.x, odomPose.y, 0.07);
const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
return new THREE.Vector3(scenePose.x, scenePose.y, 0.07);
});
navigationPlanLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(points),
@@ -2150,19 +2308,29 @@ function pickNavigationGoal(event) {
const worldPoint = new THREE.Vector3();
if (!navigationRaycaster.ray.intersectPlane(navigationGroundPlane, worldPoint)) return null;
const sdfPoint = worldPointToSdf(worldPoint);
const mapPoint = NAV2_VIEW_MODE === 'navigation'
const mapPoint = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame()
? odomToMap({ x: sdfPoint.x, y: sdfPoint.y, yaw: currentRobotPose.yaw || 0 })
: sdfPoint;
const robotMapPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame()
? odomToMap(currentRobotPose)
: currentRobotPose;
const headingToGoal = Math.atan2(
mapPoint.y - (robotMapPose?.y || 0),
mapPoint.x - (robotMapPose?.x || 0),
);
return {
x: mapPoint.x,
y: mapPoint.y,
yaw: mapPoint.yaw || 0,
// A Dubins-based Nav2 planner must know which way the vehicle should face
// at the destination. The clicked ground point has no yaw, so using zero
// forced every goal to face east and made otherwise valid paths fail.
yaw: Number.isFinite(headingToGoal) ? headingToGoal : (mapPoint.yaw || 0),
};
}
function showInitialPosePreview(pose) {
if (!posePreviewModel) return;
const scenePose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose;
const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
posePreviewModel.position.set(scenePose.x, scenePose.y, 0.01);
posePreviewModel.rotation.set(0, 0, scenePose.yaw || 0);
posePreviewModel.visible = true;
@@ -2235,7 +2403,7 @@ async function sendInitialPose(pose) {
hideInitialPosePreview();
return;
}
const gazeboPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose;
const gazeboPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
const requestId = `initial-pose-${Date.now()}-${++initialPoseRequestSequence}`;
await new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
@@ -2397,11 +2565,11 @@ renderer.domElement.addEventListener('click', async (event) => {
if (!goal) return;
try {
if (obstaclePlacementMode) {
const sdfPoint = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(goal) : goal;
const scenePoint = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(goal) : goal;
const obstacle = {
id: `runtime-wall-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
x: sdfPoint.x,
y: sdfPoint.y,
x: scenePoint.x,
y: scenePoint.y,
...(obstaclePreviewTemplate ?? createObstaclePreviewTemplate()),
};
window.parent.postMessage({ source:'autonomy-slam-viewer', type:'runtime-obstacle-add', obstacle }, '*');
@@ -2422,6 +2590,7 @@ renderer.domElement.addEventListener('click', async (event) => {
function applySlamState(state) {
latestSlamState = state;
updateRosTopicStatus(state?.topic_status);
if (
navigationGoalMarker
&& navigationGoalId
@@ -2437,11 +2606,13 @@ function applySlamState(state) {
updateNavigationMapTransform();
}
updateNavigationPlan(state?.plan);
// Navigation is rendered in odom so the map, robot, path and goal share
// one scene frame. SLAM keeps its map-frame pose for the live map view.
// Remote navigation is rendered in the map frame, matching the saved map,
// room model, AMCL pose, route and goal. Local Gazebo remains odom-based.
const visualizationPose = SHOW_POINT_CLOUD
? (state?.pose_3d || state?.pose)
: (NAV2_VIEW_MODE === 'navigation' ? state?.pose : (state?.pose_map || state?.pose));
: (NAV2_VIEW_MODE === 'navigation'
? (navigationSceneUsesMapFrame() ? (state?.pose_map || state?.pose) : state?.pose)
: (state?.pose_map || state?.pose));
if (visualizationPose) setRobotPose(visualizationPose);
if (state?.scan) {
latestScanMetadata = state.scan;
@@ -2658,7 +2829,7 @@ const worldSceneTasks = CUSTOM_WORLD || REAL_RUNTIME
const remoteNavigationSceneTasks = [];
Promise.all(EDITOR_MODE
? worldSceneTasks
: [...worldSceneTasks, ...remoteNavigationSceneTasks, (REAL_RUNTIME ? loadMkMiniModel : loadBurgerModel)({ x:-2.0, y:-0.5, z:0.01, roll:0, pitch:0, yaw:0 })]
: [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })]
).then(() => {
if (EDITOR_MODE) {
window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*');
+954 -188
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -233,12 +233,14 @@ export const QaInputBox = memo(function QaInputBox({
dark,
busy,
disabled,
accent = 'blue',
onStop,
onSubmit
}: {
dark: boolean
busy: boolean
disabled: boolean
accent?: 'blue' | 'cyan'
onStop: () => void
onSubmit: (content: string) => void
}): React.JSX.Element {
@@ -279,7 +281,12 @@ export const QaInputBox = memo(function QaInputBox({
type="button"
disabled={!canSubmit}
onClick={busy ? onStop : submit}
className="grid w-10 shrink-0 cursor-pointer place-items-center bg-[#2563eb] text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:opacity-45 disabled:hover:bg-[#2563eb]"
className={[
'grid w-10 shrink-0 cursor-pointer place-items-center text-white transition disabled:cursor-default disabled:opacity-45',
accent === 'cyan'
? 'bg-[#0e7490] hover:bg-[#0891b2] disabled:hover:bg-[#0e7490]'
: 'bg-[#2563eb] hover:bg-[#1d4ed8] disabled:hover:bg-[#2563eb]'
].join(' ')}
>
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
</button>
+19 -7
View File
@@ -30,7 +30,7 @@ export default function Sidebar({
sessionStartedAt?: string | null
sessionEndedAt?: string | null
sessionEndsAt?: string | null
runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }>
runningSessions?: Array<{ id: string; title: string; runningModuleCount?: number; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }>
canReleaseControl?: boolean
onOpenSession?: (sessionId?: string) => void
onDisconnectSession?: (sessionId?: string) => void
@@ -138,7 +138,7 @@ export default function Sidebar({
<button
type="button"
onClick={() => onOpenSession?.(session.id)}
className="flex min-h-[54px] w-full cursor-pointer items-center gap-3 rounded-lg border border-white/10 bg-white/[0.05] py-3 pl-3 pr-11 text-left transition hover:border-white/20 hover:bg-white/[0.08]"
className="flex min-h-[68px] w-full cursor-pointer items-center gap-3 rounded-lg border border-white/10 bg-white/[0.05] py-3 pl-3 pr-11 text-left transition hover:border-white/20 hover:bg-white/[0.08]"
>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
@@ -146,10 +146,16 @@ export default function Sidebar({
<span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
</span>
{(session.startedAt || session.endsAt) && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">
{[session.startedAt ? `시작 ${session.startedAt}` : null, session.endsAt ? `종료 ${session.endsAt}` : null].filter(Boolean).join(' · ')}
<span className="mt-1 block truncate text-[11px] text-white/38">
{[
session.startedAt ? `시작 ${session.startedAt}` : null,
session.endsAt ? `종료 ${session.endsAt}` : null
].filter(Boolean).join(' · ')}
</span>
)}
{session.runningModuleCount !== undefined && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">실행 중인 모듈 {session.runningModuleCount}개</span>
)}
</span>
</button>
{sessionStatus === 'running' && (
@@ -191,7 +197,7 @@ export default function Sidebar({
setSessionPopoverOpen(false)
onOpenSession?.(session.id)
}}
className="flex min-h-[54px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]"
className="flex min-h-[68px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]"
>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
@@ -199,10 +205,16 @@ export default function Sidebar({
<span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
</span>
{(session.startedAt || session.endsAt) && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">
{[session.startedAt ? `시작 ${session.startedAt}` : null, session.endsAt ? `종료 ${session.endsAt}` : null].filter(Boolean).join(' · ')}
<span className="mt-1 block truncate text-[11px] text-white/38">
{[
session.startedAt ? `시작 ${session.startedAt}` : null,
session.endsAt ? `종료 ${session.endsAt}` : null
].filter(Boolean).join(' · ')}
</span>
)}
{session.runningModuleCount !== undefined && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">실행 중인 모듈 {session.runningModuleCount}개</span>
)}
</span>
</button>
{sessionStatus === 'running' && (
+140 -8
View File
@@ -1,5 +1,5 @@
import icon from '/assets/icon.png'
import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, HelpCircle, LogOut, Minus, Search, Square, X } from 'lucide-react'
import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, Clipboard, HelpCircle, LogOut, Minus, RotateCw, ScrollText, Search, Square, Wrench, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Tooltip from './Tooltip'
@@ -97,7 +97,13 @@ const normalizeNotification = (notification: NotificationMessage): NotificationM
export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCourseSelection, transientNotice = null }: TitleBarProps): React.JSX.Element {
const [notificationOpen, setNotificationOpen] = useState(false)
const [accountOpen, setAccountOpen] = useState(false)
const [toolsOpen, setToolsOpen] = useState(false)
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const [appLogsOpen, setAppLogsOpen] = useState(false)
const [appLogsLoading, setAppLogsLoading] = useState(false)
const [appLogContent, setAppLogContent] = useState('')
const [appLogPath, setAppLogPath] = useState('')
const [appLogsCopied, setAppLogsCopied] = useState(false)
const [notifications, setNotifications] = useState<NotificationMessage[]>([])
const [localNotifications, setLocalNotifications] = useState<NotificationMessage[]>([])
const [notificationModalOpen, setNotificationModalOpen] = useState(false)
@@ -111,6 +117,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
const [transientNoticeVisible, setTransientNoticeVisible] = useState(false)
const notificationRef = useRef<HTMLDivElement | null>(null)
const accountRef = useRef<HTMLDivElement | null>(null)
const toolsRef = useRef<HTMLDivElement | null>(null)
const enqueuedTransientNoticeIdsRef = useRef<Set<number>>(new Set())
const knownNotificationIdsRef = useRef<Set<string>>(new Set())
const notificationHydratedRef = useRef(false)
@@ -148,6 +155,32 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
const affiliationLabel = formatAffiliation(user?.primaryAffiliation)
const winButton =
'grid h-7 w-7 cursor-pointer place-items-center rounded text-white/50 transition hover:bg-white/[0.06] hover:text-white'
const loadAppLogs = useCallback(async (): Promise<void> => {
if (!window.appDiagnostics) {
setAppLogContent('현재 앱에서는 로그 조회 기능을 사용할 수 없습니다. LMS 앱을 최신 버전으로 다시 실행하세요.')
return
}
setAppLogsLoading(true)
try {
const result = await window.appDiagnostics.read()
setAppLogContent(result.content)
setAppLogPath(result.path)
} catch (error) {
setAppLogContent(`로그를 읽지 못했습니다.\n${error instanceof Error ? error.message : String(error)}`)
} finally {
setAppLogsLoading(false)
}
}, [])
const handleCopyAppLogs = useCallback((): void => {
if (!appLogContent) return
void navigator.clipboard.writeText(appLogContent)
.then(() => {
setAppLogsCopied(true)
window.setTimeout(() => setAppLogsCopied(false), 1600)
})
.catch((error) => console.error('[app-logs] copy failed', error))
}, [appLogContent])
const enqueueTransientNotice = useCallback((notice: { id: number; title: string; tone?: 'info' | 'warning' | 'error' }): void => {
const title = notice.title.trim()
if (!title) return
@@ -325,6 +358,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
setNotificationOpen(false)
}
if (!accountRef.current?.contains(target)) setAccountOpen(false)
if (!toolsRef.current?.contains(target)) setToolsOpen(false)
}
window.addEventListener('pointerdown', handlePointerDown)
@@ -344,16 +378,17 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
setNotificationModalOpen(false)
return
}
if (notificationOpen || accountOpen) {
if (notificationOpen || accountOpen || toolsOpen) {
event.preventDefault()
setNotificationOpen(false)
setAccountOpen(false)
setToolsOpen(false)
}
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [accountOpen, notificationModalOpen, notificationOpen, showCloseConfirm])
}, [accountOpen, notificationModalOpen, notificationOpen, showCloseConfirm, toolsOpen])
const handleNotificationClick = (): void => {
setNotificationOpen((current) => !current)
@@ -495,20 +530,60 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
<HelpCircle size={16} />
</button>
</Tooltip>
<Tooltip label="개발자 도구 열기">
<div className="relative" ref={toolsRef}>
<Tooltip label="도구 보기" disabled={toolsOpen}>
<button
type="button"
aria-label="Open Developer Tools"
aria-label="도구 보기"
aria-expanded={toolsOpen}
onClick={() => {
setToolsOpen((current) => !current)
setNotificationOpen(false)
setAccountOpen(false)
}}
className={winButton}
>
<Wrench size={15} />
</button>
</Tooltip>
{toolsOpen ? (
<div className="absolute right-0 top-[34px] z-50 w-[164px] overflow-hidden rounded-lg border border-white/10 bg-[rgba(18,23,35,0.97)] p-1 shadow-[0_18px_42px_rgba(0,0,0,0.42)] backdrop-blur-xl">
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
onClick={() => window.location.reload()}
>
<RotateCw size={15} />
새로고침
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
onClick={() => {
setToolsOpen(false)
setAppLogsOpen(true)
void loadAppLogs()
}}
>
<ScrollText size={15} />
앱 로그
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
onClick={() => {
setToolsOpen(false)
void window.titlebar.openDevTools().then((result) => {
if (!result.opened) console.error('[titlebar] open DevTools failed:', result.message)
}).catch((error) => console.error('[titlebar] open DevTools request failed:', error))
}}
className={winButton}
>
<Bug size={15} />
개발자 도구
</button>
</Tooltip>
</div>
) : null}
</div>
<div className="relative" ref={notificationRef}>
<Tooltip label="알림" disabled={notificationOpen}>
<button
@@ -742,7 +817,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
<header className="flex h-[70px] shrink-0 items-center justify-between px-5">
<div>
<h2 className="text-[16px] font-bold text-white/90">전체 알림</h2>
<p className="mt-1 text-[11px] font-medium text-white/42">알림은 최대 하루동안만 저장됩니다. 조회기간: 현재부터 24시간 전까지</p>
<p className="mt-1 text-[11px] font-medium text-white/42">알림은 최대 하루동안만 저장됩니다.</p>
</div>
<button
type="button"
@@ -870,6 +945,63 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
</div>
) : null}
{appLogsOpen ? (
<div
className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<section className="flex h-[min(680px,calc(100vh-100px))] w-[min(920px,calc(100vw-80px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#171a21] shadow-[0_28px_90px_rgba(0,0,0,0.58)]">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-white/10 px-5">
<div className="min-w-0">
<h2 className="text-[14px] font-bold text-white/92">앱 로그</h2>
<p className="mt-0.5 truncate text-[11px] font-medium text-white/42">{appLogPath || '로그 파일을 불러오는 중입니다.'}</p>
</div>
<button
type="button"
onClick={() => setAppLogsOpen(false)}
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white"
aria-label="앱 로그 닫기"
>
<X size={18} />
</button>
</header>
<div className="min-h-0 flex-1 bg-[#101217] p-4">
{appLogsLoading ? (
<div className="flex h-full items-center justify-center text-[13px] font-medium text-white/48">로그를 불러오는 중입니다.</div>
) : appLogContent ? (
<pre className="h-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-white/[0.07] bg-black/20 p-3 font-mono text-[11px] leading-5 text-white/70">
{appLogContent}
</pre>
) : (
<div className="flex h-full items-center justify-center rounded-md border border-dashed border-white/10 text-[13px] font-medium text-white/42">
아직 기록된 앱 로그가 없습니다.
</div>
)}
</div>
<footer className="flex h-[58px] shrink-0 items-center justify-end gap-2 border-t border-white/10 px-5">
<button
type="button"
onClick={handleCopyAppLogs}
disabled={!appLogContent}
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
>
<Clipboard size={14} />
{appLogsCopied ? '복사됨' : '복사'}
</button>
<button
type="button"
onClick={() => void loadAppLogs()}
disabled={appLogsLoading}
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
>
<RotateCw size={14} className={appLogsLoading ? 'animate-spin' : ''} />
새로고침
</button>
</footer>
</section>
</div>
) : null}
{showCloseConfirm ? (
<div
className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]"
+57 -9
View File
@@ -15,6 +15,7 @@ type ModulePrebuiltArtifact = {
fileName?: string | null
checksumSha256?: string | null
extractPath?: string | null
architecture?: string | null
required?: boolean | null
}
@@ -31,6 +32,24 @@ type ModuleInstallOptions = {
runtimeImages?: RuntimeImageArtifact[] | null
}
type RosNetworkConfig = {
enabled?: boolean | null
peer?: RemoteWorkspaceTarget | null
}
type SavedMapArtifactName = 'yaml' | 'pgm' | 'database'
type SavedMapArtifactPath = {
name: SavedMapArtifactName
path: string
}
type SavedMapArtifactDownload = {
name: SavedMapArtifactName
fileName: string
url: string
}
interface Window {
launcherAuth?: {
appUrl: string | null
@@ -47,6 +66,9 @@ interface Window {
openDevTools: () => Promise<{ opened: boolean; message?: string }>
close: () => void
}
appDiagnostics?: {
read: () => Promise<{ path: string; content: string }>
}
appLifecycle: {
quit: () => void
}
@@ -105,12 +127,12 @@ interface Window {
}>
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 }>
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 }>
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => Promise<{ ok: boolean; message: string }>
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
stopRunning: () => Promise<{ ok: boolean; message: string }>
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }>
}
@@ -137,7 +159,7 @@ interface Window {
}>
message?: string
}>
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => Promise<{
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => Promise<{
ok: boolean
message: string
commit?: {
@@ -148,6 +170,21 @@ interface Window {
message: string
}
}>
teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => Promise<{
ok: boolean
message: string
commit?: {
hash: string
shortHash: string
author: string
date: string
message: string
}
}>
teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{
ok: boolean
message: string
}>
restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked') => Promise<{
ok: boolean
message: string
@@ -198,7 +235,7 @@ interface Window {
stack: 'autoware' | 'nav2' | 'slam' | null
message?: string
}>
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => Promise<{
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{
ok: boolean
running: boolean
stack: 'autoware' | 'nav2' | 'slam' | null
@@ -223,6 +260,10 @@ interface Window {
read: (workspaceRoot?: string) => Promise<WorldLayout>
write: (workspaceRoot: string | undefined, layout: WorldLayout) => Promise<WorldLayout>
}
maps: {
readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
}
visualization: Window['visualization']
git: Window['git']
terminal: Window['terminal']
@@ -237,6 +278,10 @@ interface Window {
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']>
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
}
maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
}
modules: {
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => ReturnType<Window['modules']['list']>
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
@@ -257,22 +302,25 @@ interface Window {
}
}>
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 }>
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => Promise<{ ok: boolean; workspacePath: string }>
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | 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 }>
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => Promise<{ ok: boolean; message: string }>
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
}
visualization: {
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
}
git: {
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType<Window['git']['status']>
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ReturnType<Window['git']['submit']>
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ReturnType<Window['git']['submit']>
teamPublish: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ReturnType<Window['git']['teamPublish']>
teamPull: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['git']['teamPull']>
restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked') => ReturnType<Window['git']['restoreFile']>
submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ReturnType<Window['git']['submissions']>
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType<Window['git']['commitDiff']>
+11 -11
View File
@@ -80,38 +80,38 @@ export default function AiGuideView(props: Props): React.JSX.Element {
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]">
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="rounded-lg border border-violet-300/20 bg-violet-400/[0.08] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-violet-200">현재 학습</span>
<aside data-help="ai-guide-context" className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]">
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="rounded-lg border border-cyan-300/18 bg-cyan-400/[0.07] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-cyan-200">현재 학습</span>
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
</div>
<button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-emerald-300" /><span className="min-w-0 flex-1">새 채팅</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>
<button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">새 채팅</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>
<div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">빠른 질문</span><div className="mt-2 grid gap-1">
{quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">{label}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)}
</div></div>
<div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">최근 채팅</span><div className="mt-2 grid gap-1">
{activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span></button> : null}
{history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="채팅 삭제" aria-label="채팅 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)}
{activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span></button> : null}
{history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="채팅 삭제" aria-label="채팅 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)}
</div></div>
</div>
</aside>
<main className="relative flex min-w-0 flex-1 flex-col bg-[#10131b]">
<main data-help="ai-guide-conversation" className="relative flex min-w-0 flex-1 flex-col bg-[#10131b]">
<div ref={conversationScrollRef} onScroll={(event) => {
const target = event.currentTarget
followConversationRef.current = target.scrollHeight - target.scrollTop - target.clientHeight < 96
}} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7">
}} className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7">
{loading ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 채팅을 불러오는 중</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">AI 가이드를 준비하고 있습니다</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
{messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2><p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p></div>}
{messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>}</><ExpandableMarkdownMessage content={message.content || '응답을 작성하고 있습니다.'} dark defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">응답이 중단됨</p>}</article>)}
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>}
</div>}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact accent="cyan" busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span></div>}
</main>
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">질문 목록</span></div><div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
<aside data-help="ai-guide-questions" className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">질문 목록</span></div><div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
</div>
{deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#1b2a42] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">채팅 삭제</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">이 채팅 내역을 삭제할까요?</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 채팅'}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 채팅과 답변은 복구할 수 없습니다.</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">삭제</button></div></div></section></div>}
</section>
@@ -19,7 +19,6 @@ type CurriculumHomeWeek = {
description: string
}>
deliverable: string
requiresSourceSubmission?: boolean
sourceSubmitted?: boolean
sourceSubmittedAt?: string | null
}
@@ -1,4 +1,4 @@
import { BookOpen, Car, Clock3, FileText, Play, Upload } from 'lucide-react'
import { BookOpen, Car, Clock3, FileText, Play } from 'lucide-react'
import type React from 'react'
type LearningInfoWeek = {
@@ -7,10 +7,6 @@ type LearningInfoWeek = {
tags: string[]
progress: number
duration: string
detail: {
requiresSourceSubmission?: boolean
sourceSubmitted?: boolean
}
}
type LearningInfoCardProps = {
@@ -59,24 +55,16 @@ export default function LearningInfoCard({
</div>
</div>
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] pb-2 pt-2">
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] py-1">
{[
{ label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car },
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 },
...(week.detail.requiresSourceSubmission
? [{
label: '제출 여부',
value: week.detail.sourceSubmitted ? '제출 완료' : '미제출',
Icon: Upload,
valueClassName: week.detail.sourceSubmitted ? 'text-[#178c78]' : 'text-[#e11d48]'
}]
: [])
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 }
].map(({ label, value, Icon, valueClassName }) => (
<div
key={label}
className={[
'flex items-center justify-between gap-3 border-b border-[#edf0f5] px-3.5 last:border-b-0',
label === '제출 여부' ? 'min-h-[42px] pb-1 pt-0' : 'min-h-[30px] py-0'
'min-h-[34px] py-0'
].join(' ')}
>
<span className="flex min-w-0 items-center gap-2 text-[12px] font-medium text-[#4f596b]">
@@ -52,7 +52,6 @@ export type CurriculumWeek = {
references?: DownloadResource[]
exampleCodes?: DownloadResource[]
deliverable: string
requiresSourceSubmission: boolean
sourceSubmitted?: boolean
sourceSubmittedAt?: string | null
}
@@ -69,6 +68,7 @@ export type CurriculumWeek = {
dockerImage?: string | null
packageName?: string | null
launchFile?: string | null
runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null
}>
environmentProfiles?: EnvironmentProfile[]
maps?: Array<{
@@ -81,6 +81,7 @@ export type CurriculumWeek = {
custom?: boolean
layout?: unknown
}>
isTeamProject?: boolean
}
export type CurriculumResponse = {
@@ -102,6 +103,10 @@ export type CurriculumResponse = {
activeWeek?: number | null
activeLearningStep?: string | null
learningStats?: LearningStat[]
studentGroup?: {
id: string
name: string
} | null
platforms?: PlatformOption[]
}
+40 -1
View File
@@ -2,10 +2,12 @@ import type { DriveStep } from 'driver.js'
export function getContextHelpSteps({
platformModalOpen,
localPcLayoutOpen
localPcLayoutOpen,
aiGuideOpen
}: {
platformModalOpen: boolean
localPcLayoutOpen: boolean
aiGuideOpen: boolean
}): DriveStep[] {
if (platformModalOpen) {
return [
@@ -20,6 +22,35 @@ export function getContextHelpSteps({
]
}
if (aiGuideOpen) {
return [
{
element: '[data-help="ai-guide-context"]',
popover: {
title: '학습 맥락과 빠른 질문',
description: '현재 강좌와 주차 정보를 확인하고, 자주 묻는 질문을 바로 선택할 수 있습니다. 이전 대화도 이곳에서 다시 열 수 있습니다.',
side: 'right'
}
},
{
element: '[data-help="ai-guide-conversation"]',
popover: {
title: 'AI 가이드 대화',
description: '학습 중 궁금한 내용을 입력하면 현재 강좌와 주차 맥락을 바탕으로 답변합니다.',
side: 'left'
}
},
{
element: '[data-help="ai-guide-questions"]',
popover: {
title: '질문 목록',
description: '이전에 입력한 질문 목록입니다. 항목을 누르면 해당 질문과 답변 위치로 바로 이동합니다.',
side: 'left'
}
}
]
}
if (localPcLayoutOpen) {
return [
{
@@ -46,6 +77,14 @@ export function getContextHelpSteps({
side: 'left'
}
},
{
element: '[data-help="module-parking"]',
popover: {
title: '모듈 보관',
description: '사용하지 않는 모듈을 이 영역으로 드래그하면 전체 설치·실행·중지 대상에서 제외됩니다. 보관한 모듈은 개별적으로 제어할 수 있습니다.',
side: 'left'
}
},
{
element: '[data-help="workspace-chat"]',
popover: {
@@ -83,7 +83,8 @@ export default function PlatformSelectionPanel({
const { id, status, action, state, iconKey } = platform
const Icon = PLATFORM_ICONS[iconKey] ?? Monitor
const locked = state === 'locked'
const statusPending = platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(id)
const statusPending = platform.platformKey !== 'futuredrive-local-pc'
&& !checkedRemotePlatformStatusIds.has(id)
const unavailable = !statusPending && isPlatformUnavailable(platform)
const actionDisabled = statusPending || locked || unavailable
const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id)
@@ -3,7 +3,13 @@ export type StudioSourceSettings = {
giteaBaseUrl: string
giteaOwner: string
gpuEnabled: boolean
editorWordWrap: boolean
editorFontSize: number
editorTabSize: number
editorMinimap: boolean
rosDomainId: string
rosLinkedPlatformId?: string | null
visualizationBridgeLocation?: 'local' | 'platform'
agentUrl?: string | null
sshUser?: string | null
sshPort?: string | null
@@ -18,6 +24,14 @@ export type EnvironmentProfile = {
config: Record<string, unknown>
}
export type VehicleRuntimeProfile = {
profileKey: string
driveType: 'ackermann' | 'differential'
wheelbaseMeters: number
maxSpeedMetersPerSecond: number
maxSteeringAngleRadians: number
}
export type PlatformOption = {
id: string
platformKey: string
@@ -30,6 +44,7 @@ export type PlatformOption = {
state: 'ready' | 'request' | 'reserved' | 'locked'
sourceSettings?: Partial<StudioSourceSettings> | null
environmentProfiles?: EnvironmentProfile[]
vehicleRuntimeProfile?: VehicleRuntimeProfile | null
environmentConfigOverride?: Record<string, unknown> | null
connection?: {
registered: boolean
@@ -4,6 +4,7 @@ import type React from 'react'
type DeletableModule = {
name: string
state?: 'running' | 'stopped' | 'uninstalled'
}
type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
@@ -40,9 +41,11 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
{module.name} 삭제할까요?
</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
워크스페이스에 설치된 모듈 폴더가 삭제됩니다.
{module.state === 'uninstalled'
? '등록된 커스텀 모듈 정보를 삭제합니다.'
: '워크스페이스에 설치된 모듈 폴더가 삭제됩니다.'}
</p>
{allowRepositoryDeletion && <label
{allowRepositoryDeletion && module.state !== 'uninstalled' && <label
className={[
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3',
dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
@@ -1,6 +1,8 @@
import { Download, FolderOpen, Hammer, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type React from 'react'
import { createPortal } from 'react-dom'
export type ModuleListRowItem = {
state: 'running' | 'stopped' | 'uninstalled'
@@ -72,15 +74,73 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean)
}
function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
const anchorRef = useRef<HTMLSpanElement>(null)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
useEffect(() => {
const anchor = anchorRef.current?.parentElement
if (!(anchor instanceof HTMLElement)) return
const showTooltip = (): void => {
const bounds = anchor.getBoundingClientRect()
setPosition({
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
top: bounds.top - 8
})
}
const hideTooltip = (): void => setPosition(null)
anchor.addEventListener('mouseenter', showTooltip)
anchor.addEventListener('mouseleave', hideTooltip)
anchor.addEventListener('focus', showTooltip)
anchor.addEventListener('blur', hideTooltip)
return () => {
anchor.removeEventListener('mouseenter', showTooltip)
anchor.removeEventListener('mouseleave', hideTooltip)
anchor.removeEventListener('focus', showTooltip)
anchor.removeEventListener('blur', hideTooltip)
}
}, [])
useEffect(() => {
if (!position) return
const updateTooltipPosition = (): void => {
const anchor = anchorRef.current?.parentElement
if (!(anchor instanceof HTMLElement)) return
const bounds = anchor.getBoundingClientRect()
setPosition({
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
top: bounds.top - 8
})
}
window.addEventListener('resize', updateTooltipPosition)
window.addEventListener('scroll', updateTooltipPosition, true)
return () => {
window.removeEventListener('resize', updateTooltipPosition)
window.removeEventListener('scroll', updateTooltipPosition, true)
}
}, [position])
return (
<>
<span ref={anchorRef} aria-hidden="true" className="absolute h-0 w-0 overflow-hidden" />
{position && createPortal(
<span
role="tooltip"
style={{ left: position.left, top: position.top }}
className={[
'pointer-events-none absolute -top-8 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded px-2 py-1 text-[11px] font-bold opacity-0 shadow-[0_8px_18px_rgba(0,0,0,0.22)] transition group-hover/action:opacity-100',
'pointer-events-none fixed z-[10030] -translate-x-1/2 -translate-y-full whitespace-nowrap rounded px-2 py-1 text-[11px] font-bold shadow-[0_8px_18px_rgba(0,0,0,0.22)]',
dark ? 'bg-[#111315] text-white' : 'bg-[#111520] text-white'
].join(' ')}
>
{children}
</span>
</span>,
document.body
)}
</>
)
}
@@ -139,6 +199,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
? { label: module.sourceType === 'CUSTOM' ? '소스 생성' : '설치', Icon: Download, tone: 'install' as ModuleActionTone }
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
const canDeleteUninstalledCustomModule = module.sourceType === 'CUSTOM' && module.state === 'uninstalled'
return (
<div
@@ -185,7 +246,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
<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>
@@ -278,8 +339,38 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
)
})()
))}
{canDeleteUninstalledCustomModule && (
<button
type="button"
disabled={installActionDisabled}
onClick={(event) => {
event.stopPropagation()
onInstallModule(module)
}}
aria-label={installAction.label}
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
>
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
</button>
)}
</span>
<span className="flex justify-center">
<span className="flex justify-center gap-1.5">
{canDeleteUninstalledCustomModule ? (
<button
type="button"
disabled={busy}
onClick={(event) => {
event.stopPropagation()
onDeleteModule(module)
}}
aria-label="삭제"
className={actionButtonClass(dark, 'delete', busy)}
>
<Trash2 size={14} />
<TooltipLabel dark={dark}>삭제</TooltipLabel>
</button>
) : (
<button
type="button"
disabled={installActionDisabled}
@@ -297,6 +388,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
</button>
)}
</span>
</div>
)
@@ -1,4 +1,4 @@
import { CirclePlus, X } from 'lucide-react'
import { X } from 'lucide-react'
import { useEffect, useState } from 'react'
import type React from 'react'
import ModuleDeleteConfirm from './ModuleDeleteConfirm'
@@ -9,20 +9,27 @@ import ModuleListToolbar, { type ModuleFilter } from './ModuleListToolbar'
import ModuleParameterDialog from './ModuleParameterDialog'
import { createParameterId, parseModuleParameters } from './moduleParameters'
import type { ModuleParameterPair, ModuleRow } from './types'
import { WorkspaceMapPreview, type WorkspaceMapOption } from './WorldMapSelectorModal'
type ModuleListViewProps = {
dark: boolean
modules: ModuleRow[]
parkedModules: ModuleRow[]
loading: boolean
installingModuleName: string | null
controllingModuleNames: string[]
listError: string | null
installError: string | null
onDismissError: () => void
selectedMap: WorkspaceMapOption
showSelectedMapPreview?: boolean
allowRepositoryDeletion?: boolean
allowCustomModuleCreation?: boolean
onAddModuleTab: (module: ModuleRow) => void
onOpenSource: (module: ModuleRow) => void
onInstallModule: (module: ModuleRow) => void
onDeleteModule: (module: ModuleRow, options: { deleteRepository: boolean }) => void
getStoredParameters?: (module: ModuleRow) => ModuleParameterPair[] | undefined
onUpdateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => void
onStartModule: (module: ModuleRow) => void
onBuildModule: (module: ModuleRow) => void
@@ -32,6 +39,9 @@ type ModuleListViewProps = {
onStartAll: () => void
onStopAll: () => void
onReorderModules: (source: ModuleRow, target: ModuleRow) => void
onParkModule: (module: ModuleRow) => void
onRestoreParkedModule: (module: ModuleRow) => void
parkingHeightStorageKey?: string
onOpenWorldEditor: () => void
onCreateCustomModule: (input: CustomModuleInput) => Promise<void>
}
@@ -39,16 +49,22 @@ type ModuleListViewProps = {
export default function ModuleListView({
dark,
modules,
parkedModules,
loading,
installingModuleName,
controllingModuleNames,
listError,
installError,
onDismissError,
selectedMap,
showSelectedMapPreview = true,
allowRepositoryDeletion = true,
allowCustomModuleCreation = true,
onAddModuleTab,
onOpenSource,
onInstallModule,
onDeleteModule,
getStoredParameters,
onUpdateModuleParameters,
onStartModule,
onBuildModule,
@@ -58,6 +74,9 @@ export default function ModuleListView({
onStartAll,
onStopAll,
onReorderModules,
onParkModule,
onRestoreParkedModule,
parkingHeightStorageKey,
onOpenWorldEditor,
onCreateCustomModule
}: ModuleListViewProps): React.JSX.Element {
@@ -67,7 +86,10 @@ export default function ModuleListView({
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
const [dropZone, setDropZone] = useState<'active' | 'parking' | null>(null)
const [parkingHeight, setParkingHeight] = useState(232)
const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter)
const filteredParkedRows = moduleFilter === 'all' ? parkedModules : parkedModules.filter((row) => row.state === moduleFilter)
const canReorder = moduleFilter === 'all' && !loading
const uninstalledCount = modules.filter((row) => row.state === 'uninstalled').length
const runningCount = modules.filter((row) => row.state === 'running').length
@@ -75,6 +97,93 @@ export default function ModuleListView({
const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
useEffect(() => {
if (!parkingHeightStorageKey) {
setParkingHeight(232)
return
}
const storedValue = window.localStorage.getItem(parkingHeightStorageKey)
const storedHeight = storedValue === null ? Number.NaN : Number(storedValue)
setParkingHeight(Number.isFinite(storedHeight) ? Math.min(520, Math.max(84, storedHeight)) : 232)
}, [parkingHeightStorageKey])
const updateParkingHeight = (nextHeight: number): void => {
const height = Math.min(520, Math.max(84, nextHeight))
setParkingHeight(height)
if (!parkingHeightStorageKey) return
try {
window.localStorage.setItem(parkingHeightStorageKey, String(height))
} catch {
// Keep the resized height for the current workspace session.
}
}
const finishDrag = (): void => {
setDraggedModule(null)
setDropTargetModuleName(null)
setDropZone(null)
}
const beginParkingResize = (event: React.PointerEvent<HTMLDivElement>): void => {
event.preventDefault()
const startY = event.clientY
const startHeight = parkingHeight
const onPointerMove = (moveEvent: PointerEvent): void => {
updateParkingHeight(startHeight - (moveEvent.clientY - startY))
}
const onPointerUp = (): void => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
const renderModuleRow = (row: ModuleRow, parked: boolean): React.JSX.Element => (
<ModuleListRow
key={row.folderName ?? row.name}
dark={dark}
module={row}
draggable={canReorder}
dragging={draggedModule?.name === row.name}
dropTarget={dropTargetModuleName === row.name && draggedModule?.name !== row.name && dropZone === (parked ? 'parking' : 'active')}
onDragStart={() => setDraggedModule(row)}
onDragOver={() => {
if (!draggedModule || draggedModule.name === row.name) return
setDropTargetModuleName(row.name)
setDropZone(parked ? 'parking' : 'active')
}}
onDrop={() => {
if (draggedModule) {
if (parked) onParkModule(draggedModule)
else onRestoreParkedModule(draggedModule)
if (draggedModule.name !== row.name) onReorderModules(draggedModule, row)
}
finishDrag()
}}
onDragEnd={finishDrag}
installing={installingModuleName === row.name || (!parked && installingAll && row.state === 'uninstalled')}
controlling={controllingModuleNames.includes(row.name)}
bulkControlling={bulkControlling && !parked}
onAddModuleTab={onAddModuleTab}
onOpenSource={onOpenSource}
onOpenParameters={(module) => {
const storedParameters = getStoredParameters?.(module)
const parsedParameters = storedParameters ?? parseModuleParameters(module.parameters)
setParameterDialog({
module,
parameters: parsedParameters.length
? parsedParameters
: [{ id: createParameterId(), name: '', value: '', enabled: true }]
})
}}
onInstallModule={onInstallModule}
onDeleteModule={setDeleteConfirmModule}
onStartModule={onStartModule}
onBuildModule={onBuildModule}
onStopModule={onStopModule}
onRestartModule={onRestartModule}
/>
)
useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
@@ -100,15 +209,24 @@ export default function ModuleListView({
return (
<section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className={['flex h-[69px] items-center justify-between p-3', dark ? 'bg-[#252525]' : 'bg-[#eef1f6]'].join(' ')}>
<div className={['flex h-[69px] items-center justify-between gap-4 p-3', dark ? 'bg-[#252525]' : 'bg-[#eef1f6]'].join(' ')}>
<div>
<strong className={['block text-[13px] font-bold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>모듈 리스트</strong>
<span className={['mt-1 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>자율주행 알고리즘 모듈 관리</span>
</div>
{showSelectedMapPreview && (
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 text-right">
<span className={['block text-[10px] font-bold', dark ? 'text-white/40' : 'text-[#788396]'].join(' ')}>현재 맵</span>
<strong className={['block max-w-[176px] truncate text-[12px] font-bold', dark ? 'text-white/85' : 'text-[#263247]'].join(' ')}>{selectedMap.name}</strong>
</div>
<WorkspaceMapPreview map={selectedMap} selected={false} compact borderless />
</div>
)}
</div>
<div className="-mt-2 flex min-h-0 flex-1 overflow-auto">
<div className={['flex min-h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className="-mt-2 flex min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className={['flex h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<ModuleListToolbar
dark={dark}
filter={moduleFilter}
@@ -125,73 +243,89 @@ export default function ModuleListView({
/>
<ModuleListHeader dark={dark} />
{(listError || installError) && (
<div className={['border-b px-3 py-2 text-[12px] font-semibold', dark ? 'border-white/10 bg-[#3a2525] text-[#ffb4b4]' : 'border-[#ffd4d4] bg-[#fff4f4] text-[#b42323]'].join(' ')}>
{listError || installError}
<div className={['flex items-start justify-between gap-3 border-b px-3 py-2 text-[12px] font-semibold', dark ? 'border-white/10 bg-[#3a2525] text-[#ffb4b4]' : 'border-[#ffd4d4] bg-[#fff4f4] text-[#b42323]'].join(' ')}>
<p className="min-w-0 break-words">{listError || installError}</p>
<button
type="button"
aria-label="오류 닫기"
onClick={onDismissError}
className={['mt-0.5 shrink-0 cursor-pointer transition hover:opacity-100', dark ? 'text-[#ffb4b4]/60' : 'text-[#b42323]/60'].join(' ')}
>
<X size={15} />
</button>
</div>
)}
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-0 flex-1 flex-col'].join(' ')}>
<div className="flex min-h-0 flex-1 flex-col">
<div
className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto"
onDragOver={(event) => {
if (!draggedModule) return
event.preventDefault()
setDropTargetModuleName(null)
setDropZone('active')
}}
onDrop={(event) => {
if (!draggedModule) return
event.preventDefault()
onRestoreParkedModule(draggedModule)
finishDrag()
}}
>
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-full flex-col'].join(' ')}>
{loading ? (
<ModuleListEmptyState dark={dark} loading />
) : filteredModuleRows.length === 0 ? (
<ModuleListEmptyState dark={dark} />
) : filteredModuleRows.map((row) => (
<ModuleListRow
key={row.name}
dark={dark}
module={row}
draggable={canReorder}
dragging={draggedModule?.name === row.name}
dropTarget={dropTargetModuleName === row.name && draggedModule?.name !== row.name}
onDragStart={() => setDraggedModule(row)}
onDragOver={() => {
if (draggedModule && draggedModule.name !== row.name) setDropTargetModuleName(row.name)
}}
onDrop={() => {
if (draggedModule && draggedModule.name !== row.name) onReorderModules(draggedModule, row)
setDraggedModule(null)
setDropTargetModuleName(null)
}}
onDragEnd={() => {
setDraggedModule(null)
setDropTargetModuleName(null)
}}
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
controlling={controllingModuleNames.includes(row.name)}
bulkControlling={bulkControlling}
onAddModuleTab={onAddModuleTab}
onOpenSource={onOpenSource}
onOpenParameters={(module) => {
const parsedParameters = parseModuleParameters(module.parameters)
setParameterDialog({
module,
parameters: parsedParameters.length
? parsedParameters
: [{ id: createParameterId(), name: '', value: '' }]
})
}}
onInstallModule={onInstallModule}
onDeleteModule={setDeleteConfirmModule}
onStartModule={onStartModule}
onBuildModule={onBuildModule}
onStopModule={onStopModule}
onRestartModule={onRestartModule}
/>
))}
{!loading && (
<div className={['flex justify-center pb-4 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
) : filteredModuleRows.map((row) => renderModuleRow(row, false))}
{!loading && allowCustomModuleCreation && (
<div className={['flex justify-end px-3 pb-6 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(' ')}
className={['inline-flex cursor-pointer items-center text-[12px] font-bold underline underline-offset-4 transition', dark ? 'text-[#6ea8ff] hover:text-white' : 'text-[#1d4ed8] hover:text-[#1e40af]'].join(' ')}
>
<CirclePlus size={14} aria-hidden="true" />
모듈 추가
+ 모듈 추가
</button>
</div>
)}
</div>
</div>
<div
data-help="module-parking"
className={['relative shrink-0 border-t-[3px]', dark ? 'border-white/20 bg-[#202020]' : 'border-[#c5cedc] bg-[#fbfcff]'].join(' ')}
style={{ height: parkingHeight }}
onDragOver={(event) => {
if (!draggedModule) return
event.preventDefault()
setDropTargetModuleName(null)
setDropZone('parking')
}}
onDrop={(event) => {
if (!draggedModule) return
event.preventDefault()
onParkModule(draggedModule)
finishDrag()
}}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label="보관 영역 크기 조절"
onPointerDown={beginParkingResize}
className={['absolute -top-2 left-0 right-0 z-10 h-5 cursor-row-resize', dropZone === 'parking' ? (dark ? 'bg-[#3b82f6]/16' : 'bg-[#3b82f6]/10') : ''].join(' ')}
/>
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'thin-y-scrollbar h-full overflow-y-auto'].join(' ')}>
{filteredParkedRows.length > 0 ? filteredParkedRows.map((row) => renderModuleRow(row, true)) : (
<div className={['grid h-full place-items-center px-4 text-center text-[12px] font-medium', dark ? 'text-white/28' : 'text-[#7b8799]/55'].join(' ')}>
모듈을 이곳으로 드래그하면 전체 제어 대상에서 제외됩니다
</div>
)}
</div>
</div>
</div>
</div>
</div>
{deleteConfirmModule && (
<ModuleDeleteConfirm
@@ -218,7 +352,7 @@ export default function ModuleListView({
}}
/>
)}
{moduleAddDialogOpen && (
{allowCustomModuleCreation && moduleAddDialogOpen && (
<ModuleAddDialog
dark={dark}
onClose={() => setModuleAddDialogOpen(false)}
@@ -30,7 +30,7 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
<section
className={[
'w-[460px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
'w-[500px] 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(' ')}
>
@@ -42,13 +42,14 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
</div>
<div className="p-4">
<div className="grid gap-2">
<div className={['grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px] gap-2 text-[11px] font-bold', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>
<div className={['grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_38px_32px] gap-2 text-[11px] font-bold', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>
<span>파라미터명</span>
<span>값</span>
<span className="text-center">사용</span>
<span />
</div>
{parameters.map((parameter) => (
<div key={parameter.id} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px] gap-2">
<div key={parameter.id} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_38px_32px] gap-2">
<input
value={parameter.name}
onChange={(event) => setParameters((items) => items.map((item) => item.id === parameter.id ? { ...item, name: event.target.value } : item))}
@@ -67,6 +68,14 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
].join(' ')}
placeholder=""
/>
<label className="flex h-8 cursor-pointer items-center justify-center" title="실행에 포함">
<input
type="checkbox"
checked={parameter.enabled !== false}
onChange={(event) => setParameters((items) => items.map((item) => item.id === parameter.id ? { ...item, enabled: event.target.checked } : item))}
className="h-3.5 w-3.5 cursor-pointer accent-[#2563eb]"
/>
</label>
<button
type="button"
onClick={() => setParameters((items) => items.filter((item) => item.id !== parameter.id))}
@@ -81,7 +90,7 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
))}
<button
type="button"
onClick={() => setParameters((items) => [...items, { id: createParameterId(), name: '', value: '' }])}
onClick={() => setParameters((items) => [...items, { id: createParameterId(), name: '', value: '', enabled: true }])}
className={[
'mt-1 flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md border 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]'
@@ -10,6 +10,10 @@ type Props = {
activeImagePreviewSource?: string
editorLanguage: string
editorValue: string | undefined
wordWrap: boolean
fontSize: number
tabSize: number
minimapEnabled: boolean
exampleActive: boolean
beforeMount: BeforeMount
onMount: OnMount
@@ -25,6 +29,10 @@ function WorkspaceEditorPane({
activeImagePreviewSource,
editorLanguage,
editorValue,
wordWrap,
fontSize,
tabSize,
minimapEnabled,
exampleActive,
beforeMount,
onMount,
@@ -77,13 +85,13 @@ function WorkspaceEditorPane({
options={{
automaticLayout: true,
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
fontSize: 13,
minimap: { enabled: true },
fontSize,
minimap: { enabled: minimapEnabled },
renderLineHighlight: 'all',
roundedSelection: false,
scrollBeyondLastLine: false,
tabSize: 4,
wordWrap: 'on',
tabSize,
wordWrap: wordWrap ? 'on' : 'off',
readOnly: exampleActive
}}
/>
@@ -249,7 +249,6 @@ function ModalButtonRow({
}
type SubmitConfirmModalProps = WorkspaceModalTheme & {
requiresSourceSubmission: boolean
submitting: boolean
onCancel: () => void
onConfirm: () => void
@@ -260,7 +259,6 @@ export function SubmitConfirmModal({
borderSoft,
textStrong,
textDim,
requiresSourceSubmission,
submitting,
onCancel,
onConfirm
@@ -275,14 +273,14 @@ export function SubmitConfirmModal({
>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
<Upload size={15} className="text-[#7aa7ff]" />
<strong className="text-[13px] font-bold">{requiresSourceSubmission ? '제출' : '학습 완료'}</strong>
<strong className="text-[13px] font-bold">학습 완료</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
{requiresSourceSubmission ? '변경사항을 제출할까요?' : '학습을 완료하시겠습니까?'}
현재 주차의 학습을 완료할까요?
</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
{requiresSourceSubmission ? '변경된 모듈만 원격 저장소에 제출합니다.' : '현재 주차의 학습 완료 상태로 기록합니다.'}
완료 후에는 이 주차의 학습을 다시 완료 처리할 수 없습니다.
</p>
<div className="mt-4 flex justify-end gap-2">
<button
@@ -301,7 +299,7 @@ export function SubmitConfirmModal({
onClick={onConfirm}
className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:opacity-60"
>
{requiresSourceSubmission ? '제출' : '완료'}
학습 완료
</button>
</div>
</div>
@@ -9,6 +9,7 @@ type WorkspaceNavProps = {
activeActivity: WorkspaceActivity
selectedModuleExists: boolean
installedGitModuleCount: number
teamProject: boolean
moduleTabCount: number
gitChangedFileCount: number
workspaceNavFlash: boolean
@@ -35,6 +36,7 @@ export default function WorkspaceNav({
activeActivity,
selectedModuleExists,
installedGitModuleCount,
teamProject,
moduleTabCount,
gitChangedFileCount,
workspaceNavFlash,
@@ -57,7 +59,7 @@ export default function WorkspaceNav({
id === 'workspace' ||
id === 'examples' ||
id === 'settings' ||
(id === 'git' && installedGitModuleCount > 0) ||
(id === 'git' && (teamProject || installedGitModuleCount > 0)) ||
((id === 'search' || id === 'logs') && selectedModuleExists)
return (
@@ -188,7 +188,7 @@ export default function WorkspaceQaPanel({
})}
</div>
</div>
<div ref={workspaceChatScrollRef} className="min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}>
<div ref={workspaceChatScrollRef} className="thin-y-scrollbar min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}>
{qaMessagesLoading ? (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
<div>
@@ -1,4 +1,5 @@
import type { StudioSourceSettings } from '../platforms/types'
import type { PlatformOption } from '../platforms/types'
import { StudioSettingsField, StudioSettingsSwitch } from './StudioSettingsControls'
type Props = {
@@ -9,9 +10,8 @@ type Props = {
platformName: string
configuredDraftWorkspaceRoot: string
sourceSettingsDraft: StudioSourceSettings
platformSourceSettingsDefaults: StudioSourceSettings
remotePlatforms: PlatformOption[]
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
onResetSourceSettings: (settings: StudioSourceSettings) => void
onSaveSourceSettings: () => void
}
@@ -23,9 +23,8 @@ function WorkspaceSettingsView({
platformName,
configuredDraftWorkspaceRoot,
sourceSettingsDraft,
platformSourceSettingsDefaults,
remotePlatforms,
onUpdateSourceSetting,
onResetSourceSettings,
onSaveSourceSettings
}: Props): React.JSX.Element {
return (
@@ -60,18 +59,22 @@ function WorkspaceSettingsView({
) : (
<>
<div className="mt-5 grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<StudioSettingsField
label="사용자 워크스페이스 루트"
description="모듈 소스와 빌드 결과가 저장되는 기본 경로입니다."
value={configuredDraftWorkspaceRoot}
onChange={(value) => onUpdateSourceSetting('workspaceRoot', value)}
dark={dark}
/>
<StudioSettingsField
label="ROS Domain ID"
description="같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다."
value={sourceSettingsDraft.rosDomainId}
onChange={(value) => onUpdateSourceSetting('rosDomainId', value.replace(/[^\d]/g, '').slice(0, 3))}
dark={dark}
/>
</div>
<StudioSettingsSwitch
label="GPU 가속"
description="ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다."
@@ -81,14 +84,93 @@ function WorkspaceSettingsView({
/>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => onResetSourceSettings(platformSourceSettingsDefaults)}
className={['h-9 cursor-pointer rounded-md border px-4 text-[12px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/7 hover:text-white' : 'border-[#d7dde8] bg-white text-[#4f596b] hover:bg-[#f8f9fc] hover:text-[#111520]'].join(' ')}
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>플랫폼</h3>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>연동 대상 플랫폼</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.</span>
<select
value={sourceSettingsDraft.rosLinkedPlatformId ?? ''}
onChange={(event) => onUpdateSourceSetting('rosLinkedPlatformId', event.target.value || null)}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
기본값
</button>
<option value="" className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>연동하지 않음</option>
{remotePlatforms.map((platform) => (
<option key={platform.id} value={platform.id} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{platform.title}</option>
))}
</select>
</label>
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>시각화 브리지 실행 위치</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>로컬과 플랫폼 모듈을 함께 쓸 때 실행 PC 브리지를 사용하세요.</span>
<select
value={sourceSettingsDraft.visualizationBridgeLocation ?? 'local'}
onChange={(event) => onUpdateSourceSetting('visualizationBridgeLocation', event.target.value === 'platform' ? 'platform' : 'local')}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
<option value="local" className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>실행 PC</option>
<option value="platform" disabled={!sourceSettingsDraft.rosLinkedPlatformId} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>연동 플랫폼</option>
</select>
</label>
</div>
</div>
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>에디터</h3>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>글꼴 크기</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>코드 편집기에 표시할 글자 크기입니다.</span>
<select
value={sourceSettingsDraft.editorFontSize}
onChange={(event) => onUpdateSourceSetting('editorFontSize', Number(event.target.value))}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
{[12, 13, 14, 15, 16].map((size) => (
<option key={size} value={size} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{size}px</option>
))}
</select>
</label>
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>탭 크기</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>탭 입력 시 사용할 공백 칸 수입니다.</span>
<select
value={sourceSettingsDraft.editorTabSize}
onChange={(event) => onUpdateSourceSetting('editorTabSize', Number(event.target.value))}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
{[2, 4, 8].map((size) => (
<option key={size} value={size} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{size}칸</option>
))}
</select>
</label>
</div>
<div className="mt-4">
<StudioSettingsSwitch
label="줄바꿈"
description="긴 줄을 편집기 너비에 맞춰 다음 줄에 표시합니다."
checked={sourceSettingsDraft.editorWordWrap}
onChange={(checked) => onUpdateSourceSetting('editorWordWrap', checked)}
dark={dark}
/>
<div className="mt-4">
<StudioSettingsSwitch
label="미니맵 표시"
description="편집기 오른쪽에 코드 축소 지도를 표시합니다."
checked={sourceSettingsDraft.editorMinimap}
onChange={(checked) => onUpdateSourceSetting('editorMinimap', checked)}
dark={dark}
/>
</div>
</div>
</div>
<div className="mt-8 flex justify-end">
<button
type="button"
onClick={onSaveSourceSettings}
@@ -1,4 +1,4 @@
import { RefreshCw } from 'lucide-react'
import { Download, RefreshCw, Upload } from 'lucide-react'
import type { ModuleRow } from './types'
type GitChangedFile = {
@@ -60,6 +60,11 @@ type Props = {
textStrong: string
textMid: string
textDim: string
teamProject: boolean
teamSyncing: boolean
gitSubmitting: boolean
learningSubmitted: boolean
hasInstalledGitModules: boolean
gitPanelTab: 'source' | 'history'
gitChangedFileCount: number
gitChangedModules: ModuleRow[]
@@ -77,6 +82,9 @@ type Props = {
selectedGitSubmissionDiff: string
onGitPanelTabChange: (tab: 'source' | 'history') => void
onRefresh: () => void
onTeamPublish: () => void
onTeamPull: () => void
onCommitPush: () => void
onSelectGitFile: (moduleName: string, filePath: string) => void
onOpenGitChangedFile: (moduleName: string, filePath: string) => void
onOpenGitFileContextMenu: (module: ModuleRow, file: GitChangedFile, x: number, y: number) => void
@@ -94,6 +102,11 @@ function WorkspaceSourceControlView({
textStrong,
textMid,
textDim,
teamProject,
teamSyncing,
gitSubmitting,
learningSubmitted,
hasInstalledGitModules,
gitPanelTab,
gitChangedFileCount,
gitChangedModules,
@@ -111,6 +124,9 @@ function WorkspaceSourceControlView({
selectedGitSubmissionDiff,
onGitPanelTabChange,
onRefresh,
onTeamPublish,
onTeamPull,
onCommitPush,
onSelectGitFile,
onOpenGitChangedFile,
onOpenGitFileContextMenu,
@@ -121,7 +137,8 @@ function WorkspaceSourceControlView({
getGitStatusTone,
formatGitCommitDate
}: Props): React.JSX.Element {
const refreshing = gitPanelTab === 'history' ? gitSubmissionsLoading : gitLoading
const refreshing = !teamProject && gitPanelTab === 'history' ? gitSubmissionsLoading : gitLoading
const historyLoading = teamProject ? gitLoading : gitSubmissionsLoading
return (
<section
@@ -133,7 +150,7 @@ function WorkspaceSourceControlView({
<div className="flex items-end">
{[
{ key: 'source' as const, label: '소스 제어' },
{ key: 'history' as const, label: '제출 내역' }
{ key: 'history' as const, label: teamProject ? '팀 변경 내역' : '제출 내역' }
].map((tab) => (
<button
key={tab.key}
@@ -155,6 +172,57 @@ function WorkspaceSourceControlView({
))}
</div>
<div className="flex items-center gap-2 pb-2">
{!teamProject && (
<button
type="button"
onClick={onCommitPush}
disabled={learningSubmitted || gitSubmitting || !hasInstalledGitModules || gitChangedFileCount === 0}
className={[
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
learningSubmitted || gitSubmitting || !hasInstalledGitModules || gitChangedFileCount === 0 ? 'cursor-default opacity-45' : 'cursor-pointer',
dark
? 'border-[#60a5fa]/45 bg-[#2563eb]/15 text-[#bfdbfe] hover:bg-[#2563eb]/26 hover:text-white'
: 'border-[#93c5fd] bg-[#eff6ff] text-[#1d5fae] hover:bg-[#dbeafe] hover:text-[#174a87]'
].join(' ')}
>
{gitSubmitting ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}
제출
</button>
)}
{teamProject && (
<>
<button
type="button"
onClick={onTeamPull}
disabled={teamSyncing || !hasInstalledGitModules}
className={[
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
teamSyncing || !hasInstalledGitModules ? 'cursor-default opacity-45' : 'cursor-pointer',
dark
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
<Download size={14} />
팀 변경 사항 받기
</button>
<button
type="button"
onClick={onTeamPublish}
disabled={learningSubmitted || teamSyncing || !hasInstalledGitModules || gitChangedFileCount === 0}
className={[
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
learningSubmitted || teamSyncing || !hasInstalledGitModules || gitChangedFileCount === 0 ? 'cursor-default opacity-45' : 'cursor-pointer',
dark
? 'border-[#60a5fa]/45 bg-[#2563eb]/15 text-[#bfdbfe] hover:bg-[#2563eb]/26 hover:text-white'
: 'border-[#93c5fd] bg-[#eff6ff] text-[#1d5fae] hover:bg-[#dbeafe] hover:text-[#174a87]'
].join(' ')}
>
{teamSyncing ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}
팀에 반영
</button>
</>
)}
<button
type="button"
onClick={onRefresh}
@@ -178,7 +246,7 @@ function WorkspaceSourceControlView({
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>변경 파일</span>
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>{teamProject ? '내 변경 파일' : '변경 파일'}</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}개</span>
</div>
@@ -189,7 +257,11 @@ function WorkspaceSourceControlView({
</div>
)}
{gitLoading && !Object.keys(gitModuleStatuses).length ? (
{!hasInstalledGitModules ? (
<div className={['grid min-h-full place-items-center px-6 text-center text-[12px] font-semibold leading-relaxed', textDim].join(' ')}>
공동 저장소는 모듈을 설치하면 생성됩니다.
</div>
) : gitLoading && !Object.keys(gitModuleStatuses).length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
Git 상태 불러오는 중
</div>
@@ -289,7 +361,7 @@ function WorkspaceSourceControlView({
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
<span className={['text-[12px] font-bold', textMid].join(' ')}>제출 목록</span>
<span className={['text-[12px] font-bold', textMid].join(' ')}>{teamProject ? '팀 변경 내역' : '제출 목록'}</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}개</span>
</div>
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
@@ -298,22 +370,22 @@ function WorkspaceSourceControlView({
{gitError}
</div>
)}
{gitSubmissionsLoading && !gitSubmissions.length ? (
{historyLoading && !gitSubmissions.length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
제출 내역 불러오는 중
{teamProject ? '팀 변경 내역 불러오는 중' : '제출 내역 불러오는 중'}
</div>
) : !gitSubmissionGroups.length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
제출 내역 없음
{teamProject ? '팀 변경 내역 없음' : '제출 내역 없음'}
</div>
) : (
<div className="grid gap-3">
{gitSubmissionGroups.map((group, groupIndex) => (
<div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
<div className={['px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
<p className={['text-[12px] font-black', textStrong].join(' ')}>제출 {gitSubmissionGroups.length - groupIndex}</p>
<p className={['text-[12px] font-black', textStrong].join(' ')}>{teamProject ? '팀 반영' : `제출 ${gitSubmissionGroups.length - groupIndex}`}</p>
<p className={['mt-0.5 text-[10px] font-bold', textDim].join(' ')}>
{formatGitCommitDate(group.submittedAt)} · {group.submissions.length}개 모듈
{formatGitCommitDate(group.submittedAt)} · {teamProject ? group.submissions[0]?.commit.author : `${group.submissions.length}개 모듈`}
</p>
</div>
<div className="grid gap-1 p-1.5">
@@ -118,10 +118,10 @@ export default function WorkspaceTopBar({
)}
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
{submitVisible && (
<Tooltip label="제출" side="bottom">
<Tooltip label="학습 완료" side="bottom">
<button
type="button"
aria-label="제출"
aria-label="학습 완료"
disabled={submitDisabled}
onClick={onSubmit}
className={[
@@ -1,30 +1,28 @@
import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react'
import { CirclePlay, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
import type React from 'react'
import { useEffect, useState } from 'react'
export type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d'
export type Nav2VisualizationMode = 'slam' | 'navigation'
type WorkspaceVisualizationPanelProps = {
dark: boolean
textStrong: string
textDim: string
iconButtonClass: string
minimized: boolean
large: boolean
position: { x: number; y: number }
size: { width: number; height: number }
stacks: VisualizationStack[]
activeStack: VisualizationStack
available: boolean
running: boolean
busy: boolean
viewerUrl: string
nav2Mode: Nav2VisualizationMode
navigationMapSelected: boolean
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
onToggleLarge: () => void
onMinimize: () => void
onClose: () => void
onStackChange: (stack: VisualizationStack) => void
onStart: () => void
onStop: () => void
onTerminate: () => Promise<void> | void
onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void
}
@@ -33,34 +31,32 @@ export default function WorkspaceVisualizationPanel({
textStrong,
textDim,
iconButtonClass,
minimized,
large,
position,
size,
stacks,
activeStack,
available,
running,
busy,
viewerUrl,
nav2Mode,
navigationMapSelected,
onDragStart,
onToggleLarge,
onMinimize,
onClose,
onStackChange,
onStart,
onStop,
onTerminate,
onResizeStart
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
const selectableStacks = navigationMapSelected
? ['nav2'] as VisualizationStack[]
: stacks.filter((stack) => stack !== 'nav2')
const stackLabel = (stack: VisualizationStack): string => {
if (stack === 'slam') return '2D SLAM'
if (stack === 'slam3d') return '3D SLAM'
if (stack === 'nav2') return '주행'
return 'Autoware'
}
const [terminateConfirmOpen, setTerminateConfirmOpen] = useState(false)
const [activeViewerUrl, setActiveViewerUrl] = useState<string | null>(() => (running ? viewerUrl : null))
useEffect(() => {
if (!running || !viewerUrl) return
setActiveViewerUrl((current) => current === viewerUrl ? current : viewerUrl)
}, [running, viewerUrl])
return (
<>
<section
className={[
'absolute z-40 overflow-hidden rounded-lg border shadow-[0_18px_48px_rgba(0,0,0,0.32)]',
@@ -71,12 +67,14 @@ export default function WorkspaceVisualizationPanel({
left: 0,
right: 0,
top: 40,
bottom: 0
bottom: 0,
display: minimized ? 'none' : undefined
}
: {
left: position.x,
top: position.y,
width: size.width
width: size.width,
display: minimized ? 'none' : undefined
}}
>
<div
@@ -94,24 +92,42 @@ export default function WorkspaceVisualizationPanel({
<div className="flex items-center gap-1">
<button
type="button"
aria-label="크게보기"
aria-label="최소화"
onMouseDown={(event) => event.stopPropagation()}
onClick={onToggleLarge}
onClick={onMinimize}
className={[iconButtonClass, 'group relative'].join(' ')}
>
{large ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
<Minus size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
{large ? '작게보기' : '크게보기'}
최소화
</span>
</button>
<button
type="button"
aria-label="닫기"
aria-label={large ? '원래 크기' : '최대화'}
onMouseDown={(event) => event.stopPropagation()}
onClick={onClose}
className={iconButtonClass}
onClick={onToggleLarge}
className={[iconButtonClass, 'group relative'].join(' ')}
>
<Square size={13} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
{large ? '원래 크기' : '최대화'}
</span>
</button>
<button
type="button"
aria-label={running ? '시각화 종료' : '닫기'}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => {
if (running) setTerminateConfirmOpen(true)
else onClose()
}}
className={[iconButtonClass, 'group relative'].join(' ')}
>
<X size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
{running ? '종료' : '닫기'}
</span>
</button>
</div>
</div>
@@ -119,72 +135,39 @@ export default function WorkspaceVisualizationPanel({
className={['flex flex-col bg-black', large ? 'h-[calc(100%-36px)]' : ''].join(' ')}
style={large ? undefined : { height: size.height }}
>
<div className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#101624] px-3">
{navigationMapSelected ? (
<span className="text-[11px] font-bold text-white/72">주행</span>
) : selectableStacks.length > 1 ? (
<div className="flex items-center gap-1">
{selectableStacks.map((stack) => (
<button
key={stack}
type="button"
disabled={busy}
onClick={() => onStackChange(stack)}
className={[
'h-7 rounded-md px-2.5 text-[11px] font-bold transition',
stack === activeStack
? 'bg-white/14 text-white'
: 'text-white/52 hover:bg-white/8 hover:text-white/80',
busy ? 'cursor-default opacity-55' : 'cursor-pointer'
].join(' ')}
>
{stackLabel(stack)}
</button>
))}
</div>
) : <span />}
<div className="flex items-center gap-2">
{running ? (
<button
type="button"
disabled={busy || !stacks.length}
onClick={onStop}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : '종료'}
</button>
) : (
<button
type="button"
disabled={busy || !stacks.length}
onClick={onStart}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : '시작'}
</button>
)}
</div>
</div>
<div className="relative min-h-0 flex-1">
<iframe
key={`${activeStack}-${nav2Mode}-${running ? 'running' : 'offline'}`}
title="Autonomy Studio Visualization"
src={running ? viewerUrl : 'about:blank'}
src={activeViewerUrl ?? 'about:blank'}
className="h-full w-full border-0"
allow="fullscreen"
onError={() => console.error('[visualization] viewer frame failed to load', activeViewerUrl)}
/>
{!running && (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34">
<div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]">
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '해당 주차는 시각화를 지원하지 않습니다'}</p>
<p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p>
<div className="absolute inset-0 bg-black/34">
{available ? (
<>
<button
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"
>
<CirclePlay size={40} />
{busy ? '처리 중' : '실행하기'}
</button>
<div className="absolute inset-x-0 top-[70%] px-5 text-center">
<p className="text-[13px] font-bold text-white/72">지도와 차량 상태가 표시됩니다.</p>
</div>
</>
) : (
<div className="grid h-full place-items-center px-5 text-center">
<div>
<p className="text-[13px] font-bold text-white/72">해당 주차는 시각화를 지원하지 않습니다</p>
<p className="mt-2 text-[11px] font-medium text-white/52">강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.</p>
</div>
</div>
)}
</div>
)}
</div>
@@ -202,5 +185,24 @@ export default function WorkspaceVisualizationPanel({
</>
)}
</section>
{terminateConfirmOpen && (
<div className="fixed inset-0 z-[70] grid place-items-center bg-black/42">
<section className={['w-[360px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#dfe3ed] bg-white text-[#111520]'].join(' ')}>
<div className={['flex items-center gap-2 border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#e5e8ef]'].join(' ')}>
<Power size={15} className="text-[#e87979]" />
<strong className="text-[13px] font-bold">시각화 종료</strong>
</div>
<div className="p-4">
<p className="text-[13px] font-semibold">시각화를 종료할까요?</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>종료 후에는 실행 버튼으로 다시 시작할 수 있습니다.</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={busy} onClick={() => setTerminateConfirmOpen(false)} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-default disabled:opacity-45', 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="button" disabled={busy} onClick={() => { setTerminateConfirmOpen(false); void onTerminate() }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838] disabled:cursor-default disabled:opacity-45">종료</button>
</div>
</div>
</section>
</div>
)}
</>
)
}
@@ -1,6 +1,8 @@
import { AlertTriangle, Check, House, Map, PencilRuler, Radio, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
export type SavedMapRuntimeKind = 'SIMULATION' | 'PLATFORM_2D' | 'PLATFORM_3D'
export type WorkspaceMapOption = {
key: string
platformKey: 'NAV2'
@@ -15,8 +17,12 @@ export type WorkspaceMapOption = {
sourceMapKey?: string
yamlPath?: string
pgmPath?: string
yamlUrl?: string
pgmUrl?: string
databasePath?: string
databaseUrl?: string
previewPgm?: string
runtimeKind?: SavedMapRuntimeKind
layout?: unknown
remoteSlam?: boolean
}
@@ -49,9 +55,17 @@ export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
}
]
const LOCAL_CUSTOM_MAP = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')!
function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: boolean }): React.JSX.Element {
export function WorkspaceMapPreview({
map,
selected,
compact = false,
borderless = false
}: {
map: WorkspaceMapOption
selected: boolean
compact?: boolean
borderless?: boolean
}): React.JSX.Element {
const Icon = map.remoteSlam ? Radio : map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const saved = Boolean(map.saved || map.remoteSlam)
const canvasRef = useRef<HTMLCanvasElement>(null)
@@ -116,8 +130,9 @@ function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: bool
<span
aria-hidden="true"
className={[
'relative block h-16 w-24 shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
selected ? 'border-[#6f9cff]' : 'border-white/15'
'relative block shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
compact ? 'h-11 w-[66px]' : 'h-16 w-24',
borderless ? 'border-0' : selected ? 'border-[#6f9cff]' : 'border-white/15'
].join(' ')}
>
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
@@ -143,7 +158,7 @@ function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: bool
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
</>
))}
{saved && <span className="absolute inset-x-0 bottom-0 bg-[#0f172a]/75 py-0.5 text-center text-[8px] font-extrabold tracking-[0.08em] text-white">SLAM</span>}
{saved && <span className={['absolute inset-x-0 bottom-0 bg-[#0f172a]/75 text-center font-extrabold tracking-[0.08em] text-white', compact ? 'py-px text-[6px]' : 'py-0.5 text-[8px]'].join(' ')}>{map.runtimeKind === 'PLATFORM_3D' ? '3D SLAM' : 'SLAM'}</span>}
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
<Icon className="size-3" />
</span>
@@ -152,10 +167,10 @@ function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: bool
}
type WorldMapSelectorModalProps = {
platformKey: string
weekNumber: number
maps?: WorkspaceMapOption[]
showEnvironment?: boolean
initialScope?: 'simulation' | 'platform'
simulationMaps: WorkspaceMapOption[]
platformMaps: WorkspaceMapOption[]
selectedMapKey: string
onSelect: (map: WorkspaceMapOption) => void
onCreate?: () => void
@@ -163,22 +178,19 @@ type WorldMapSelectorModalProps = {
onClose: () => void
}
export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: configuredMaps, showEnvironment = true, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const maps = configuredMaps?.length
? [...configuredMaps, ...(showEnvironment && !configuredMaps.some((map) => map.key === LOCAL_CUSTOM_MAP.key) ? [LOCAL_CUSTOM_MAP] : [])]
: (showEnvironment && platformKey.toUpperCase() === 'NAV2' ? NAV2_MAP_OPTIONS : [])
const environmentMaps = maps.filter((map) => !map.saved && !map.remoteSlam)
const savedMaps = maps.filter((map) => map.saved)
const remoteSlamMaps = maps.filter((map) => map.remoteSlam)
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const [scope, setScope] = useState<'simulation' | 'platform'>(initialScope)
const maps = scope === 'simulation' ? simulationMaps : platformMaps
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [tab, setTab] = useState<'environment' | 'saved'>(() => (!showEnvironment || selectedMap?.saved ? 'saved' : 'environment'))
const visibleMaps = tab === 'environment' ? environmentMaps : [...remoteSlamMaps, ...savedMaps]
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
useEffect(() => {
setTab(!showEnvironment || selectedMap?.saved ? 'saved' : 'environment')
setPendingMap(selectedMap)
}, [selectedMapKey, showEnvironment])
setPendingMap(maps.find((map) => map.key === selectedMapKey) ?? null)
}, [maps, selectedMapKey])
useEffect(() => {
setScope(initialScope)
}, [initialScope])
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
@@ -186,20 +198,18 @@ export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: c
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차에서 사용할 {platformKey.toUpperCase()} 학습 환경</p>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차 학습 세션 맵</p>
</div>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" />
</button>
</header>
{showEnvironment && (
<div className="flex gap-1 border-b border-white/10 px-5 pt-3">
<button type="button" onClick={() => setTab('environment')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'environment' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>학습 환경</button>
<button type="button" onClick={() => setTab('saved')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'saved' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>저장된 맵</button>
<button type="button" onClick={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>시뮬레이션</button>
<button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button>
</div>
)}
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
{visibleMaps.map((map) => {
{maps.map((map) => {
const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey
return (
@@ -212,7 +222,7 @@ export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: c
pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]'
].join(' ')}
>
<MapPreview map={map} selected={pending} />
<WorkspaceMapPreview map={map} selected={pending} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-bold">{map.name}</span>
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
@@ -227,10 +237,10 @@ export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: c
</button>
)
})}
{!visibleMaps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">저장된 SLAM 맵이 없습니다.</div>}
{!maps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">{scope === 'platform' ? '저장된 플랫폼 SLAM 맵이 없습니다.' : '사용 가능한 시뮬레이션 맵이 없습니다.'}</div>}
</div>
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
{tab === 'environment' && onCreate && (
{scope === 'simulation' && onCreate && (
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
<PencilRuler className="size-3.5" />
맵 편집
@@ -13,7 +13,8 @@ export function parseModuleParameters(tokens: string[]): ModuleParameterPair[] {
return {
id: createParameterId(),
name: separatorIndex >= 0 ? token.slice(0, separatorIndex) : token,
value: separatorIndex >= 0 ? token.slice(separatorIndex + 2) : ''
value: separatorIndex >= 0 ? token.slice(separatorIndex + 2) : '',
enabled: true
}
})
}
@@ -21,6 +22,7 @@ export function parseModuleParameters(tokens: string[]): ModuleParameterPair[] {
export function serializeModuleParameters(parameters: ModuleParameterPair[]): string {
return parameters
.map((parameter) => {
if (parameter.enabled === false) return ''
const name = parameter.name.trim()
if (!name) return ''
return `${name}:=${parameter.value.trim()}`
@@ -6,13 +6,23 @@ export const defaultStudioSourceSettings: StudioSourceSettings = {
giteaBaseUrl: 'http://localhost:3000',
giteaOwner: 'student',
gpuEnabled: false,
rosDomainId: '77'
editorWordWrap: false,
editorFontSize: 13,
editorTabSize: 4,
editorMinimap: true,
rosDomainId: '77',
rosLinkedPlatformId: null,
visualizationBridgeLocation: 'local',
}
function normalizeCoursePathSegment(courseKey: string): string {
return courseKey.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'default-course'
}
function normalizeTeamPathSegment(groupId: string): string {
return groupId.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'team'
}
export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
const suffix = `${studentSlug}/${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
@@ -22,8 +32,16 @@ export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string
return `${normalizedRoot}/${suffix}`
}
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
export function makeTeamWeekWorkspaceRoot(workspaceRoot: string, groupId: string, courseKey: string, weekNumber: number): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
const suffix = `teams/${normalizeTeamPathSegment(groupId)}/${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
return `${normalizedRoot}/${suffix}`
}
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string, teamGroupId?: string | null): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
if (teamGroupId) return makeTeamWeekWorkspaceRoot(normalizedRoot, teamGroupId, courseKey, weekNumber)
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
@@ -32,12 +50,23 @@ export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: st
export function normalizeStudioSourceSettings(settings?: Partial<StudioSourceSettings> | null): StudioSourceSettings {
const giteaOwner = settings?.giteaOwner?.trim() || defaultStudioSourceSettings.giteaOwner
const requestedEditorFontSize = Number(settings?.editorFontSize)
return {
workspaceRoot: settings?.workspaceRoot || defaultStudioSourceSettings.workspaceRoot,
giteaBaseUrl: (settings?.giteaBaseUrl || defaultStudioSourceSettings.giteaBaseUrl).replace(/\/+$/, ''),
giteaOwner: giteaOwner === 'students' ? defaultStudioSourceSettings.giteaOwner : giteaOwner,
gpuEnabled: Boolean(settings?.gpuEnabled),
editorWordWrap: settings?.editorWordWrap ?? defaultStudioSourceSettings.editorWordWrap,
editorFontSize: Number.isFinite(requestedEditorFontSize)
? Math.min(16, Math.max(12, Math.round(requestedEditorFontSize)))
: defaultStudioSourceSettings.editorFontSize,
editorTabSize: [2, 4, 8].includes(Number(settings?.editorTabSize))
? Number(settings?.editorTabSize)
: defaultStudioSourceSettings.editorTabSize,
editorMinimap: settings?.editorMinimap ?? defaultStudioSourceSettings.editorMinimap,
rosDomainId: String(settings?.rosDomainId ?? defaultStudioSourceSettings.rosDomainId).trim() || defaultStudioSourceSettings.rosDomainId,
rosLinkedPlatformId: settings?.rosLinkedPlatformId?.trim() || null,
visualizationBridgeLocation: settings?.visualizationBridgeLocation === 'platform' ? 'platform' : 'local',
agentUrl: settings?.agentUrl ?? null,
sshUser: settings?.sshUser ?? null,
sshPort: settings?.sshPort ?? null
@@ -2,6 +2,7 @@ export type ModuleParameterPair = {
id: string
name: string
value: string
enabled?: boolean
}
export type ModuleRow = {
@@ -19,6 +20,7 @@ export type ModuleRow = {
dockerImage?: string | null
packageName?: string | null
launchFile?: string | null
runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null
parameters: string[]
cpu: number | null
memory: number | null
@@ -31,6 +33,7 @@ export type ModulePrebuiltArtifact = {
fileName?: string | null
checksumSha256?: string | null
extractPath?: string | null
architecture?: string | null
required?: boolean | null
}
@@ -41,3 +44,8 @@ export type RuntimeImageArtifact = {
checksumSha256?: string | null
required?: boolean | null
}
export type RosNetworkConfig = {
enabled?: boolean
peer?: RemoteWorkspaceTarget | null
}
@@ -1,8 +1,8 @@
import { useCallback, useRef, useState } from 'react'
import { LMS_API_BASE_URL } from '../../config/appConfig'
import type { StudioSourceSettings } from '../platforms/types'
import type { StudioSourceSettings, VehicleRuntimeProfile } from '../platforms/types'
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
import type { ModuleParameterPair, ModulePrebuiltArtifact, ModuleRow, RuntimeImageArtifact } from './types'
import type { ModuleParameterPair, ModulePrebuiltArtifact, ModuleRow, RosNetworkConfig, RuntimeImageArtifact } from './types'
import { compareModulePriority, compareModuleShutdownPriority } from './workspaceModules'
type NoticeTone = 'info' | 'warning' | 'error'
@@ -12,6 +12,11 @@ type ModuleActionResult = {
message?: string
}
type SavedMapPathOverrides = {
savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null
}
const startupCategoryOrder = ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control', 'system']
function compareModuleStartupPriority(a: ModuleRow, b: ModuleRow): number {
@@ -36,10 +41,14 @@ type UseWorkspaceModuleControlOptions = {
clonePathPreview: string
remoteWorkspace: boolean
sourceSettings: StudioSourceSettings
rosNetwork?: RosNetworkConfig | null
simulatorWorldFile?: string | null
savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null
prepareSavedMap?: () => Promise<SavedMapPathOverrides>
bulkExcludedModuleKeys?: string[]
activeRuntimeImages: RuntimeImageArtifact[]
vehicleRuntimeProfiles?: VehicleRuntimeProfile[]
getFreshAccessToken: () => Promise<string>
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void>
@@ -72,10 +81,14 @@ export function useWorkspaceModuleControl({
clonePathPreview,
remoteWorkspace,
sourceSettings,
rosNetwork,
simulatorWorldFile,
savedMapYamlPath,
savedMapDatabasePath,
prepareSavedMap,
bulkExcludedModuleKeys = [],
activeRuntimeImages,
vehicleRuntimeProfiles = [],
getFreshAccessToken,
getRemoteWorkspaceTarget,
prepareRemoteRuntimeModule,
@@ -87,18 +100,33 @@ export function useWorkspaceModuleControl({
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve())
const queuedModuleNamesRef = useRef(new Set<string>())
const isExcludedFromBulkControl = useCallback((module: ModuleRow): boolean => (
bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
), [bulkExcludedModuleKeys])
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null; savedMapDatabasePath?: string | null }): string => {
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
const parameters = module.parameters.filter((parameter) => (
!parameter.startsWith('world:=')
&& !parameter.startsWith('world_layout_enabled:=')
&& !parameter.startsWith('map:=')
&& !parameter.startsWith('database_path:=')
&& !parameter.startsWith('localization:=')
&& (!isMkMiniModule || (
!parameter.startsWith('runtime_wheelbase:=')
&& !parameter.startsWith('runtime_max_speed:=')
&& !parameter.startsWith('runtime_max_steering_angle:=')
))
))
if (isMkMiniModule && mkMiniProfile) {
parameters.push(`runtime_wheelbase:=${mkMiniProfile.wheelbaseMeters}`)
parameters.push(`runtime_max_speed:=${mkMiniProfile.maxSpeedMetersPerSecond}`)
parameters.push(`runtime_max_steering_angle:=${mkMiniProfile.maxSteeringAngleRadians}`)
}
if (module.category.toLowerCase() === 'simulator' && worldFile) {
parameters.push(`world:=${worldFile}`)
}
@@ -111,7 +139,7 @@ export function useWorkspaceModuleControl({
parameters.push('localization:=true')
}
return parameters.join(' ')
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile])
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
if (!module.folderName || !module.packageName || !module.launchFile) {
@@ -126,6 +154,15 @@ export function useWorkspaceModuleControl({
launchFile: module.launchFile,
}
if (remoteWorkspace) {
if (module.runtimeTarget === 'SHARED') {
await window.remoteWorkspace.modules.transfer(
getRemoteWorkspaceTarget(),
clonePathPreview,
module.name,
module.folderName,
)
return
}
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), input)
return
}
@@ -185,26 +222,27 @@ export function useWorkspaceModuleControl({
)
}, [activeRuntimeImages, clonePathPreview, courseKey, createCustomModuleSource, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
const remoteModuleStart = useCallback(async (module: ModuleRow, overrides?: SavedMapPathOverrides): Promise<ModuleActionResult> => {
await prepareRemoteRuntimeModule(module)
return window.remoteWorkspace.modules.start(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
getModuleRosParams(module),
getModuleRosParams(module, overrides),
sourceSettings.gpuEnabled,
sourceSettings.rosDomainId,
activeRuntimeImages
activeRuntimeImages,
rosNetwork,
)
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const remoteModuleStop = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => (
window.remoteWorkspace.modules.stop(getRemoteWorkspaceTarget(), module.name, module.folderName)
), [getRemoteWorkspaceTarget])
const remoteModuleRestart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
const remoteModuleRestart = useCallback(async (module: ModuleRow, overrides?: SavedMapPathOverrides): Promise<ModuleActionResult> => {
await remoteModuleStop(module).catch(() => ({ ok: false, message: '모듈 중지 실패' }))
return remoteModuleStart(module)
return remoteModuleStart(module, overrides)
}, [remoteModuleStart, remoteModuleStop])
const installModule = useCallback(async (module: ModuleRow): Promise<void> => {
@@ -225,7 +263,7 @@ export function useWorkspaceModuleControl({
const installAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return
const targets = availableModules.filter((module) => module.state === 'uninstalled').sort(compareModulePriority)
const targets = availableModules.filter((module) => !isExcludedFromBulkControl(module) && module.state === 'uninstalled').sort(compareModulePriority)
if (!targets.length) return
setInstallingModuleName('__all__')
@@ -243,11 +281,11 @@ export function useWorkspaceModuleControl({
} finally {
setInstallingModuleName(null)
}
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules])
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, isExcludedFromBulkControl, loadAvailableModules])
const startAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return
const targets = availableModules.filter((module) => module.state === 'stopped').sort(compareModulePriority)
const targets = availableModules.filter((module) => !isExcludedFromBulkControl(module) && module.state === 'stopped').sort(compareModulePriority)
if (!targets.length) {
onTitlebarNotice('시작할 모듈이 없습니다', 'warning')
return
@@ -256,10 +294,11 @@ export function useWorkspaceModuleControl({
setInstallingModuleName('__start_all__')
setModuleInstallError(null)
try {
const savedMapOverrides = await prepareSavedMap?.()
for (const module of [...targets].sort(compareModuleStartupPriority)) {
const result = remoteWorkspace
? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!result.ok) throw new Error(result.message)
markModuleState(module, 'running')
if (module.category.toLowerCase() === 'simulator') await waitForSimulatorStartup()
@@ -280,20 +319,24 @@ export function useWorkspaceModuleControl({
clonePathPreview,
getModuleRosParams,
installingModuleName,
isExcludedFromBulkControl,
loadAvailableModules,
markModuleState,
onTitlebarNotice,
prepareSavedMap,
remoteModuleStart,
remoteWorkspace,
sourceSettings.gpuEnabled,
sourceSettings.rosDomainId
sourceSettings.rosDomainId,
rosNetwork
])
const stopAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return
const targets = availableModules.filter((module) => module.state === 'running').sort(compareModuleShutdownPriority)
const targets = availableModules.filter((module) => !isExcludedFromBulkControl(module) && module.state === 'running').sort(compareModuleShutdownPriority)
const hasExcludedRunningModule = availableModules.some((module) => isExcludedFromBulkControl(module) && module.state === 'running')
const stopLocalVisualization = async (): Promise<void> => {
if (remoteWorkspace) return
if (remoteWorkspace || hasExcludedRunningModule) return
await onStopVisualization()
}
@@ -331,7 +374,7 @@ export function useWorkspaceModuleControl({
} finally {
setInstallingModuleName(null)
}
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, onStopVisualization, onTitlebarNotice, remoteWorkspace])
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, isExcludedFromBulkControl, loadAvailableModules, onStopVisualization, onTitlebarNotice, remoteWorkspace])
const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => {
if (installingModuleName) return false
@@ -341,6 +384,7 @@ export function useWorkspaceModuleControl({
setInstallingModuleName('__restart_all__')
setModuleInstallError(null)
try {
const savedMapOverrides = { ...overrides, ...(await prepareSavedMap?.()) }
if (remoteWorkspace) {
const result = await window.remoteWorkspace.modules.stopMany(
getRemoteWorkspaceTarget(),
@@ -362,8 +406,8 @@ export function useWorkspaceModuleControl({
for (const module of [...targets].sort(compareModuleStartupPriority)) {
const result = remoteWorkspace
? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, overrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!result.ok) throw new Error(result.message)
markModuleState(module, 'running')
if (module.category.toLowerCase() === 'simulator') await waitForSimulatorStartup()
@@ -380,16 +424,28 @@ export function useWorkspaceModuleControl({
} finally {
setInstallingModuleName(null)
}
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, remoteModuleStart, remoteWorkspace, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
if (installingModuleName || queuedModuleNamesRef.current.has(module.name)) return
// The parking area only opts modules out of bulk actions; those modules
// must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = (
installingModuleName === '__stop_all__'
&& isExcludedFromBulkControl(module)
)
if (
(!canControlExcludedModuleDuringBulkStop && installingModuleName)
|| queuedModuleNamesRef.current.has(module.name)
) return
queuedModuleNamesRef.current.add(module.name)
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null)
const execute = async (): Promise<void> => {
try {
const savedMapOverrides = action === 'start' || action === 'restart'
? await prepareSavedMap?.()
: undefined
const result = action === 'stop'
? remoteWorkspace
? await remoteModuleStop(module)
@@ -397,14 +453,14 @@ export function useWorkspaceModuleControl({
: action === 'build'
? remoteWorkspace
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, getModuleRosParams(module))
: action === 'restart'
? remoteWorkspace
? await remoteModuleRestart(module)
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
? await remoteModuleRestart(module, savedMapOverrides)
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
: remoteWorkspace
? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!result.ok) throw new Error(result.message)
if (action === 'start' || action === 'restart') {
@@ -420,7 +476,7 @@ export function useWorkspaceModuleControl({
if (vehicle) {
const vehicleResult = remoteWorkspace
? await remoteModuleRestart(vehicle)
: await window.localWorkspace.modules.restart(vehicle.name, clonePathPreview, vehicle.folderName, getModuleRosParams(vehicle), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
: await window.localWorkspace.modules.restart(vehicle.name, clonePathPreview, vehicle.folderName, getModuleRosParams(vehicle), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
}
}
@@ -465,9 +521,11 @@ export function useWorkspaceModuleControl({
clonePathPreview,
getModuleRosParams,
installingModuleName,
isExcludedFromBulkControl,
loadAvailableModules,
markModuleState,
onTitlebarNotice,
prepareSavedMap,
remoteModuleRestart,
remoteModuleStart,
remoteModuleStop,
@@ -15,6 +15,7 @@ type UseWorkspaceModuleListOptions = {
remoteWorkspace: boolean
remoteWorkspaceTargetKey: string
pollingActive: boolean
skipInitialModuleLoad?: boolean
applyModuleParameterOverrides: (modules: ModuleRow[]) => ModuleRow[]
}
@@ -24,10 +25,30 @@ type UseWorkspaceModuleListResult = {
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
modulesLoading: boolean
moduleListError: string | null
clearModuleListError: () => void
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
loadRemoteModules: () => Promise<{ modules: ModuleRow[] }>
}
function readRemoteModuleCache(key: string): ModuleRow[] | null {
try {
const raw = window.sessionStorage.getItem(key)
if (!raw) return null
const modules = JSON.parse(raw) as unknown
return Array.isArray(modules) ? modules as ModuleRow[] : null
} catch {
return null
}
}
function storeRemoteModuleCache(key: string, modules: ModuleRow[]): void {
try {
window.sessionStorage.setItem(key, JSON.stringify(modules))
} catch {
// The next background probe can still populate the visible module state.
}
}
function parseRemoteWorkspaceTarget(targetKey: string): RemoteWorkspaceTarget | null {
try {
const parsed = JSON.parse(targetKey) as RemoteWorkspaceTarget | null
@@ -44,12 +65,22 @@ export function useWorkspaceModuleList({
remoteWorkspace,
remoteWorkspaceTargetKey,
pollingActive,
skipInitialModuleLoad = false,
applyModuleParameterOverrides
}: UseWorkspaceModuleListOptions): UseWorkspaceModuleListResult {
const [availableModules, setAvailableModules] = useState<ModuleRow[]>([])
const [modulesLoading, setModulesLoading] = useState(false)
const [moduleListError, setModuleListError] = useState<string | null>(null)
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
const remoteModuleCacheKey = useMemo(
() => `lms:remote-module-list:${remoteWorkspaceTargetKey}:${curriculumModuleKey}`,
[curriculumModuleKey, remoteWorkspaceTargetKey],
)
const localModuleCacheKey = useMemo(
() => `lms:local-module-list:${clonePathPreview}:${curriculumModuleKey}`,
[clonePathPreview, curriculumModuleKey],
)
const moduleCacheKey = remoteWorkspace ? remoteModuleCacheKey : localModuleCacheKey
const moduleInputsRef = useRef({ curriculumModules, applyModuleParameterOverrides })
const loadSequenceRef = useRef(0)
const moduleLoadInFlightRef = useRef(false)
@@ -83,7 +114,8 @@ export function useWorkspaceModuleList({
// runtime confirms it, rather than briefly switching the row back.
const pendingDuration = state === 'running' ? 120_000 : 30_000
pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + pendingDuration })
setAvailableModules((current) => current.map((item) => {
setAvailableModules((current) => {
const next = current.map((item) => {
if (moduleStateKey(item) !== key) return item
return {
...item,
@@ -92,8 +124,11 @@ export function useWorkspaceModuleList({
cpu: state === 'running' ? item.cpu : null,
memory: state === 'running' ? item.memory : null,
}
}))
}, [])
})
storeRemoteModuleCache(moduleCacheKey, next)
return next
})
}, [moduleCacheKey])
useEffect(() => {
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
@@ -117,14 +152,18 @@ export function useWorkspaceModuleList({
const result = await loadRemoteModules()
if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null)
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
setAvailableModules(modules)
storeRemoteModuleCache(moduleCacheKey, modules)
return
}
const result = await window.localWorkspace.modules.list(clonePathPreview, currentCurriculumModules ?? [])
if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null)
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
setAvailableModules(modules)
storeRemoteModuleCache(moduleCacheKey, modules)
} catch (error) {
if (requestSequence !== loadSequenceRef.current) return
console.error('[modules] list failed', error)
@@ -142,12 +181,29 @@ export function useWorkspaceModuleList({
clonePathPreview,
curriculumModuleKey,
loadRemoteModules,
moduleCacheKey,
remoteWorkspace
])
useEffect(() => {
const cachedModules = readRemoteModuleCache(moduleCacheKey)
if (cachedModules) {
const modules = applyPendingModuleStates(
moduleInputsRef.current.applyModuleParameterOverrides(cachedModules).sort(compareModulePriority),
)
setAvailableModules(modules)
setModuleListError(null)
setModulesLoading(false)
if (skipInitialModuleLoad) return
}
if (!cachedModules) {
setAvailableModules(makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules))
setModuleListError(null)
}
void loadAvailableModules()
}, [loadAvailableModules, remoteWorkspaceTargetKey])
}, [applyPendingModuleStates, loadAvailableModules, moduleCacheKey, remoteWorkspaceTargetKey, skipInitialModuleLoad])
useEffect(() => {
if (!pollingActive) return
@@ -165,6 +221,7 @@ export function useWorkspaceModuleList({
markModuleState,
modulesLoading,
moduleListError,
clearModuleListError: () => setModuleListError(null),
loadAvailableModules,
loadRemoteModules
}
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { EnvironmentProfile } from '../platforms/types'
import type { RuntimeImageArtifact } from './types'
import type { RosNetworkConfig, RuntimeImageArtifact } from './types'
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
export type Nav2VisualizationMode = 'slam' | 'navigation'
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase()
if (type === 'local' || type === 'platform') return 'nav2'
if (type === 'real_slam') return 'slam'
if (type === 'real_slam_3d') return 'slam3d'
if (type === 'real_navigation') return 'nav2'
@@ -20,7 +21,14 @@ function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationM
}
function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
return ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile?.profileType.toLowerCase() ?? '')
if (['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile?.profileType.toLowerCase() ?? '')) return true
const config = profile?.config
return Boolean(
config
&& typeof config === 'object'
&& !Array.isArray(config)
&& ((config as Record<string, unknown>).target === 'remote-jetson' || (config as Record<string, unknown>).target === 'platform'),
)
}
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
@@ -39,7 +47,13 @@ type UseWorkspaceVisualizationOptions = {
fallbackStack: VisualizationStack
remoteWorkspace: boolean
remoteTarget: RemoteWorkspaceTarget | null
bridgeLocation?: 'local' | 'platform'
bridgeWorkspaceRoot?: string | null
sessionKey?: string | null
rosNetwork?: RosNetworkConfig | null
mapKey?: string | null
platform?: string | null
visualizationRuntime?: 'real' | 'simulation'
worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
}
@@ -74,7 +88,13 @@ export function useWorkspaceVisualization({
fallbackStack,
remoteWorkspace,
remoteTarget,
bridgeLocation = 'local',
bridgeWorkspaceRoot,
sessionKey,
rosNetwork,
mapKey,
platform,
visualizationRuntime,
worldLayoutVersion,
onTitlebarNotice
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
@@ -84,18 +104,26 @@ export function useWorkspaceVisualization({
const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => selectRuntimeProfiles(profiles, remoteWorkspace), [profiles, remoteWorkspace])
const runtimeProfiles = useMemo(() => {
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
if (fallbackStack === 'autoware') return candidates
// A NAV2 curriculum can retain legacy Autoware profiles. Do not let one
// become the active viewer merely because it appears first in the list.
return candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
}, [fallbackStack, profiles, remoteWorkspace])
const visualizationStacks = useMemo(() => (
runtimeProfiles
.map((profile) => resolveVisualizationStack(profile.profileType))
.filter((stack): stack is VisualizationStack => stack !== null)
), [runtimeProfiles])
const visualizationStackStorageKey = useMemo(() => {
if (sessionKey) return `lms:visualization-stack:${sessionKey}`
const workspaceKey = remoteWorkspace
? `${remoteTarget?.host ?? ''}:${remoteTarget?.workspaceRoot ?? ''}`
: clonePathPreview
return workspaceKey ? `lms:visualization-stack:${workspaceKey}` : null
}, [clonePathPreview, remoteTarget?.host, remoteTarget?.workspaceRoot, remoteWorkspace])
}, [clonePathPreview, remoteTarget?.host, remoteTarget?.workspaceRoot, remoteWorkspace, sessionKey])
const selectVisualizationStack = useCallback((stack: VisualizationStack) => {
setVisualizationStack(stack)
if (visualizationStackStorageKey) window.localStorage.setItem(visualizationStackStorageKey, stack)
@@ -108,33 +136,34 @@ export function useWorkspaceVisualization({
typeof image === 'object' && image !== null && typeof (image as RuntimeImageArtifact).image === 'string'
))
: []
const activeEnvironmentAgent = typeof activeEnvironmentConfig.agent === 'object' && activeEnvironmentConfig.agent !== null
? activeEnvironmentConfig.agent as Record<string, unknown>
: null
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
? activeEnvironmentAgent.url.replace(/\/+$/, '')
: ''
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && Boolean(activeEnvironmentAgentUrl || remoteTarget?.host)
const visualizationRemoteBridgeHttpUrl = (() => {
if (!visualizationRemote) return ''
if (!activeEnvironmentAgentUrl && remoteTarget?.host) return `http://${remoteTarget.host}:8765`
try {
const url = new URL(activeEnvironmentAgentUrl)
url.port = '8765'
return url.toString().replace(/\/+$/, '')
} catch {
return ''
}
})()
const visualizationBridgeHttpUrl = visualizationRemoteBridgeHttpUrl || 'http://localhost:8765'
const visualizationBridgeUrl = visualizationRemote
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
: 'ws://localhost:8765'
const bridgeTarget = remoteTarget ?? rosNetwork?.peer ?? null
const bridgeRunsOnPlatform = bridgeLocation === 'platform' && Boolean(bridgeTarget?.host)
const visualizationBridgeHttpUrl = bridgeRunsOnPlatform
? `http://${bridgeTarget!.host}:8765`
: 'http://localhost:8765'
const visualizationBridgeUrl = visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
const visualizationBackendUrl = visualizationBridgeHttpUrl
const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack
const pointCloudQuery = visualizationStack === 'slam3d' ? '&pointCloud=1' : ''
const viewerRuntime = visualizationRuntime ?? (isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation')
const localBridgeProfile = useMemo(() => {
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
? activeEnvironmentConfig.bridge as Record<string, unknown>
: {}
return {
...activeEnvironmentConfig,
type: visualizationRuntimeStack,
target: 'local',
agent: undefined,
runtime: undefined,
runtimeImages: undefined,
bridge: visualizationRuntimeStack === 'autoware'
? configuredBridge
: { ...configuredBridge, image: 'slam-ros-bridge:latest' },
}
}, [activeEnvironmentConfig, visualizationRuntimeStack])
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2'
? `./slam-viewer/index.html?directBridge=1&mode=${visualizationStack === 'nav2' ? nav2VisualizationMode : 'slam'}&runtime=${isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
? `./slam-viewer/index.html?directBridge=1&mode=${visualizationStack === 'nav2' ? nav2VisualizationMode : 'slam'}&runtime=${viewerRuntime}&platform=${encodeURIComponent(platform?.trim() || '')}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
: `./autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
useEffect(() => {
@@ -149,10 +178,10 @@ export function useWorkspaceVisualization({
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
}, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks])
const refreshVisualizationStatus = async (): Promise<void> => {
const status = remoteWorkspace
? remoteTarget?.host
? await window.remoteWorkspace.visualization.status(remoteTarget).catch((error) => ({
const refreshVisualizationStatus = useCallback(async (): Promise<void> => {
const status = bridgeRunsOnPlatform
? bridgeTarget?.host
? await window.remoteWorkspace.visualization.status(bridgeTarget).catch((error) => ({
ok: false,
running: false,
stack: null,
@@ -176,7 +205,17 @@ export function useWorkspaceVisualization({
: status.message
? status.message
: '대기')
}
}, [bridgeRunsOnPlatform, bridgeTarget, visualizationStack, visualizationStacks])
useEffect(() => {
if (!visualizationRunning) return
void refreshVisualizationStatus()
const timer = window.setInterval(() => {
void refreshVisualizationStatus()
}, 3000)
return () => window.clearInterval(timer)
}, [refreshVisualizationStatus, visualizationRunning])
const startVisualizationBridge = async (mode = nav2VisualizationMode): Promise<void> => {
if (visualizationBusy) return
@@ -189,11 +228,11 @@ export function useWorkspaceVisualization({
setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중')
try {
const status = remoteWorkspace
? remoteTarget?.host
? await window.remoteWorkspace.visualization.start(remoteTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config)
const status = bridgeRunsOnPlatform
? bridgeTarget?.host
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, clonePathPreview, rosDomainId, activeEnvironmentProfile.config)
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork)
if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running))
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'
@@ -203,6 +242,7 @@ export function useWorkspaceVisualization({
setVisualizationStatusMessage(formatVisualizationStackLabel(statusStack))
onTitlebarNotice('화면이 시작되었습니다', 'info')
} catch (error) {
console.error('[visualization] start failed', error)
setVisualizationRunning(false)
setVisualizationStatusMessage(error instanceof Error ? error.message : '시작 실패')
onTitlebarNotice('화면 시작에 실패했습니다', 'error')
@@ -216,11 +256,11 @@ export function useWorkspaceVisualization({
setVisualizationBusy(true)
setVisualizationStatusMessage('종료 중')
try {
const status = remoteWorkspace
? remoteTarget?.host
? await window.remoteWorkspace.visualization.stop(remoteTarget)
const status = bridgeRunsOnPlatform
? bridgeTarget?.host
? await window.remoteWorkspace.visualization.stop(bridgeTarget)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.stop(activeEnvironmentProfile?.config)
: await window.localWorkspace.visualization.stop(localBridgeProfile)
if (!status.ok) throw new Error(status.message ?? '종료 실패')
setVisualizationRunning(false)
setVisualizationStatusMessage('대기')
@@ -22,6 +22,7 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod
dockerImage: module.dockerImage ?? null,
packageName: module.packageName ?? null,
launchFile: module.launchFile ?? null,
runtimeTarget: module.runtimeTarget ?? null,
parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [],
cpu: null,
memory: null,