Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions editor-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
))
Expand Down Expand Up @@ -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}",
)


Expand Down Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions editor/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1061,7 +1061,7 @@ export default function App() {
<div style={{ flex: 1, position: 'relative' }} onDrop={onDrop} onDragOver={onDragOver} onMouseMove={trackMouseFlowPos}>

{/* ── top bar ── */}
<div style={{
<div className="bn-topbar" style={{
position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10,
background: 'var(--panel)',
borderBottom: '1px solid var(--line)',
Expand All @@ -1077,9 +1077,9 @@ export default function App() {
<span>BLACKNODE</span>
</div>

<div style={{ flex: 1 }} />
<div className="bn-topbar-spacer" style={{ flex: 1 }} />

<span style={{ color: 'var(--tx3)', fontSize: 14 }}>right-click to add</span>
<span className="bn-top-hint" style={{ color: 'var(--tx3)', fontSize: 14 }}>right-click to add</span>

<select
className="bn-top-select"
Expand Down Expand Up @@ -1177,7 +1177,7 @@ export default function App() {
Clear
</button>

<span style={{
<span className="bn-backend-status" style={{
padding: '3px 10px',
borderRadius: 20,
background: serverOk ? (isDark ? '#0d2a1a' : '#dcfce7') : (isDark ? '#2a0d0d' : '#fee2e2'),
Expand All @@ -1194,7 +1194,10 @@ export default function App() {
? 'Backend connected'
: `Backend disconnected${serverError ? `: ${serverError}` : ''}`}
>
{serverOk ? '● backend' : `○ backend offline${serverError ? ': ' + serverError : ''}`}
<span>{serverOk ? '●' : '○'}</span>
<span className="bn-backend-label">
{serverOk ? 'backend' : `backend offline${serverError ? ': ' + serverError : ''}`}
</span>
</span>
</div>

Expand Down
1 change: 1 addition & 0 deletions editor/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export interface HardwareDeviceStatus {
positions?: Record<string, number>
raw_positions?: Record<string, number>
error?: string
notice?: string
updated_at?: number
calibration?: {
name?: string
Expand Down
32 changes: 26 additions & 6 deletions editor/src/components/DeploymentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { useStore } from '../store'

const REFRESH_INTERVAL_MS = 3000
const DEFAULT_DEPLOYMENT_NAME = 'Deployed graph'
const ROBOT_NODE_TYPES = new Set(['Robot', 'RobotProfileLoad'])
const JOINT_MOTION_NODE_TYPES = new Set([
'ROS2SetJoint',
Expand All @@ -32,6 +33,13 @@ function normalizedHardwareIdentity(value: unknown): string {
return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '')
}

function deploymentNameFromTab(name: string | undefined): string {
const cleanName = name?.trim() ?? ''
return !cleanName || cleanName.toLocaleLowerCase() === 'untitled'
? DEFAULT_DEPLOYMENT_NAME
: cleanName
}

function deviceForHardwareIdentity(
devices: HardwareDevice[],
hardwareId: string,
Expand Down Expand Up @@ -96,6 +104,8 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr
const stopRuntimeServices = useStore(s => s.stopRuntimeServices)
const tabs = useStore(s => s.tabs)
const activeTabId = useStore(s => s.activeTabId)
const activeTab = tabs.find(tab => tab.id === activeTabId)
const activeDeploymentName = deploymentNameFromTab(activeTab?.name)
const switchTab = useStore(s => s.switchTab)
const workflowRevision = useStore(s => s.workflowRevision)
const workflowMetadata = useStore(s => s.workflowMetadata)
Expand Down Expand Up @@ -184,6 +194,15 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr
return () => window.clearInterval(id)
}, [])

useEffect(() => {
setRemoteDeploymentName(activeDeploymentName)
}, [activeDeploymentName, activeTabId])

useEffect(() => {
setPreflight(null)
setRemoteNotice(null)
}, [activeTabId])

useEffect(() => {
let cancelled = false
const loadRequirements = async () => {
Expand Down Expand Up @@ -372,9 +391,9 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr
}

const handleDeploy = async (autostart: boolean) => {
const name = window.prompt('Name this deployment', 'Deployed graph')
const name = window.prompt('Name this deployment', activeDeploymentName)
if (name === null) return
const finalName = name.trim() || 'Deployed graph'
const finalName = name.trim() || activeDeploymentName
await act(async () => {
// Only a running deployment competes for the hardware, so stop the
// editor's live graph first in that case. Save-only just writes the
Expand Down Expand Up @@ -426,7 +445,7 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr
setTargetStatus(result.status)
}
setPreflight(result)
setRemoteDeploymentName(current => current.trim() || result.workflow.name)
setRemoteDeploymentName(current => current.trim() || activeDeploymentName)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
Expand Down Expand Up @@ -461,8 +480,9 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr
const name = (
existing?.name
|| remoteDeploymentName.trim()
|| activeDeploymentName
|| preflight.workflow.name
|| 'Deployed graph'
|| DEFAULT_DEPLOYMENT_NAME
)
setBusy(true)
setRemoteAction(start ? 'send-run' : 'send')
Expand Down Expand Up @@ -856,7 +876,7 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr
<input
value={remoteDeploymentName}
onChange={event => setRemoteDeploymentName(event.target.value)}
placeholder={preflight.workflow.name || 'Deployed graph'}
placeholder={activeDeploymentName}
disabled={busy}
/>
</label>
Expand Down Expand Up @@ -911,7 +931,7 @@ export default function DeploymentsPanel({ onOpenTemplates }: DeploymentsPanelPr
Refresh
</button>
</div>
<div className="bn-runs-list">
<div className="bn-runs-list bn-card-list bn-remote-deployment-list">
{selectedDeviceId && remoteDeployments.length === 0 && (
<div className="bn-runs-empty">
No deployment has been sent to this device. Check the setup, then choose
Expand Down
71 changes: 46 additions & 25 deletions editor/src/components/DevicesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -250,7 +250,9 @@ export default function DevicesPanel() {
<div className="bn-runs-toolbar">
<div>
<div className="bn-runs-title">Devices</div>
<div className="bn-runs-subtitle">{devices.length} paired</div>
<div className="bn-runs-subtitle">
{devices.length} paired {devices.length === 1 ? 'device' : 'devices'}
</div>
</div>
<div className="bn-runs-actions">
<button onClick={refresh} disabled={busy} style={miniButton}>Refresh</button>
Expand Down Expand Up @@ -361,7 +363,7 @@ export default function DevicesPanel() {

{error && <div className="bn-runs-error">{error}</div>}

<div className="bn-runs-list">
<div className="bn-runs-list bn-card-list bn-device-list">
{devices.length === 0 && !showForm && !error && (
<div className="bn-runs-empty">
No hardware device is paired. Run <strong>./pair.sh</strong> on the device,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -450,18 +454,20 @@ function DeviceRow({
{state?.error && (
<div className="bn-run-error-line bn-device-error" role="alert">{state.error}</div>
)}
{status?.error && (
<div className="bn-device-error-action" role="alert">
<div className="bn-run-error-line bn-device-error">{status.error}</div>
{deploymentLease?.id && (
<button
onClick={() => onStopDeployment(deploymentLease.id)}
disabled={busy || state?.loading}
style={dangerButton}
>
Stop “{deploymentLease.name}” and check again
</button>
)}
{deploymentLease && (
<div className="bn-device-lease-notice" role="status">
<strong>Deployment controls this robot</strong>
<span>
{status?.notice || (
`“${deploymentLease.name}” is running. Device checks pause here `
+ 'to prevent two processes from opening the same hardware connection.'
)}
</span>
</div>
)}
{status?.error && !deploymentLease && (
<div className="bn-run-error-line bn-device-error" role="alert">
{status.error}
</div>
)}
{runtime && !runtime.ok && (
Expand All @@ -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
Expand All @@ -494,12 +500,16 @@ function DeviceRow({
</div>
<div className="bn-run-detail">
<div className="bn-device-facts">
<DeviceFact label="Armed" value={status ? (status.armed ? 'Yes' : 'No') : '—'} warn={Boolean(status?.armed)} />
<DeviceFact
label={deploymentLease ? 'Motion control' : 'Armed'}
value={deploymentLease ? `Deployment · ${deploymentLease.name}` : status ? (status.armed ? 'Yes' : 'No') : '—'}
warn={!deploymentLease && Boolean(status?.armed)}
/>
<DeviceFact label="Calibrated" value={status?.calibrated == null ? '—' : status.calibrated ? 'Yes' : 'No'} />
<DeviceFact
label="Hardware"
value={deploymentLease ? `Used by ${deploymentLease.name}` : connected ? 'Connected' : 'Unavailable'}
warn={leased || (status != null && !connected)}
value={deploymentLease ? 'Connected to deployment' : connected ? 'Connected' : 'Unavailable'}
warn={!leased && status != null && !connected}
/>
<DeviceFact
label="Deployment runtime"
Expand All @@ -510,7 +520,18 @@ function DeviceRow({
<DeviceFact label="Last check" value={formatCheckedAt(state?.checkedAt)} />
</div>
<div className="bn-run-detail-actions">
<button onClick={onRefresh} disabled={busy || state?.loading} style={miniButton}>Check</button>
{deploymentLease?.id && (
<button
onClick={() => onStopDeployment(deploymentLease.id)}
disabled={busy || state?.loading}
style={miniButton}
>
Stop deployment
</button>
)}
<button onClick={onRefresh} disabled={busy || state?.loading} style={miniButton}>
Refresh status
</button>
<button onClick={onRename} disabled={busy} style={miniButton}>Rename</button>
<button onClick={onRepair} disabled={busy} style={miniButton}>Re-pair</button>
<button onClick={onRemove} disabled={busy} style={dangerButton}>Remove</button>
Expand All @@ -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
}
Expand Down
23 changes: 19 additions & 4 deletions editor/src/components/NodeSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand All @@ -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,
Expand All @@ -129,6 +141,7 @@ export default function NodeSearch({
display: 'flex',
alignItems: 'center',
gap: 8,
flexShrink: 0,
}}>
<span style={{ color: 'var(--tx3)', fontSize: 16, flexShrink: 0 }}>⌕</span>
{title && (
Expand Down Expand Up @@ -162,7 +175,7 @@ export default function NodeSearch({
</div>

{/* results */}
<div ref={listRef} style={{ maxHeight: 340, overflowY: 'auto' }}>
<div ref={listRef} style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }}>
{grouped.length === 0 && (
<div style={{
padding: '16px 14px',
Expand Down Expand Up @@ -229,9 +242,11 @@ export default function NodeSearch({
padding: '7px 14px',
borderTop: '1px solid var(--line)',
display: 'flex',
flexWrap: 'wrap',
gap: 14,
color: 'var(--tx3)',
fontSize: 13,
flexShrink: 0,
}}>
<span>↑↓ navigate</span>
<span>↵ {actionLabel}</span>
Expand Down
Loading