diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx index 8d89a1016cd..3ce70d005fc 100644 --- a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx @@ -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( + + ) + + expect(html).toContain('Needs attention - All configured authentication methods failed') + }) + it('shows exact update guidance for incompatible runtime hosts', () => { const html = renderToStaticMarkup( {host.label} - {getHostStatusDetail(host)} + + {getHostStatusDetail(host)} + {canConnectHost ? ( diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index 5207fd2c987..45128f8f38b 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -75,6 +75,38 @@ describe('sidebar host options', () => { }) }) + it('says why an SSH host failed, as Settings does (#29648)', () => { + const failed = (state: Pick & Partial) => + 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: [], diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts index aaa371115c9..8c6f4b022ec 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -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) diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts index b5d9f824885..b027af2a710 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts @@ -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] = { diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts index 7d665eb50d6..7c47261a4ca 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts @@ -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]