diff --git a/editor-server/server.py b/editor-server/server.py index a380872..92e31b6 100644 --- a/editor-server/server.py +++ b/editor-server/server.py @@ -3682,7 +3682,11 @@ def validate_device_deployment(device_id: str, req: DeploymentPreflightReq): ( f"{len(remote_status.get('joint_names') or [])} joints are reporting state." if connected - else str(remote_status.get("error") or "Hardware is not connected.") + else str( + remote_status.get("error") + or remote_status.get("notice") + or "Hardware is not connected." + ) ), blocking=not connected, )) @@ -4106,7 +4110,7 @@ def _set_device_deployment_lease(device_id: str, *, leased: bool) -> None: ) raise HTTPException( 502, - f"Could not {method} the robot hardware monitor: {message}", + f"Could not {method} robot hardware access: {message}", ) @@ -4144,10 +4148,11 @@ def _deployment_aware_device_status(device_id: str) -> dict[str, Any]: "name": str(owner.get("name") or owner.get("id") or "Deployment"), "state": str(owner.get("state") or "running"), } - result["error"] = ( - "Robot hardware is being used by running deployment " - f"'{owner.get('name') or owner.get('id')}'. Stop that deployment " - "here or in Deployments before using the hardware monitor." + result.pop("error", None) + result["notice"] = ( + f"Running deployment '{owner.get('name') or owner.get('id')}' " + "controls this robot. Device checks are paused here to prevent " + "another process from opening the same hardware connection." ) return result diff --git a/editor/src/App.tsx b/editor/src/App.tsx index 03955e1..531d8e4 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -1061,7 +1061,7 @@ export default function App() {
{/* ── top bar ── */} -
BLACKNODE
-
+
- right-click to add + right-click to add setRemoteDeploymentName(event.target.value)} - placeholder={preflight.workflow.name || 'Deployed graph'} + placeholder={activeDeploymentName} disabled={busy} /> @@ -911,7 +931,7 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr Refresh
-
+
{selectedDeviceId && remoteDeployments.length === 0 && (
No deployment has been sent to this device. Check the setup, then choose diff --git a/editor/src/components/DevicesPanel.tsx b/editor/src/components/DevicesPanel.tsx index e8af63d..84bf0c1 100644 --- a/editor/src/components/DevicesPanel.tsx +++ b/editor/src/components/DevicesPanel.tsx @@ -222,7 +222,7 @@ export default function DevicesPanel() { const stopDeployment = async (device: HardwareDevice, deploymentId: string) => { if (!window.confirm( - `Stop the deployment using "${device.name}" and reconnect its hardware monitor?`, + `Stop the deployment using "${device.name}" and return control to Devices?`, )) return setBusy(true) setError(null) @@ -250,7 +250,9 @@ export default function DevicesPanel() {
Devices
-
{devices.length} paired
+
+ {devices.length} paired {devices.length === 1 ? 'device' : 'devices'} +
@@ -361,7 +363,7 @@ export default function DevicesPanel() { {error &&
{error}
} -
+
{devices.length === 0 && !showForm && !error && (
No hardware device is paired. Run ./pair.sh on the device, @@ -412,16 +414,18 @@ function DeviceRow({ const deploymentLease = status?.deployment_lease const leased = Boolean(status?.leased_to_deployment || deploymentLease) const runtimeReady = runtime?.ok === true - const ready = connected && runtimeReady - const color = ready - ? 'var(--ok)' + const ready = leased ? runtimeReady : connected && runtimeReady + const color = leased + ? runtimeReady ? 'var(--ok)' : 'var(--warn)' + : ready + ? 'var(--ok)' : serviceOnline || runtime ? 'var(--warn)' : 'var(--err)' const label = state?.loading ? 'CHECK' : leased - ? 'IN USE' + ? 'ACTIVE' : ready ? 'READY' : serviceOnline @@ -450,18 +454,20 @@ function DeviceRow({ {state?.error && (
{state.error}
)} - {status?.error && ( -
-
{status.error}
- {deploymentLease?.id && ( - - )} + {deploymentLease && ( +
+ Deployment controls this robot + + {status?.notice || ( + `“${deploymentLease.name}” is running. Device checks pause here ` + + 'to prevent two processes from opening the same hardware connection.' + )} + +
+ )} + {status?.error && !deploymentLease && ( +
+ {status.error}
)} {runtime && !runtime.ok && ( @@ -483,7 +489,7 @@ function DeviceRow({ {state?.loading ? 'Checking hardware and runtime…' : deploymentLease - ? `Running deployment: ${deploymentLease.name}` + ? `“${deploymentLease.name}” controls this robot` : ready ? 'Hardware and runtime ready' : serviceOnline @@ -494,12 +500,16 @@ function DeviceRow({
- +
- + {deploymentLease?.id && ( + + )} + @@ -527,7 +548,7 @@ function hardwareRecoveryHint(error?: string): string | null { return 'The latest check reached the device service, but the servo bus did not answer. Check servo power, bus wiring, the configured serial port, baud rate, and servo IDs. On the device, run ./probe.sh --servos 6.' } if (normalized.includes('leased') && normalized.includes('deployment')) { - return 'The hardware monitor is paused for a deployment. Restore the deployment runtime connection, then press Check so Blacknode can identify the owner or recover a stale lease.' + return 'Device checks pause while a deployment owns this robot. If no deployment is running, restore the deployment runtime connection and refresh the status to recover the stale reservation.' } return null } diff --git a/editor/src/components/NodeSearch.tsx b/editor/src/components/NodeSearch.tsx index 657cba0..67f88a5 100644 --- a/editor/src/components/NodeSearch.tsx +++ b/editor/src/components/NodeSearch.tsx @@ -97,8 +97,17 @@ export default function NodeSearch({ const flatItems = grouped.flatMap(g => g.nodes) - const left = screenPos.x + 260 > window.innerWidth ? screenPos.x - 260 : screenPos.x - const top = screenPos.y + 420 > window.innerHeight ? screenPos.y - Math.min(420, screenPos.y) : screenPos.y + const viewportMargin = 8 + const menuWidth = Math.min(260, Math.max(180, window.innerWidth - viewportMargin * 2)) + const menuHeight = Math.min(420, Math.max(160, window.innerHeight - viewportMargin * 2)) + const left = Math.max( + viewportMargin, + Math.min(screenPos.x, window.innerWidth - menuWidth - viewportMargin), + ) + const top = Math.max( + viewportMargin, + Math.min(screenPos.y, window.innerHeight - menuHeight - viewportMargin), + ) return ( <> @@ -112,7 +121,10 @@ export default function NodeSearch({ style={{ position: 'fixed', left, top, - width: 260, + width: menuWidth, + maxHeight: menuHeight, + display: 'flex', + flexDirection: 'column', background: 'var(--panel)', border: '1px solid var(--line2)', borderRadius: 10, @@ -129,6 +141,7 @@ export default function NodeSearch({ display: 'flex', alignItems: 'center', gap: 8, + flexShrink: 0, }}> {title && ( @@ -162,7 +175,7 @@ export default function NodeSearch({
{/* results */} -
+
{grouped.length === 0 && (
↑↓ navigate ↵ {actionLabel} diff --git a/editor/src/index.css b/editor/src/index.css index 842c2d8..43f0666 100644 --- a/editor/src/index.css +++ b/editor/src/index.css @@ -590,6 +590,98 @@ textarea { overflow-y: auto; } +.bn-topbar { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: var(--line2) transparent; +} + +.bn-topbar > *:not(.bn-topbar-spacer) { + flex-shrink: 0; +} + +.bn-backend-status { + display: inline-flex; + align-items: center; + gap: 4px; +} + +@media (max-width: 1180px) { + .bn-topbar { + gap: 6px !important; + padding-inline: 10px !important; + } + + .bn-top-hint { + display: none; + } + + .bn-top-button { + padding-inline: 9px; + } + + .bn-top-select { + min-width: 96px; + } +} + +@media (max-width: 860px) { + .bn-brand span { + display: none; + } + + .bn-top-button, + .bn-top-select { + padding-inline: 7px; + font-size: 12px; + } + + .bn-top-select { + min-width: 78px; + } + + .bn-backend-label { + display: none; + } + + .bn-backend-status { + padding-inline: 7px !important; + } +} + +.bn-card-list { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + background: color-mix(in srgb, var(--bg) 72%, var(--panel)); +} + +.bn-card-list .bn-run-row { + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--line2); + border-radius: 8px; + background: var(--lift); + box-shadow: 0 2px 8px color-mix(in srgb, #000 16%, transparent); +} + +.bn-card-list .bn-run-row.is-expanded { + border-color: color-mix(in srgb, var(--run-status) 38%, var(--line2)); + background: color-mix(in srgb, var(--menu-active) 44%, var(--lift)); +} + +.bn-card-list .bn-run-timeline-mark::before { + display: none; +} + +.bn-card-list .bn-runs-empty { + border: 1px dashed var(--line2); + border-radius: 8px; + background: var(--lift); +} + .bn-device-form { display: flex; flex-direction: column; @@ -1371,6 +1463,16 @@ textarea { justify-content: flex-start; } + .bn-deploy-panel .bn-robot-step-actions { + align-items: stretch; + justify-content: flex-start; + } + + .bn-deploy-panel .bn-robot-step-actions button, + .bn-deploy-panel > .bn-runs-toolbar .bn-runs-actions button { + flex: 1 1 130px; + min-height: 32px; + } } @container deployment-panel (max-width: 330px) { @@ -1388,6 +1490,12 @@ textarea { align-items: flex-start; white-space: normal; } + + .bn-deploy-panel .bn-robot-step-actions button, + .bn-deploy-panel > .bn-runs-toolbar .bn-runs-actions button { + flex-basis: 100%; + width: 100%; + } } .bn-run-error-line.bn-device-error { @@ -1415,6 +1523,29 @@ textarea { flex: 0 1 auto; } +.bn-device-lease-notice { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 8px; + padding: 9px 10px; + border: 1px solid color-mix(in srgb, var(--ok) 44%, var(--line2)); + border-radius: 6px; + background: color-mix(in srgb, var(--ok) 8%, transparent); +} + +.bn-device-lease-notice strong { + color: var(--ok); + font-size: 12px; +} + +.bn-device-lease-notice span { + color: var(--tx2); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + .bn-device-runtime-reuse { display: flex; align-items: center; diff --git a/tests/test_editor_devices.py b/tests/test_editor_devices.py index b2d4fc4..f7ae9f7 100644 --- a/tests/test_editor_devices.py +++ b/tests/test_editor_devices.py @@ -548,7 +548,9 @@ def test_device_status_identifies_the_running_lease_owner(self): "name": "Leader live", "state": "running", }) - self.assertIn("Leader live", payload["error"]) + self.assertNotIn("error", payload) + self.assertIn("Leader live", payload["notice"]) + self.assertIn("Device checks are paused", payload["notice"]) rpc_methods = [ body["method"] for method, path, _auth, body in hardware.requests @@ -1364,7 +1366,7 @@ def test_preflight_names_running_deployment_that_owns_hardware(self): ) self.assertEqual(hardware_check["status"], "fail") self.assertIn("Leader live", hardware_check["message"]) - self.assertIn("Stop that deployment", hardware_check["message"]) + self.assertIn("Device checks are paused", hardware_check["message"]) def test_staging_rejects_graph_changed_after_validation(self): hardware = _HardwareService()