fix(ssh): say why an SSH host failed in the Add project host picker (#33992)

The Add project host picker showed a failed SSH host as "Needs attention
- SSH" with no reason. Its Connect handler also overwrote the failure
main had just published with the host's earlier state, and toasted
Electron's raw IPC error. The picker row now shows the same reason
Settings → SSH Hosts does. The handler keeps main's published failure
and toasts through the shared SSH connect-failure toast.

Fixes #29648
This commit is contained in:
Neil
2026-10-26 08:40:58 -07:00
committed by GitHub
parent ee325f8d1b
commit d47ffbe46d
6 changed files with 157 additions and 25 deletions
@@ -94,6 +94,30 @@ describe('AddRepoHostSelector', () => {
expect(html).not.toContain('opacity-55')
})
it('says why a failed SSH host needs attention (#29648)', () => {
const html = renderToStaticMarkup(
<AddRepoHostSelector
hosts={[
{
id: 'ssh:ssh-1',
label: 'Builder',
detail: 'SSH',
kind: 'ssh',
health: 'error',
presence: 'configured',
failureReason: 'All configured authentication methods failed'
}
]}
selectedHostId="local"
open
onOpenChange={vi.fn()}
onSelectHost={vi.fn()}
/>
)
expect(html).toContain('Needs attention - All configured authentication methods failed')
})
it('shows exact update guidance for incompatible runtime hosts', () => {
const html = renderToStaticMarkup(
<AddRepoHostSelector
@@ -37,7 +37,8 @@ function getHostStatusDetail(host: SidebarHostOption): string {
if (host.compatibility?.kind === 'blocked') {
return describeRuntimeCompatBlock(host.compatibility)
}
return `${getSidebarHostHealthLabel(host.health, host.blockedReason)}${host.detail ? ` - ${host.detail}` : ''}`
const detail = host.failureReason ?? host.detail
return `${getSidebarHostHealthLabel(host.health, host.blockedReason)}${detail ? ` - ${detail}` : ''}`
}
export function AddRepoHostSelector({
@@ -250,7 +251,9 @@ export function AddRepoHostSelector({
<span className="truncate font-medium">{host.label}</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
<span className="min-w-0 flex-1 truncate">{getHostStatusDetail(host)}</span>
<span className="min-w-0 flex-1 truncate" title={host.failureReason}>
{getHostStatusDetail(host)}
</span>
</span>
</span>
{canConnectHost ? (
@@ -75,6 +75,38 @@ describe('sidebar host options', () => {
})
})
it('says why an SSH host failed, as Settings does (#29648)', () => {
const failed = (state: Pick<SshConnectionState, 'status'> & Partial<SshConnectionState>) =>
buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map([['ssh-1', 'Builder']]),
sshConnectionStates: new Map([
['ssh-1', { targetId: 'ssh-1', error: null, reconnectAttempt: 0, ...state }]
]),
settings: { activeRuntimeEnvironmentId: null }
}).find((host) => host.id === 'ssh:ssh-1')
expect(
failed({ status: 'auth-failed', error: 'All configured authentication methods failed' })
).toMatchObject({
health: 'error',
detail: 'SSH',
failureReason: 'All configured authentication methods failed'
})
expect(
failed({
status: 'error',
error: 'raw',
managedServer: {
kind: 'relay',
reason: 'refused',
detail: 'This SSH host cannot move yet: automations (1).'
}
})?.failureReason
).toContain('automations (1)')
expect(failed({ status: 'connected' })).not.toHaveProperty('failureReason')
})
it('includes the focused runtime compatibility host', () => {
const hosts = buildSidebarHostOptions({
repos: [],
@@ -26,6 +26,7 @@ import type {
} from '../../../../shared/runtime-host-status'
import type { WslHostStatus } from '../../../../shared/wsl-host-types'
import { translate } from '@/i18n/i18n'
import { sshConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
import {
getWslHostRowDetail,
getWslHostRowHealth,
@@ -40,6 +41,8 @@ export type SidebarHostOption = {
kind: 'local' | 'ssh' | 'runtime'
health: ExecutionHostHealth
presence: 'local' | 'configured' | 'project' | 'active'
/** Why a failed SSH host failed, as Settings → SSH Hosts says it; host pickers show it. */
failureReason?: string
// Host menus need the compatibility verdict to explain version skew.
compatibility?: RuntimeCompatVerdict
blockedReason?: RuntimeHostBlockedReason
@@ -115,9 +118,13 @@ export function buildSidebarHostOptions(args: {
}
if (host.kind === 'ssh') {
const targetId = decodeURIComponent(host.id.slice('ssh:'.length))
const state = args.sshConnectionStates?.get(targetId)
// Disconnected hosts remain listed when configured or still referenced by projects.
return {
...host,
...(host.health === 'error' && state
? { failureReason: sshConnectFailureText(state, state.error?.trim() || host.detail) }
: {}),
presence: configuredSshTargetIds.has(targetId)
? 'configured'
: projectSshTargetIds.has(targetId)
@@ -52,7 +52,10 @@ vi.mock('react', async (importOriginal) => {
})
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState)
useAppStore: Object.assign(
(selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState),
{ getState: () => mocks.storeState }
)
}))
vi.mock('sonner', () => ({
@@ -106,6 +109,7 @@ describe('useAddRepoHostSelection', () => {
mocks.storeState.setActiveRuntimeEnvironmentPreference.mockResolvedValue(true)
mocks.storeState.sshConnectionStates = new Map()
mocks.storeState.runtimeEnvironments = []
mocks.storeState.setSshConnectionState.mockReset()
mocks.sshConnect.mockReset()
mocks.sshGetState.mockReset()
mocks.isWebClient = false
@@ -274,6 +278,72 @@ describe('useAddRepoHostSelection', () => {
expect(setStep).toHaveBeenCalledWith('add')
})
it('keeps the failure main published for the host, and toasts its reason (#29648)', async () => {
mocks.stateValues = ['local', true]
mocks.hostOptions[1] = { ...mocks.hostOptions[1], health: 'disconnected' }
const published = {
targetId: 'ssh-1',
status: 'auth-failed' as const,
error: 'All configured authentication methods failed',
reconnectAttempt: 0
}
mocks.sshConnect.mockImplementation(async () => {
mocks.storeState.sshConnectionStates = new Map([['ssh-1', published]])
throw new Error(
"Error invoking remote method 'ssh:connect': Error: All configured authentication methods failed"
)
})
const { toast } = await import('sonner')
const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
await useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }).handleConnectAddProjectHost(
'ssh:ssh-1'
)
expect(mocks.storeState.setSshConnectionState).toHaveBeenCalledTimes(1)
expect(toast.error).toHaveBeenCalledWith('All configured authentication methods failed')
})
it('marks the host failed with the error when connect rejects before main publishes', async () => {
mocks.stateValues = ['local', true]
mocks.hostOptions[1] = { ...mocks.hostOptions[1], health: 'disconnected' }
mocks.storeState.sshConnectionStates = new Map([
[
'ssh-1',
{
targetId: 'ssh-1',
status: 'error',
error: 'old',
reconnectAttempt: 0,
managedServer: { kind: 'relay', reason: 'refused', detail: 'an earlier refusal' }
}
]
])
mocks.storeState.setSshConnectionState.mockImplementation((id: string, state: unknown) => {
mocks.storeState.sshConnectionStates = new Map([[id, state]])
})
mocks.sshConnect.mockRejectedValue(
new Error(
"Error invoking remote method 'ssh:connect': Error: connect ECONNREFUSED 10.0.0.1:22"
)
)
const { toast } = await import('sonner')
const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
await useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() }).handleConnectAddProjectHost(
'ssh:ssh-1'
)
expect(mocks.storeState.sshConnectionStates.get('ssh-1')).toEqual({
targetId: 'ssh-1',
status: 'error',
error: 'connect ECONNREFUSED 10.0.0.1:22',
reconnectAttempt: 0,
remotePlatform: undefined
})
expect(toast.error).toHaveBeenCalledWith('connect ECONNREFUSED 10.0.0.1:22')
})
it('does not auto-select the active runtime host while it is unavailable', async () => {
mocks.stateValues = ['local', false]
mocks.hostOptions[2] = {
@@ -17,6 +17,8 @@ import {
pickerExecutionHosts
} from '../../../../shared/managed-orcad-execution-host'
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
import { isWebClientLocation } from '@/lib/web-client-location'
export function useAddRepoHostSelection({
@@ -197,29 +199,23 @@ export function useAddRepoHostSelection({
setStep('add')
setHostSelectorOpen(false)
} catch (err) {
setSshConnectionState(
parsed.targetId,
previousState ?? {
const fallback = translate(
'auto.components.sidebar.useAddRepoHostSelection.connectionFailed',
'SSH connection failed.'
)
// Main publishes the failure and its reason; only replace our own optimistic row.
if (
useAppStore.getState().sshConnectionStates.get(parsed.targetId)?.status === 'connecting'
) {
setSshConnectionState(parsed.targetId, {
targetId: parsed.targetId,
status: 'disconnected',
error:
err instanceof Error
? err.message
: translate(
'auto.components.sidebar.useAddRepoHostSelection.connectionFailed',
'SSH connection failed.'
),
reconnectAttempt: 0
}
)
toast.error(
err instanceof Error
? err.message
: translate(
'auto.components.sidebar.useAddRepoHostSelection.connectionFailed',
'SSH connection failed.'
)
)
status: 'error',
error: extractIpcErrorMessage(err, fallback),
reconnectAttempt: previousState?.reconnectAttempt ?? 0,
remotePlatform: previousState?.remotePlatform
})
}
toastSshConnectFailure(parsed.targetId, err, fallback)
}
},
[selectableHostOptions, setSshConnectionState, setStep, sshConnectionStates]