diff --git a/docs/project-lifecycle.md b/docs/project-lifecycle.md index 4d4d23e..cb3ee0c 100644 --- a/docs/project-lifecycle.md +++ b/docs/project-lifecycle.md @@ -102,8 +102,14 @@ For workflow use, install or pair the Runtime and open **Inspect a Compute Device**. Select the computer on `ComputeDevice`; immediately before each editor cook, Blacknode reads current ROS state through the authenticated Runtime and `DeviceInspect` exposes the graph inventory, capability candidates, and -unclassified interfaces. Saved workflows contain the stable device ID and -display identity, never live machine state, an SSH password, or a pairing token. +unclassified interfaces. Build an operational stream as `ComputeDevice` → +`PhysicalRobot` → `RobotDeployment` → `RobotStream`. Each node owns one choice, +so the graph can branch from a stable robot or deployment into separate map, +camera, LiDAR, IMU, and future stream paths. Connect the selected stream topic +and message type to a generic ROS 2 node, then connect its message output to the +matching visualization or processing node. Saved workflows contain stable +device, robot, deployment, and topic identity, never live machine state, an SSH +password, or a pairing token. Installation remains a separate explicit action after the inspection report. **Install Runtime only** is the default for a new compute device. Before the @@ -254,6 +260,10 @@ Connection can be connected, disconnected, checking, unknown, or unreachable; deployment can be active, inactive, completed, failed, or absent. The latest inactive deployment remains available on the Runtime and can restart after the robot passes the same connected, disarmed, calibration, and ownership checks. +Managed capabilities remain visible on the robot card after deployment. Mapping +shows its live occupancy stream while running and keeps restart and saved-map +state available when stopped. Start, save, and stop actions report lifecycle +progress in both the robot card and Deployments. Update the graph, check setup again, and send a new revision to iterate. Project artifacts retain evidence from datasets, training runs, policies, evaluations, diff --git a/editor-server/server.py b/editor-server/server.py index 133db66..15800bc 100644 --- a/editor-server/server.py +++ b/editor-server/server.py @@ -5346,6 +5346,36 @@ def _device_host_live_inspection(host_id: str) -> dict[str, Any]: if str(value or "").strip() ] diagnostics_ok = bool(diagnostics.get("ok")) + try: + deployment_payload = client.list_deployments() + except (DeviceRegistryError, AttributeError, TypeError): + deployment_payload = {} + robot_targets = [ + { + "id": str(robot.get("id") or ""), + "name": str(robot.get("name") or robot.get("id") or "Robot"), + "remote_device_id": str(robot.get("remote_device_id") or ""), + "paused": bool(robot.get("paused")), + } + for robot in (host.get("robots") or []) + if isinstance(robot, dict) and str(robot.get("id") or "") + ] + robot_ids = {robot["id"] for robot in robot_targets} + deployments = [] + for value in deployment_payload.get("deployments", []): + if not isinstance(value, dict): + continue + target_id = str(value.get("target_device_id") or "") + if target_id and target_id not in robot_ids: + continue + deployment = _robot_deployment_summary(value) + deployment.update({ + "target_device_id": target_id, + "project_id": str(value.get("project_id") or ""), + "workflow_slug": str(value.get("workflow_slug") or ""), + "updated_at": str(value.get("updated_at") or ""), + }) + deployments.append(deployment) inspection = { "ok": diagnostics_ok, "live": diagnostics_ok, @@ -5395,6 +5425,8 @@ def _device_host_live_inspection(host_id: str) -> dict[str, Any]: "instances": [], "suggested_port": 0, "suggested_instance_id": "", + "robots": robot_targets, + "deployments": deployments, "ros2_graph": { "available": bool(diagnostics.get("available")), "state": "available" if diagnostics_ok else "unavailable", @@ -5409,7 +5441,65 @@ def _device_host_live_inspection(host_id: str) -> dict[str, Any]: "diagnostics_summary": str(diagnostics.get("summary") or ""), }, } - return _classify_inspected_ros2_graph(inspection) + inspection = _classify_inspected_ros2_graph(inspection) + streams: list[dict[str, Any]] = [] + seen_streams: set[tuple[str, str, str]] = set() + graph = inspection.get("ros2_graph") + capabilities = ( + graph.get("capabilities") + if isinstance(graph, dict) + and isinstance(graph.get("capabilities"), list) + else [] + ) + for candidate in capabilities: + if not isinstance(candidate, dict): + continue + capability = str(candidate.get("capability") or "").strip() + for evidence in candidate.get("evidence") or []: + if not isinstance(evidence, dict) or evidence.get("kind") != "topic": + continue + topic = str(evidence.get("name") or "").strip() + message_type = str(evidence.get("message_type") or "").strip() + key = (capability, topic, message_type) + if not capability or not topic or key in seen_streams: + continue + seen_streams.add(key) + streams.append({ + "kind": "blacknode.deployed-stream", + "schema_version": 1, + "source": "ros2_graph", + "capability": capability, + "device_id": host_id, + "robot_id": "", + "deployment_id": "", + "state": "available", + "available": True, + "topic": topic, + "message_type": message_type, + }) + for deployment in deployments: + if int(deployment.get("mapping_control_count") or 0) != 1: + continue + topic = str(deployment.get("mapping_topic") or "/map") + key = ("map", topic, "nav_msgs/msg/OccupancyGrid") + if key in seen_streams: + continue + seen_streams.add(key) + streams.append({ + "kind": "blacknode.deployed-stream", + "schema_version": 1, + "source": "deployment", + "capability": "map", + "device_id": host_id, + "robot_id": str(deployment.get("target_device_id") or ""), + "deployment_id": str(deployment.get("id") or ""), + "state": str(deployment.get("state") or "stopped"), + "available": str(deployment.get("state") or "") == "running", + "topic": topic, + "message_type": "nav_msgs/msg/OccupancyGrid", + }) + inspection["streams"] = streams + return inspection def _refresh_live_compute_device_params() -> None: @@ -10071,6 +10161,37 @@ def _set_device_deployment_lease(device_id: str, *, leased: bool) -> None: ) +def _robot_deployment_summary( + deployment: dict[str, Any], + *, + include_motion: bool = True, +) -> dict[str, Any]: + """Keep robot-card lifecycle fields portable and credential free.""" + summary: dict[str, Any] = { + "id": str(deployment.get("id") or ""), + "name": str( + deployment.get("name") + or deployment.get("id") + or "Deployment" + ), + "state": str(deployment.get("state") or "stopped"), + } + motion_count = int(deployment.get("motion_control_count") or 0) + if include_motion or motion_count or deployment.get("motion_armed"): + summary["motion_armed"] = bool(deployment.get("motion_armed")) + summary["motion_control_count"] = motion_count + mapping_count = int(deployment.get("mapping_control_count") or 0) + if mapping_count: + summary["mapping_control_count"] = mapping_count + summary["mapping_topic"] = str( + deployment.get("mapping_topic") or "/map" + ) + artifact = deployment.get("last_map_artifact") + if isinstance(artifact, dict): + summary["last_map_artifact"] = dict(artifact) + return summary + + def _deployment_aware_device_status(device_id: str) -> dict[str, Any]: """Report running deployments separately from physical motion ownership.""" client = _paired_device_client(device_id) @@ -10137,13 +10258,7 @@ def _deployment_aware_device_status(device_id: str) -> dict[str, Any]: } for item in active ] - deployment = { - "id": str(owner.get("id") or ""), - "name": str(owner.get("name") or owner.get("id") or "Deployment"), - "state": str(owner.get("state") or "running"), - "motion_armed": bool(owner.get("motion_armed")), - "motion_control_count": int(owner.get("motion_control_count") or 0), - } + deployment = _robot_deployment_summary(owner) if hardware_is_leased: result["deployment_lease"] = deployment result["armed"] = bool(deployment.get("motion_armed")) @@ -10248,15 +10363,10 @@ def _deployment_aware_device_status(device_id: str) -> dict[str, Any]: if stored: deployment = stored[0] result = dict(status) - inactive_deployment = { - "id": str(deployment.get("id") or ""), - "name": str( - deployment.get("name") - or deployment.get("id") - or "Deployment" - ), - "state": str(deployment.get("state") or "stopped"), - } + inactive_deployment = _robot_deployment_summary( + deployment, + include_motion=False, + ) # Keep the old field as a compatibility alias for existing clients. result["inactive_deployment"] = inactive_deployment result["stored_deployment"] = inactive_deployment diff --git a/editor/src/App.tsx b/editor/src/App.tsx index d3d55a0..deb33e4 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -52,7 +52,6 @@ const NODE_TYPES = { const TAB_H = 52 // workflow tab bar height const THEME_STORAGE_KEY = 'blacknode-theme' const UI_TEST_STORAGE_KEY = 'blacknode-ui-test' -const NODE_DENSITY_STORAGE_KEY = 'blacknode-node-density' const SIMULATION_VIEWER_HEIGHT_STORAGE_KEY = 'blacknode-simulation-viewer-height' const NEWTON_SCENE_FILE_EXTENSIONS = [ '.usd', '.usda', '.usdc', '.urdf', '.xacro', '.xml', '.mjcf', @@ -79,14 +78,6 @@ function loadUiTestPreference() { return true } -function loadNodeDensityPreference(): 'detailed' | 'compact' { - try { - return window.localStorage.getItem(NODE_DENSITY_STORAGE_KEY) === 'compact' ? 'compact' : 'detailed' - } catch { - return 'detailed' - } -} - function loadSimulationViewerHeight(): number { try { const value = Number(window.localStorage.getItem(SIMULATION_VIEWER_HEIGHT_STORAGE_KEY)) @@ -128,6 +119,61 @@ interface PendingXacroEnvironmentState { values: Record } +function ToolbarIcon({ + name, + className, +}: { + name: 'organize' | 'refresh' | 'light' | 'dark' | 'clear' + className?: string +}) { + return ( + + ) +} + function missingXacroEnvironmentVariable(message: string): string | null { const match = message.match(/Xacro requires environment variable ['"]([^'"]+)['"]/i) return match?.[1] ?? null @@ -205,7 +251,6 @@ function WorkspaceApp() { const [search, setSearch] = useState(null) const [isDark, setIsDark] = useState(loadDarkThemePreference) const [isUiTest, setIsUiTest] = useState(loadUiTestPreference) - const [nodeDensity, setNodeDensity] = useState<'detailed' | 'compact'>(loadNodeDensityPreference) const [hoveredEdgeId, setHoveredEdgeId] = useState(null) const [hoveredPort, setHoveredPort] = useState<{ nodeId: string @@ -243,10 +288,10 @@ function WorkspaceApp() { const [simulationViewerVisible, setSimulationViewerVisible] = useState(true) const [simulationViewerDetached, setSimulationViewerDetached] = useState(false) const [simulationViewerHeight, setSimulationViewerHeight] = useState(loadSimulationViewerHeight) + const [fileMenuOpen, setFileMenuOpen] = useState(false) + const [fileMenuPosition, setFileMenuPosition] = useState({ top: 0, left: 0 }) const [simulationViewerMenuOpen, setSimulationViewerMenuOpen] = useState(false) const [simulationViewerMenuPosition, setSimulationViewerMenuPosition] = useState({ top: 0, left: 0 }) - const [hostedViewMenuOpen, setHostedViewMenuOpen] = useState(false) - const [hostedViewMenuPosition, setHostedViewMenuPosition] = useState({ top: 0, left: 0 }) const [newtonWorkspace, setNewtonWorkspace] = useState(null) const [newtonWorkspaceAvailable, setNewtonWorkspaceAvailable] = useState(false) const [newtonWorkspaceBusy, setNewtonWorkspaceBusy] = useState(false) @@ -271,10 +316,10 @@ function WorkspaceApp() { ) }, []) const lastSimulationViewerUrl = useRef('') + const fileMenuTriggerRef = useRef(null) + const fileMenuRef = useRef(null) const simulationViewerMenuTriggerRef = useRef(null) const simulationViewerMenuRef = useRef(null) - const hostedViewMenuTriggerRef = useRef(null) - const hostedViewMenuRef = useRef(null) const updatePendingCloseName = useCallback((draftName: string) => { setPendingClose(current => current ? { ...current, draftName } : current) }, []) @@ -520,43 +565,49 @@ function WorkspaceApp() { }, [newtonWorkspace?.open, nodeTypes]) useLayoutEffect(() => { - if (!simulationViewerMenuOpen) return + if (!fileMenuOpen) return const positionMenu = () => { - const trigger = simulationViewerMenuTriggerRef.current + const trigger = fileMenuTriggerRef.current if (!trigger) return const bounds = trigger.getBoundingClientRect() - const width = 210 - setSimulationViewerMenuPosition({ + const width = 250 + setFileMenuPosition({ top: bounds.bottom + 7, - left: Math.max(8, Math.min(window.innerWidth - width - 8, bounds.right - width)), + left: Math.max(8, Math.min(window.innerWidth - width - 8, bounds.left)), }) } + const closeMenu = () => setFileMenuOpen(false) const closeOnOutsidePointer = (event: PointerEvent) => { const target = event.target if (!(target instanceof Node)) return - if (simulationViewerMenuTriggerRef.current?.contains(target)) return - if (simulationViewerMenuRef.current?.contains(target)) return - setSimulationViewerMenuOpen(false) + if (fileMenuTriggerRef.current?.contains(target)) return + if (fileMenuRef.current?.contains(target)) return + closeMenu() + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeMenu() } positionMenu() document.addEventListener('pointerdown', closeOnOutsidePointer) + document.addEventListener('keydown', closeOnEscape) window.addEventListener('resize', positionMenu) window.addEventListener('scroll', positionMenu, true) return () => { document.removeEventListener('pointerdown', closeOnOutsidePointer) + document.removeEventListener('keydown', closeOnEscape) window.removeEventListener('resize', positionMenu) window.removeEventListener('scroll', positionMenu, true) } - }, [simulationViewerMenuOpen]) + }, [fileMenuOpen]) useLayoutEffect(() => { - if (!hostedViewMenuOpen) return + if (!simulationViewerMenuOpen) return const positionMenu = () => { - const trigger = hostedViewMenuTriggerRef.current + const trigger = simulationViewerMenuTriggerRef.current if (!trigger) return const bounds = trigger.getBoundingClientRect() const width = 210 - setHostedViewMenuPosition({ + setSimulationViewerMenuPosition({ top: bounds.bottom + 7, left: Math.max(8, Math.min(window.innerWidth - width - 8, bounds.right - width)), }) @@ -564,9 +615,9 @@ function WorkspaceApp() { const closeOnOutsidePointer = (event: PointerEvent) => { const target = event.target if (!(target instanceof Node)) return - if (hostedViewMenuTriggerRef.current?.contains(target)) return - if (hostedViewMenuRef.current?.contains(target)) return - setHostedViewMenuOpen(false) + if (simulationViewerMenuTriggerRef.current?.contains(target)) return + if (simulationViewerMenuRef.current?.contains(target)) return + setSimulationViewerMenuOpen(false) } positionMenu() document.addEventListener('pointerdown', closeOnOutsidePointer) @@ -577,7 +628,7 @@ function WorkspaceApp() { window.removeEventListener('resize', positionMenu) window.removeEventListener('scroll', positionMenu, true) } - }, [hostedViewMenuOpen]) + }, [simulationViewerMenuOpen]) useEffect(() => { if (!activeSimulationViewer) { @@ -627,15 +678,6 @@ function WorkspaceApp() { } }, [isUiTest]) - useLayoutEffect(() => { - document.documentElement.setAttribute('data-node-density', isUiTest ? nodeDensity : 'detailed') - try { - window.localStorage.setItem(NODE_DENSITY_STORAGE_KEY, nodeDensity) - } catch { - // The density remains available for this editor session. - } - }, [isUiTest, nodeDensity]) - useEffect(() => { const onPortHover = (event: Event) => { const detail = (event as CustomEvent<{ @@ -1925,20 +1967,6 @@ function WorkspaceApp() {
- File - - + {fileMenuOpen && createPortal( +
+ +
+ Export as + {frameworkExportTargets.map(target => ( + + ))} +
, + document.body, + )}
- Run {hostedPreview && WEB PREVIEW} {hostedPreview ? ( @@ -2026,98 +2099,7 @@ function WorkspaceApp() {
- View - {hostedPreview ? ( - <> - - {hostedViewMenuOpen && createPortal( -
- - - -
- - -
- -
, - document.body, - )} - - ) : ( + {!hostedPreview && ( <> @@ -2250,29 +2235,37 @@ function WorkspaceApp() { document.body, )}
+ + )} - - - - - )}
diff --git a/editor/src/components/DeploymentsPanel.tsx b/editor/src/components/DeploymentsPanel.tsx index 67cb2ce..aa75e95 100644 --- a/editor/src/components/DeploymentsPanel.tsx +++ b/editor/src/components/DeploymentsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' +import { useEffect, useMemo, useState, type CSSProperties } from 'react' import { api, type ComputeDevice, @@ -12,11 +12,11 @@ import { type DeviceActionProgress, type HardwareDevice, type HardwareDeviceStatus, - type MappingSnapshot, type RemoteDeployment, type RemoteDeploymentState, } from '../api' import { useStore } from '../store' +import LiveOccupancyMap from './LiveOccupancyMap' const REFRESH_INTERVAL_MS = 3000 const DEFAULT_DEPLOYMENT_NAME = 'Deployed graph' @@ -123,6 +123,7 @@ export default function DeploymentsPanel({ const [remoteDeploymentName, setRemoteDeploymentName] = useState('') const [remoteAction, setRemoteAction] = useState<'send' | 'send-run' | null>(null) const [remoteProgress, setRemoteProgress] = useState(null) + const [remoteOperationProgress, setRemoteOperationProgress] = useState>({}) const [remoteNotice, setRemoteNotice] = useState(null) const [rosDiagnostics, setRosDiagnostics] = useState('') const stopRuntimeServices = useStore(s => s.stopRuntimeServices) @@ -598,14 +599,19 @@ export default function DeploymentsPanel({ setRemoteDeployments(deploymentsForRobot(result.deployments, selectedDeviceId)) } - const actRemote = async (fn: () => Promise) => { + const actRemote = async ( + fn: () => Promise, + onFailure?: (message: string) => void, + ) => { setBusy(true) setError(null) try { await fn() await refreshRemote() } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + const message = err instanceof Error ? err.message : String(err) + setError(message) + onFailure?.(message) } finally { setBusy(false) } @@ -731,10 +737,69 @@ export default function DeploymentsPanel({ } for this robot? The other deployment will remain available in a stopped state.`, ) ) return - await actRemote(() => api.startRemoteDeployment( - selectedDeviceId, - deployment.id, - )) + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { + progress: 10, + message: Number(deployment.mapping_control_count || 0) === 1 + ? 'Starting mapping session' + : 'Starting deployment', + }, + })) + await actRemote(async () => { + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { progress: 55, message: 'Waiting for the device Runtime' }, + })) + await api.startRemoteDeployment(selectedDeviceId, deployment.id) + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { + progress: 100, + message: Number(deployment.mapping_control_count || 0) === 1 + ? 'Mapping session started' + : 'Deployment started', + }, + })) + }, message => setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { progress: 0, message: `Deployment start failed: ${message}` }, + }))) + } + + const stopRemote = async (deployment: RemoteDeployment) => { + if (!selectedDeviceId) return + const isMapping = Number(deployment.mapping_control_count || 0) === 1 + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { + progress: 10, + message: isMapping ? 'Stopping mapping session' : 'Stopping deployment', + }, + })) + await actRemote(async () => { + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { + progress: 60, + message: isMapping ? 'Closing map stream and SLAM process' : 'Waiting for process shutdown', + }, + })) + await api.stopRemoteDeployment(selectedDeviceId, deployment.id) + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { + progress: 100, + message: isMapping ? 'Mapping session stopped' : 'Deployment stopped', + }, + })) + }, message => setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { + progress: 0, + message: `${isMapping ? 'Mapping stop' : 'Deployment stop'} failed: ${message}`, + }, + }))) } const openRemoteWorkflow = async (deployment: RemoteDeployment) => { @@ -799,20 +864,40 @@ export default function DeploymentsPanel({ if (!selectedDeviceId) return setBusy(true) setError(null) + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { progress: 10, message: 'Preparing map save' }, + })) try { + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { + progress: 45, + message: 'Saving occupancy grid and pose graph on the robot', + }, + })) const result = await api.saveRemoteDeploymentMap( selectedDeviceId, deployment.id, ) await refreshRemote() const mapPath = String(result.artifact?.map_yaml || result.artifact?.directory || '') + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { progress: 100, message: 'Map saved' }, + })) setRemoteNotice( `Map "${String(result.artifact?.map_name || 'map')}" saved on the device${ mapPath ? ` at ${mapPath}` : '' }.${result.warning ? ` ${result.warning}` : ''}`, ) } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + const message = err instanceof Error ? err.message : String(err) + setRemoteOperationProgress(previous => ({ + ...previous, + [deployment.id]: { progress: 0, message: `Map save failed: ${message}` }, + })) + setError(message) } finally { setBusy(false) } @@ -1420,9 +1505,8 @@ export default function DeploymentsPanel({ onStart={() => startRemote(deployment)} onSetMotion={armed => setRemoteMotion(deployment, armed)} onSaveMap={() => saveRemoteMap(deployment)} - onStop={() => actRemote(() => ( - api.stopRemoteDeployment(selectedDeviceId, deployment.id) - ))} + onStop={() => stopRemote(deployment)} + actionProgress={remoteOperationProgress[deployment.id]} onRollback={() => { if (!window.confirm(`Roll back "${deployment.name}" to its previous revision?`)) return actRemote(() => api.rollbackRemoteDeployment( @@ -1548,6 +1632,7 @@ function RemoteDeploymentRow({ onStop, onRollback, onDelete, + actionProgress, }: { deployment: RemoteDeployment targetDeviceId: string @@ -1564,6 +1649,7 @@ function RemoteDeploymentRow({ onStop: () => void onRollback: () => void onDelete: () => void + actionProgress?: DeviceActionProgress }) { const isRunning = deployment.state === 'running' const isMapping = Number(deployment.mapping_control_count || 0) === 1 @@ -1650,6 +1736,7 @@ function RemoteDeploymentRow({ Delete
+ {actionProgress && } {isRunning && isMapping && ( (null) - const [snapshot, setSnapshot] = useState(null) - const [message, setMessage] = useState('Connecting to the live map…') - - useEffect(() => { - let cancelled = false - const pull = async () => { - try { - const next = await api.remoteDeploymentMapSnapshot(deviceId, deploymentId) - if (cancelled) return - setSnapshot(next) - setMessage(next.report || 'Waiting for occupancy data…') - } catch (err) { - if (!cancelled) setMessage(err instanceof Error ? err.message : String(err)) - } - } - void pull() - const timer = window.setInterval(pull, 2000) - return () => { cancelled = true; window.clearInterval(timer) } - }, [deviceId, deploymentId]) - - useEffect(() => { - const canvas = canvasRef.current - const info = snapshot?.message?.info - const data = snapshot?.message?.data - const width = Math.max(0, Number(info?.width || 0)) - const height = Math.max(0, Number(info?.height || 0)) - if (!canvas || !Array.isArray(data) || !width || !height || data.length < width * height) return - canvas.width = width - canvas.height = height - const context = canvas.getContext('2d') - if (!context) return - const image = context.createImageData(width, height) - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const sourceIndex = y * width + x - const targetIndex = ((height - y - 1) * width + x) * 4 - const occupancy = Number(data[sourceIndex]) - const color = occupancy < 0 ? [30, 36, 48] : occupancy >= 65 ? [20, 24, 31] : [226, 232, 240] - image.data[targetIndex] = color[0] - image.data[targetIndex + 1] = color[1] - image.data[targetIndex + 2] = color[2] - image.data[targetIndex + 3] = 255 - } - } - context.putImageData(image, 0, 0) - }, [snapshot]) - - const info = snapshot?.message?.info - const fresh = snapshot?.status?.source_fresh !== false && Boolean(snapshot?.message?.data?.length) +function DeploymentActionProgress({ value }: { value: DeviceActionProgress }) { + const failed = value.progress <= 0 && value.message.toLowerCase().includes('failed') return ( -
-
- Live mapping · {topic} - {fresh ? 'LIVE' : 'WAITING'} -
- -
- {message} - {info?.width && info?.height && ( - {info.width} × {info.height} cells · {Number(info.resolution || 0).toFixed(3)} m/cell - )} +
+
+ {value.message} + {value.progress}%
-
+ + {mappingDeployment?.id && ( +
+
+ Mapping + + {runningDeployment + ? `Live occupancy stream · ${mappingDeployment.mapping_topic || '/map'}` + : `Saved deployment · ${mappingDeployment.state} · ready to restart`} + +
+ + {runningDeployment ? 'RUNNING' : 'STOPPED'} + +
+ )}
)} + {runningDeployment?.id && mappingDeployment?.id && ( + + )}
+ {runningDeployment?.id && mappingDeployment?.id && ( + + )} {showMonitor && (
(null) + const [snapshot, setSnapshot] = useState(null) + const [message, setMessage] = useState('Connecting to the live map…') + + useEffect(() => { + let cancelled = false + const pull = async () => { + try { + const next = await api.remoteDeploymentMapSnapshot(deviceId, deploymentId) + if (cancelled) return + setSnapshot(next) + setMessage(next.report || 'Waiting for occupancy data…') + } catch (err) { + if (!cancelled) setMessage(err instanceof Error ? err.message : String(err)) + } + } + void pull() + const timer = window.setInterval(pull, 2000) + return () => { cancelled = true; window.clearInterval(timer) } + }, [deviceId, deploymentId]) + + useEffect(() => { + const canvas = canvasRef.current + const info = snapshot?.message?.info + const data = snapshot?.message?.data + const width = Math.max(0, Number(info?.width || 0)) + const height = Math.max(0, Number(info?.height || 0)) + if (!canvas || !Array.isArray(data) || !width || !height || data.length < width * height) return + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) return + const image = context.createImageData(width, height) + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const sourceIndex = y * width + x + const targetIndex = ((height - y - 1) * width + x) * 4 + const occupancy = Number(data[sourceIndex]) + const color = occupancy < 0 ? [30, 36, 48] : occupancy >= 65 ? [20, 24, 31] : [226, 232, 240] + image.data[targetIndex] = color[0] + image.data[targetIndex + 1] = color[1] + image.data[targetIndex + 2] = color[2] + image.data[targetIndex + 3] = 255 + } + } + context.putImageData(image, 0, 0) + }, [snapshot]) + + const info = snapshot?.message?.info + const fresh = snapshot?.status?.source_fresh !== false && Boolean(snapshot?.message?.data?.length) + return ( +
+
+ Live mapping · {topic} + {fresh ? 'LIVE' : 'WAITING'} +
+ +
+ {message} + {info?.width && info?.height && ( + {info.width} × {info.height} cells · {Number(info.resolution || 0).toFixed(3)} m/cell + )} +
+
+ ) +} diff --git a/editor/src/index.css b/editor/src/index.css index 9a61587..7646eb5 100644 --- a/editor/src/index.css +++ b/editor/src/index.css @@ -261,6 +261,38 @@ html[data-theme="light"] .bn-logo-image-light { transform: translateY(1px); } +.bn-top-icon-button { + display: inline-grid; + width: 30px; + min-width: 30px; + padding: 5px !important; + place-items: center; +} + +.bn-top-icon { + display: block; + width: 17px; + height: 17px; +} + +.bn-top-icon.is-spinning { + animation: bn-toolbar-spin .8s linear infinite; +} + +.bn-top-clear-button:hover:not(:disabled) { + color: var(--err) !important; +} + +@keyframes bn-toolbar-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .bn-top-icon.is-spinning { + animation: none; + } +} + .bn-top-select { background: var(--panel); border: 1px solid var(--line2); @@ -688,6 +720,8 @@ html[data-theme="light"] .bn-logo-image-light { .bn-topbar { overflow-x: auto; overflow-y: hidden; + gap: 8px; + padding-inline: 8px; scrollbar-width: thin; scrollbar-color: var(--line2) transparent; } @@ -717,7 +751,6 @@ html[data-theme="light"] .bn-logo-image-light { flex-shrink: 0; } -.bn-topbar-group-label, .bn-template-search-icon, .bn-inspector-empty-sections { display: none; @@ -5437,6 +5470,40 @@ button.bn-device-fact { border-radius: 6px; } +.bn-device-capability-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--line2); + border-radius: 6px; + background: color-mix(in srgb, var(--panel) 86%, var(--ok) 14%); +} + +.bn-device-capability-control > div { + display: grid; + gap: 3px; +} + +.bn-device-capability-control > div > span { + color: var(--tx3); + font-family: var(--font-mono); + font-size: 11px; +} + +.bn-device-capability-control > span { + color: var(--tx3); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: .08em; +} + +.bn-device-capability-control > span.is-live { + color: var(--ok); +} + .bn-live-map-head, .bn-live-map-foot { display: flex; @@ -6229,7 +6296,7 @@ html[data-ui-test="refined"] .bn-topbar-group { align-items: center; gap: 4px; min-height: 38px; - padding: 12px 16px 2px; + padding: 2px 16px; } html[data-ui-test="refined"] .bn-topbar-group + .bn-topbar-group { @@ -6237,19 +6304,6 @@ html[data-ui-test="refined"] .bn-topbar-group + .bn-topbar-group { border-left: 1px solid var(--ui-test-separator); } -html[data-ui-test="refined"] .bn-topbar-group-label { - position: absolute; - top: 1px; - left: 14px; - display: block; - color: var(--tx3); - font-size: 11px; - font-weight: 650; - letter-spacing: .08em; - line-height: 1; - text-transform: uppercase; -} - html[data-ui-test="refined"] .bn-top-button, html[data-ui-test="refined"] .bn-top-select { min-height: 29px; @@ -7431,7 +7485,6 @@ html[data-ui-test="refined"] .bn-ros-advanced { /* ── Blacknode node-language experiment ────────── These additions remain behind UI Test so the production node language can be compared against the current editor without changing saved workflows. */ -.bn-node-density-controls, .bn-node-glyph, .bn-node-runtime-state, .bn-node-hover-preview, @@ -7441,33 +7494,6 @@ html[data-ui-test="refined"] .bn-ros-advanced { display: none; } -html[data-ui-test="refined"] .bn-node-density-controls { - display: inline-flex; - overflow: hidden; - margin-left: 4px; - padding: 2px; - border-radius: 9px; - background: var(--ui-test-surface-secondary); - box-shadow: inset 0 0 0 1px var(--ui-test-separator); -} - -html[data-ui-test="refined"] .bn-node-density-button { - min-height: 25px; - padding: 3px 8px; - border: 0; - border-radius: 7px !important; - background: transparent; - color: var(--tx3); - font: 570 11px var(--font-ui); - cursor: pointer; -} - -html[data-ui-test="refined"] .bn-node-density-button.is-active { - background: var(--ui-test-card); - color: var(--tx1); - box-shadow: 0 1px 4px rgba(0, 0, 0, .14); -} - /* Palette categories carry a persistent, restrained identity color. */ html[data-ui-test="refined"] .bn-node-palette-groups { padding: 10px 8px 24px !important; @@ -7882,80 +7908,6 @@ html[data-ui-test="refined"] .react-flow__edge.bn-edge-executing .react-flow__ed } } -/* Compact mode is visual-only: React Flow remeasures the CSS-sized cards and - workflow JSON keeps its detailed dimensions untouched. */ -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode { - width: 178px !important; - height: auto !important; - min-width: 178px !important; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-frame { - width: 178px !important; - height: auto !important; - min-width: 178px !important; - min-height: 0 !important; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-frame > div:not(.bn-node-header):not(.bn-node-ports) { - display: none !important; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-header { - min-height: 38px; - padding: 7px 8px !important; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-header-glyph { - width: 23px; - height: 23px; - flex-basis: 23px; - border-radius: 6px; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-title { - font-size: 13px !important; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-type, -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-runtime-state span, -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-cook-button, -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-port-section-label, -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-port-row > span { - display: none !important; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-runtime-state { - padding: 3px; - border: 0; - background: transparent; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-ports { - display: grid !important; - grid-template-columns: repeat(4, minmax(0, 1fr)); - min-height: 28px; - padding: 6px 10px !important; - border-top-color: var(--ui-test-separator); -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-port-row { - min-width: 0; - min-height: 16px; - padding: 1px 0 !important; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-port-row .react-flow__handle { - position: relative !important; - top: auto !important; - right: auto !important; - left: auto !important; - width: 10px !important; - height: 10px !important; - margin: auto; - transform: none !important; -} - @media (prefers-reduced-motion: reduce) { html[data-ui-test="refined"] .bn-node-frame.is-executing, html[data-ui-test="refined"] .bn-node-runtime-state[data-tone="running"] i, @@ -9048,17 +9000,6 @@ html[data-ui-test="refined"] .bn-node-header-glyph + div .bn-node-type { max-width: 100%; } -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-header { - --bn-node-header-gap: 6px; - --bn-node-icon-title-gap: 9px; -} - -html[data-ui-test="refined"][data-node-density="compact"] .react-flow__node-blacknode .bn-node-header-glyph { - width: 23px; - height: 23px; - flex-basis: 23px; -} - /* ── Refined preview: compact template category rhythm ─── */ html[data-ui-test="refined"] .bn-template-group { gap: 2px !important; @@ -11212,6 +11153,14 @@ html[data-ui-test="refined"] .bn-package-action-menu summary { padding-inline: 7px !important; } +.bn-file-menu-trigger { + display: inline-flex; + min-width: 64px; + align-items: center; + justify-content: space-between; + gap: 7px; +} + .bn-simulation-viewer-menu-items { position: absolute; top: calc(100% + 7px); @@ -11254,6 +11203,42 @@ html[data-ui-test="refined"] .bn-package-action-menu summary { cursor: default; } +.bn-file-menu-items { + width: 250px; +} + +.bn-file-menu-items button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; +} + +.bn-file-menu-items button span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bn-file-menu-items button small { + color: var(--tx3); + font: 10px var(--font-mono); +} + +.bn-file-menu-divider { + height: 1px; + margin: 5px 6px; + background: var(--line2); +} + +.bn-file-menu-label { + padding: 4px 9px 3px; + color: var(--tx3); + font: 650 10px var(--font-ui); + letter-spacing: .08em; + text-transform: uppercase; +} + /* Local file browser ------------------------------------------------------ */ .bn-local-file-picker-backdrop { position: fixed; @@ -11915,8 +11900,8 @@ html[data-ui-test="refined"] .bn-package-action-menu summary { white-space: nowrap; } -/* Hosted editor: keep the account action in layout and move secondary view - actions into a menu so the 52px toolbar never collides or shows a scrollbar. */ +/* Hosted editor: keep the account action in layout and let the compact toolbar + controls scroll invisibly when the viewport is narrow. */ .bn-topbar.is-hosted-preview { overflow: hidden; } @@ -11950,10 +11935,6 @@ html[data-ui-test="refined"] .bn-topbar.is-hosted-preview .bn-topbar-group { padding: 0 10px; } -html[data-ui-test="refined"] .bn-topbar.is-hosted-preview .bn-topbar-group-label { - display: none; -} - .bn-topbar.is-hosted-preview > .bn-cloud-account-trigger { position: static; z-index: auto; @@ -11965,27 +11946,6 @@ html[data-ui-test="refined"] .bn-topbar.is-hosted-preview .bn-topbar-group-label display: none; } -.bn-hosted-view-menu-trigger { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.bn-hosted-view-menu-divider { - height: 1px; - margin: 4px 6px; - background: var(--line2); -} - -.bn-hosted-view-menu-items button.is-danger { - color: var(--err); -} - -.bn-hosted-view-menu-items button.is-danger:hover { - background: var(--err-soft); - color: var(--err); -} - @media (max-width: 1180px) { .bn-topbar.is-hosted-preview .bn-brand { width: 52px !important; diff --git a/python/blacknode/package_index.py b/python/blacknode/package_index.py index dbd32d3..51e8f2a 100644 --- a/python/blacknode/package_index.py +++ b/python/blacknode/package_index.py @@ -585,6 +585,7 @@ "node_types": [ "ComputeDevice", "DeviceInspect", + "PhysicalRobot", "RobotAttachment", "RobotAttachmentList", "RobotCapabilityBinding", @@ -592,12 +593,14 @@ "RobotCapabilityList", "RobotCapabilityProfile", "RobotConnectionDashboard", + "RobotDeployment", "RobotDiscovery", "RobotMonitor", "RobotRawMonitor", "RobotRawMonitorMockProvider", "RobotROSCapabilityDiscover", "RobotROSInterfaceCheck", + "RobotStream", "RobotUSBDiscovery" ] }, @@ -633,6 +636,7 @@ "ComputeDevice", "DeviceInspect", "HardwareCapabilities", + "PhysicalRobot", "Robot", "RobotAttachment", "RobotAttachmentList", @@ -645,6 +649,7 @@ "RobotCapabilityProfile", "RobotConnectionDashboard", "RobotDefinition", + "RobotDeployment", "RobotDiscovery", "RobotDriverDescriptor", "RobotDriverLauncher", @@ -661,6 +666,7 @@ "RobotROSCapabilityDiscover", "RobotROSInterfaceCheck", "RobotServo", + "RobotStream", "RobotUSBDiscovery" ] }, diff --git a/tests/test_compute_device_inspection.py b/tests/test_compute_device_inspection.py index c111fbc..a12eb77 100644 --- a/tests/test_compute_device_inspection.py +++ b/tests/test_compute_device_inspection.py @@ -163,6 +163,18 @@ def ros2_diagnostics(self): "warnings": [], } + def list_deployments(self): + return { + "deployments": [{ + "id": "room-map", + "name": "Room map", + "state": "running", + "target_device_id": "", + "mapping_control_count": 1, + "mapping_topic": "/map", + }] + } + with patch.object(registry, "host_client", return_value=Runtime()): response = self.client.get( f"/device-hosts/{device['id']}/live-inspection" @@ -189,6 +201,13 @@ def ros2_diagnostics(self): "blacknode-robot capabilities component", inspection["ros2_graph"]["report"], ) + self.assertEqual(inspection["deployments"][0]["id"], "room-map") + map_stream = next( + stream for stream in inspection["streams"] + if stream["capability"] == "map" + ) + self.assertEqual(map_stream["topic"], "/map") + self.assertEqual(map_stream["message_type"], "nav_msgs/msg/OccupancyGrid") self.assertNotIn("password", json.dumps(inspection).lower()) def test_editor_cook_injects_live_state_without_saving_it(self): diff --git a/tests/test_editor_deployed_capabilities.py b/tests/test_editor_deployed_capabilities.py new file mode 100644 index 0000000..ed4adba --- /dev/null +++ b/tests/test_editor_deployed_capabilities.py @@ -0,0 +1,31 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_mapping_actions_report_progress_and_remain_on_robot_card(): + deployments = ( + ROOT / "editor" / "src" / "components" / "DeploymentsPanel.tsx" + ).read_text(encoding="utf-8") + devices = ( + ROOT / "editor" / "src" / "components" / "DevicesPanel.tsx" + ).read_text(encoding="utf-8") + + assert "Saving occupancy grid and pose graph on the robot" in deployments + assert "Closing map stream and SLAM process" in deployments + assert "DeploymentActionProgress" in deployments + assert "Restart mapping" in devices + assert "Save map" in devices + assert "Stop mapping" in devices + assert "