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