From 2cbb71963cf6bfccbb679b13426081d121f1620e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:11:24 -0700 Subject: [PATCH] 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 --- .../SshStatusSegment.managed-hosts.test.tsx | 231 ++++++++++++++++++ .../status-bar/SshStatusSegment.tsx | 37 ++- 2 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/status-bar/SshStatusSegment.managed-hosts.test.tsx diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.managed-hosts.test.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.managed-hosts.test.tsx new file mode 100644 index 00000000000..025d09ce562 --- /dev/null +++ b/src/renderer/src/components/status-bar/SshStatusSegment.managed-hosts.test.tsx @@ -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(), + sshConnectionStates: new Map(), + runtimeEnvironments: new Array(), + runtimeStatusByEnvironmentId: new Map(), + 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) =>
{children}
+ return { + DropdownMenu: content, + DropdownMenuContent: content, + DropdownMenuItem: content, + DropdownMenuSeparator: () =>
, + 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() + 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() + 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() + 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() + 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() + 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() + 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() + expect(screen.getAllByText('Claw box')).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index fa1153418a4..c01c5a11f83 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -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,