fix(ssh): show managed hosts once in the connection menu (#28277)

* fix(ssh): show managed hosts once in the connection menu

* fix(ssh): name the merged host row after its SSH host in the status menu
This commit is contained in:
Neil
2026-10-13 03:11:24 -07:00
committed by GitHub
parent 3a7ec7dff1
commit 2cbb71963c
2 changed files with 261 additions and 7 deletions
@@ -0,0 +1,231 @@
// @vitest-environment happy-dom
import type { PropsWithChildren } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import { SshStatusSegment } from './SshStatusSegment'
const mocks = vi.hoisted(() => ({
disconnectRuntime: vi.fn(async () => undefined),
disconnectSsh: vi.fn(async () => undefined),
connectRuntime: vi.fn(async () => true),
readSnapshots: vi.fn(async () => undefined)
}))
function noSettings(): GlobalSettings | null {
return null
}
let state = {
sshTargetLabels: new Map<string, string>(),
sshConnectionStates: new Map<string, SshConnectionState>(),
runtimeEnvironments: new Array<PublicKnownRuntimeEnvironment>(),
runtimeStatusByEnvironmentId: new Map<string, RuntimeEnvironmentStatus>(),
settings: noSettings(),
readRuntimeHostStatusSnapshots: mocks.readSnapshots,
hydrateRuntimeEnvironmentStatuses: vi.fn(),
recordFeatureInteraction: vi.fn(),
openSettingsTarget: vi.fn(),
setActiveView: vi.fn()
}
vi.mock('../../store', () => {
const useAppStore = (selector: (value: typeof state) => unknown) => selector(state)
useAppStore.getState = () => state
return { useAppStore }
})
vi.mock('./runtime-environment-explicit-connect', () => ({
connectRuntimeHostAndReloadProjects: mocks.connectRuntime
}))
vi.mock('@/components/ui/dropdown-menu', () => {
const content = ({ children }: PropsWithChildren) => <div>{children}</div>
return {
DropdownMenu: content,
DropdownMenuContent: content,
DropdownMenuItem: content,
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: content,
DropdownMenuSub: content,
DropdownMenuSubContent: content,
DropdownMenuSubTrigger: content
}
})
function environment(id: string, name: string): PublicKnownRuntimeEnvironment {
return {
id,
name,
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: id,
endpoints: [],
preferredEndpointId: id
}
}
function sshState(managedServer?: SshConnectionState['managedServer']): SshConnectionState {
return {
targetId: 'ssh-openclaw',
status: 'connected',
error: null,
reconnectAttempt: 0,
managedServer
}
}
function readyStatus(): RuntimeEnvironmentStatus {
return {
status: {
runtimeId: 'fixture',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
desktopWindowStatus: 'available',
liveTabCount: 0,
liveLeafCount: 0
},
checkedAt: 1,
remoteControl: {
state: 'ready',
pendingRequestCount: 0,
subscriptionCount: 0,
reconnectAttempt: 0,
lastConnectedAt: null,
lastClose: null,
lastError: null
}
}
}
beforeEach(() => {
vi.clearAllMocks()
state = {
...state,
sshTargetLabels: new Map([['ssh-openclaw', 'openclaw']]),
sshConnectionStates: new Map([
['ssh-openclaw', sshState({ kind: 'managed', environmentId: 'server-openclaw' })]
]),
runtimeEnvironments: [
{
...environment('server-openclaw', 'openclaw'),
orcadDeployment: {
sshTargetId: 'ssh-openclaw',
sshTargetGeneration: 1,
localPort: 7001,
remotePort: 6768
}
},
environment('server-windows', 'win-lowspec')
],
runtimeStatusByEnvironmentId: new Map([
['server-openclaw', readyStatus()],
['server-windows', readyStatus()]
]),
settings: noSettings()
}
Object.assign(window, {
api: {
runtimeEnvironments: { disconnect: mocks.disconnectRuntime },
ssh: { disconnect: mocks.disconnectSsh }
}
})
})
afterEach(() => {
cleanup()
Reflect.deleteProperty(window, 'api')
})
describe('the status menu for a managed SSH host', () => {
it('shows and counts one row per machine and disconnects the managed server', async () => {
render(<SshStatusSegment compact={false} iconOnly={false} />)
expect(screen.getAllByText('openclaw')).toHaveLength(1)
expect(screen.queryByText('SSH Host')).toBeNull()
expect(
screen.getByRole('button', { name: 'Remote host connection status' }).textContent
).toContain('2 hosts')
fireEvent.click(screen.getAllByRole('button', { name: 'Disconnect' })[0])
await waitFor(() =>
expect(mocks.disconnectRuntime).toHaveBeenCalledWith({ selector: 'server-openclaw' })
)
expect(mocks.disconnectSsh).not.toHaveBeenCalled()
})
it('keeps only the SSH row when the host falls back to its relay', async () => {
state.sshConnectionStates.set(
'ssh-openclaw',
sshState({ kind: 'relay', reason: 'source_changed' })
)
render(<SshStatusSegment compact={false} iconOnly={false} />)
expect(screen.getAllByText('openclaw')).toHaveLength(1)
expect(screen.getByText('SSH Host')).toBeTruthy()
expect(
screen.getByRole('button', { name: 'Remote host connection status' }).textContent
).toContain('2 hosts')
fireEvent.click(screen.getAllByRole('button', { name: 'Disconnect' })[1])
await waitFor(() =>
expect(mocks.disconnectSsh).toHaveBeenCalledWith({ targetId: 'ssh-openclaw' })
)
expect(mocks.disconnectRuntime).not.toHaveBeenCalled()
})
it('keeps the managed server reconnect action when the pair is disconnected', async () => {
state.sshConnectionStates.set('ssh-openclaw', { ...sshState(), status: 'disconnected' })
state.runtimeStatusByEnvironmentId.set('server-openclaw', {
status: null,
checkedAt: 1,
snapshot: {
environmentId: 'server-openclaw',
pairingRevision: 1,
sequence: 1,
checkedAt: 1,
status: null,
verification: 'unavailable',
transport: 'disconnected',
retired: true
}
})
render(<SshStatusSegment compact={false} iconOnly={false} />)
expect(screen.getAllByText('openclaw')).toHaveLength(1)
expect(
screen.getByRole('button', { name: 'Remote host connection status' }).textContent
).toContain('1 host')
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
await waitFor(() => expect(mocks.connectRuntime).toHaveBeenCalledWith('server-openclaw'))
})
it('does not merge an independently paired server just because its name matches', () => {
state.runtimeEnvironments[0] = environment('server-openclaw', 'openclaw')
render(<SshStatusSegment compact={false} iconOnly={false} />)
expect(screen.getAllByText('openclaw')).toHaveLength(2)
expect(
screen.getByRole('button', { name: 'Remote host connection status' }).textContent
).toContain('3 hosts')
})
it('keeps the SSH row until the managed server catalog has arrived', () => {
state.runtimeEnvironments = [environment('server-windows', 'win-lowspec')]
render(<SshStatusSegment compact={false} iconOnly={false} />)
expect(screen.getAllByText('openclaw')).toHaveLength(1)
expect(screen.getByText('SSH Host')).toBeTruthy()
})
it('names the managed row after its SSH host, honouring a rename saved on the SSH id', () => {
state.runtimeEnvironments[0] = { ...state.runtimeEnvironments[0], name: 'orcad-ssh-openclaw' }
render(<SshStatusSegment compact={false} iconOnly={false} />)
expect(screen.getAllByText('openclaw')).toHaveLength(1)
expect(screen.queryByText('orcad-ssh-openclaw')).toBeNull()
cleanup()
state.settings = {
...getDefaultSettings('/home/test'),
hostSettingOverrides: { 'ssh:ssh-openclaw': { displayLabel: 'Claw box' } }
}
render(<SshStatusSegment compact={false} iconOnly={false} />)
expect(screen.getAllByText('Claw box')).toHaveLength(1)
})
})
@@ -14,8 +14,11 @@ import { translate } from '@/i18n/i18n'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import {
isRuntimeOwnedSshTargetId,
toRuntimeExecutionHostId
toRuntimeExecutionHostId,
toSshExecutionHostId
} from '../../../../shared/execution-host'
import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry'
import { pickerExecutionHosts } from '../../../../shared/managed-orcad-execution-host'
import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { RuntimeHostStatusRow } from './RuntimeHostStatusRow'
import { connectedHostCountLabel, connectingHostsLabel } from './ssh-status-segment-copy'
@@ -52,26 +55,46 @@ export function SshStatusSegment({
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings])
const userManagedEnvironments = runtimeEnvironments.filter(isUserManagedRuntimeEnvironment)
// Why the registry: a managed server and its SSH host are one machine, shown on its active route.
const visibleHostLabels = new Map(
pickerExecutionHosts(
buildExecutionHostRegistry({
repos: [],
settings,
hostSource: 'configured-only',
sshTargetLabels,
sshConnectionStates,
runtimeEnvironments: userManagedEnvironments,
runtimeStatusByEnvironmentId,
hostLabelOverrides
})
).map((host) => [host.id, host.label])
)
const targets = Array.from(sshTargetLabels.entries())
// Why: runtime-owned (per-workspace-env) SSH targets are hidden — never list them
// as a user-facing SSH host in the status bar.
.filter(([id]) => !isRuntimeOwnedSshTargetId(id))
.filter(
([id]) => !isRuntimeOwnedSshTargetId(id) && visibleHostLabels.has(toSshExecutionHostId(id))
)
.map(([id, label]) => {
const state = sshConnectionStates.get(id)
return {
id,
label,
label: visibleHostLabels.get(toSshExecutionHostId(id)) || label,
status: state?.status ?? ('disconnected' satisfies SshConnectionStatus)
}
})
const runtimeHosts = runtimeEnvironments
.filter(isUserManagedRuntimeEnvironment)
const runtimeHosts = userManagedEnvironments
.filter((environment) => visibleHostLabels.has(toRuntimeExecutionHostId(environment.id)))
.map((environment) => {
const statusEntry = runtimeStatusByEnvironmentId.get(environment.id)
const override = hostLabelOverrides.get(toRuntimeExecutionHostId(environment.id))
return {
id: environment.id,
label: override || environment.name || environment.id,
label:
visibleHostLabels.get(toRuntimeExecutionHostId(environment.id)) ||
environment.name ||
environment.id,
snapshot: statusEntry?.snapshot,
status: statusEntry?.status ?? null,
active: settings?.activeRuntimeEnvironmentId === environment.id,