mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user