diff --git a/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts b/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts index efd8b6578a7..9161dace8ed 100644 --- a/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts +++ b/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts @@ -14,6 +14,7 @@ import { uploadRelayDirectory } from './remote-install-transfers' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' import { pinnedNodeRuntimeAsset } from '../../../shared/node-runtime-pin' import { DesktopDownloadUnreachableError } from '../../node-runtime/pinned-runtime-materializer' +import { setNodeDistMirrorSettingReader } from '../../node-runtime/node-dist-mirror-setting' import { HOST_NODE_DOWNLOAD_FAILED, HOST_NODE_DOWNLOAD_OK, @@ -103,9 +104,35 @@ describe('pinned Node when this computer cannot download it', () => { expect(message).toContain(OFFLINE_PATH) expect(message).toContain('Settings > Advanced > Network > Node.js download mirror') expect(message).toContain("NODEJS_ORG_MIRROR in this computer's environment") + expect(failure).toMatchObject({ mirror: 'unset' }) expect(uploadRelayDirectory).not.toHaveBeenCalled() }) + it('records that both attempts went through the configured mirror', async () => { + freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nmirror: curl exit 7`) + setNodeDistMirrorSettingReader(() => 'https://npmmirror.com/mirrors/node') + try { + const failure = await ensure(refused).catch((error: unknown) => error) + expect(failure).toMatchObject({ name: 'NodeRuntimeUnreachableError', mirror: 'tried' }) + expect(String(failure)).toContain('check the mirror of nodejs.org/dist in Settings') + } finally { + setNodeDistMirrorSettingReader(null) + } + }) + + it('offers no mirror for a build nodejs.org/dist does not carry', async () => { + freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nnodejs.org: curl exit 7`) + const failure = await ensureRemoteOrcadNodeRuntime({ + conn, + host: getRemoteHostPlatform('linux-arm64'), + slotDir: '/home/me/.orca-remote/orcad-0.1.0+abc', + target: 'linux-arm64-musl', + archivePath: refused + }).catch((error: unknown) => error) + expect(failure).toMatchObject({ name: 'NodeRuntimeUnreachableError', mirror: 'unsupported' }) + expect(String(failure)).not.toContain('Node.js download mirror') + }) + it("quotes only this computer's short reason, not its standalone advice", async () => { freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nnodejs.org: curl exit 7`) const desktop = () => diff --git a/src/main/managed-server/install/remote-node-runtime-host-download.ts b/src/main/managed-server/install/remote-node-runtime-host-download.ts index 174f0f405e1..dc7adb9a3eb 100644 --- a/src/main/managed-server/install/remote-node-runtime-host-download.ts +++ b/src/main/managed-server/install/remote-node-runtime-host-download.ts @@ -6,7 +6,7 @@ */ import { copyFile, link, mkdtemp } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { nodeDistMirrorUrl } from '../../../shared/node-dist-mirror' +import { nodeDistMirrorUrl, type NodeDistMirrorAttempt } from '../../../shared/node-dist-mirror' import { NODE_RUNTIME_PIN, nodeRuntimeReleaseUrl, @@ -131,7 +131,8 @@ export class NodeRuntimeUnreachableError extends Error { readonly hostReport: string, readonly offlineArchivePath: string | undefined, cause: unknown, - mirrorable = true + /** Whether both attempts tried a mirror; the setup failure reads this, never the text. */ + readonly mirror: NodeDistMirrorAttempt = 'unset' ) { const desktop = cause instanceof DesktopDownloadUnreachableError @@ -139,17 +140,19 @@ export class NodeRuntimeUnreachableError extends Error { : cause instanceof Error ? cause.message : String(cause) - const mirror = mirrorable - ? ', set a mirror of nodejs.org/dist in Settings > Advanced > Network > Node.js download mirror ' + - "(or NODEJS_ORG_MIRROR in this computer's environment)" - : '' + const mirrorAdvice = + mirror === 'unsupported' + ? '' + : `, ${mirror === 'tried' ? 'check the' : 'set a'} mirror of nodejs.org/dist in Settings > ` + + 'Advanced > Network > Node.js download mirror ' + + "(or NODEJS_ORG_MIRROR in this computer's environment)" const offline = offlineArchivePath ? `, or download that file on another machine and copy it to ${offlineArchivePath} on this computer.` : '.' super( `Neither this computer nor the host could download Node from ${url}. ` + `This computer: ${desktop}. The host: ${hostReport}. ` + - `Check the network or proxy on either machine and connect again${mirror}${offline}`, + `Check the network or proxy on either machine and connect again${mirrorAdvice}${offline}`, { cause } ) this.name = 'NodeRuntimeUnreachableError' @@ -170,11 +173,12 @@ export async function downloadNodeRuntimeOnHost(args: { timeoutMs: number signal?: AbortSignal }): Promise { + const mirror = configuredNodeDistMirror() const output = await args.exec( hostDownloadNodeRuntimeCommand(args.host, { stageDir: args.stageDir, target: args.target, - mirror: configuredNodeDistMirror() + mirror }), { signal: args.signal, timeoutMs: args.timeoutMs } ) @@ -185,7 +189,11 @@ export async function downloadNodeRuntimeOnHost(args: { result.report, args.offlineArchivePath, args.desktopError, - pinnedNodeRuntimeAsset(args.target).source === 'official' + pinnedNodeRuntimeAsset(args.target).source !== 'official' + ? 'unsupported' + : mirror + ? 'tried' + : 'unset' ) } console.warn( diff --git a/src/main/ssh/ssh-host-server-on-connect-node-download.test.ts b/src/main/ssh/ssh-host-server-on-connect-node-download.test.ts new file mode 100644 index 00000000000..e6577ca6380 --- /dev/null +++ b/src/main/ssh/ssh-host-server-on-connect-node-download.test.ts @@ -0,0 +1,104 @@ +/** + * A setup that couldn't download Node on either machine carries a structured flag the renderer + * reads to offer a mirror; nothing parses the message text. + */ +import { describe, expect, it, vi } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' +import { resolveHostServerOnConnect } from './ssh-host-server-on-connect' +import { hostServerDepsStub } from './ssh-host-server-on-connect-test-deps' +import { unservedHostServerMessage, unservedServerStatus } from './ssh-host-unserved-status' +import { sftpSessionClosedError } from './ssh-file-transfer-abort' +import { NodeRuntimeUnreachableError } from '../managed-server/install/remote-node-runtime-host-download' +import { admitSshManagedServerStatus } from '../../shared/ssh-managed-server-status-admission' + +const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } + +function unreachable(mirror?: 'unset' | 'tried' | 'unsupported'): NodeRuntimeUnreachableError { + return new NodeRuntimeUnreachableError( + 'https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz', + 'its downloads failed (curl: (7) Failed to connect)', + '/cache/node/archives/node.tar.gz', + new Error('net::ERR_CONNECTION_REFUSED'), + mirror + ) +} + +async function deployFailingWith(error: unknown) { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const result = await resolveHostServerOnConnect( + target, + hostServerDepsStub({ + isEmptyHost: () => true, + deploy: async () => { + throw error + } + }) + ) + if (result.route !== 'unserved') { + throw new Error('expected unserved') + } + return result + } finally { + warn.mockRestore() + } +} + +describe('a managed-server setup that could not download Node', () => { + it.each(['unset', 'tried', 'unsupported'] as const)( + 'publishes the blocked download with mirror %s', + async (mirror) => { + const result = await deployFailingWith(unreachable(mirror)) + expect(result).toMatchObject({ + reason: 'failed', + nodeDownloadBlocked: { mirror } + }) + expect(result.detail).toContain('Neither this computer nor the host could download Node') + expect(admitSshManagedServerStatus(unservedServerStatus(result))).toEqual({ + managedServer: expect.objectContaining({ + kind: 'relay', + reason: 'failed', + nodeDownloadBlocked: { mirror } + }) + }) + const message = unservedHostServerMessage(result) + expect(message).toContain('Neither this computer nor this host could download Node.js') + if (mirror === 'unsupported') { + expect(message).not.toContain('mirror') + } else { + expect(message).toContain(mirror === 'tried' ? 'Check the mirror' : 'Set a Node.js') + } + } + ) + + it('reads the flag through a wrapping install error', async () => { + const wrapped = new Error('Installing the runtime failed', { cause: unreachable('unset') }) + await expect(deployFailingWith(wrapped)).resolves.toMatchObject({ + nodeDownloadBlocked: { mirror: 'unset' } + }) + }) + + it('never reads the flag from message text', async () => { + const lookalike = new Error(unreachable('unset').message) + const result = await deployFailingWith(lookalike) + expect(result).not.toHaveProperty('nodeDownloadBlocked') + expect(unservedHostServerMessage(result)).toContain('Try connecting again') + }) + + it('lets lost contact win, since nothing proves the host failed', async () => { + const lost = new Error('upload failed', { cause: sftpSessionClosedError() }) + const result = await deployFailingWith(lost) + expect(result).toMatchObject({ contactLost: true }) + expect(result).not.toHaveProperty('nodeDownloadBlocked') + }) + + it('drops an unknown mirror state from a newer build at admission', () => { + expect( + admitSshManagedServerStatus({ + kind: 'relay', + reason: 'failed', + nodeDownloadBlocked: { mirror: 'regional' } + }) + ).toEqual({ managedServer: { kind: 'relay', reason: 'failed' } }) + }) +}) diff --git a/src/main/ssh/ssh-host-server-on-connect.ts b/src/main/ssh/ssh-host-server-on-connect.ts index a52c957ad46..66720400122 100644 --- a/src/main/ssh/ssh-host-server-on-connect.ts +++ b/src/main/ssh/ssh-host-server-on-connect.ts @@ -16,6 +16,7 @@ import type { import type { SshManagedServerRelayReason, SshManagedServerLinkedProject, + SshManagedServerNodeDownloadBlocked, SshManagedServerServingNote, SshManagedServerUpdateNote, SshTarget @@ -68,6 +69,7 @@ export type HostServerOnConnectResult = held?: true /** Setup lost contact with the host, so it is not known to have failed there. */ contactLost?: true + nodeDownloadBlocked?: SshManagedServerNodeDownloadBlocked /** COMPAT(legacy-relay-bridge): the connect stays up so the bridge serves the terminals. */ bridged?: true } @@ -344,6 +346,7 @@ async function unfinished( reason, detail: boundedDetail(failure.detail), ...(failure.contactLost ? { contactLost: true as const } : {}), + ...(failure.nodeDownloadBlocked ? { nodeDownloadBlocked: failure.nodeDownloadBlocked } : {}), ...(held ? { held: true as const } : {}) } } diff --git a/src/main/ssh/ssh-host-server-setup-failure.ts b/src/main/ssh/ssh-host-server-setup-failure.ts index ee0f1ec3fef..b4fb697e326 100644 --- a/src/main/ssh/ssh-host-server-setup-failure.ts +++ b/src/main/ssh/ssh-host-server-setup-failure.ts @@ -1,4 +1,6 @@ /** What a managed-server setup that stopped short shows under its SSH host. */ +import type { SshManagedServerNodeDownloadBlocked } from '../../shared/ssh-types' +import { isNodeDistMirrorAttempt } from '../../shared/node-dist-mirror' import { isSftpSessionClosedError } from './ssh-file-transfer-abort' // Bounds a failure's detail; the host log tail inside it is already capped. @@ -8,12 +10,14 @@ export type SetupFailure = { detail: string /** Contact was lost mid-setup: nothing proves the host failed, so it is never called a failure. */ contactLost?: true + nodeDownloadBlocked?: SshManagedServerNodeDownloadBlocked } export function setupFailure(failure: unknown): SetupFailure { return { detail: failure instanceof Error ? failure.message : String(failure), - ...(lostContact(failure) ? { contactLost: true as const } : {}) + ...(lostContact(failure) ? { contactLost: true as const } : {}), + ...nodeDownloadBlocked(failure) } } @@ -21,6 +25,18 @@ export function boundedDetail(text: string): string { return text.length > MAX_FAILURE_DETAIL_CHARS ? `…${text.slice(-MAX_FAILURE_DETAIL_CHARS)}` : text } +// Why by name (as the host-unavailable classifier does) and the chain: no download code here. +function nodeDownloadBlocked( + failure: unknown, + depth = 0 +): { nodeDownloadBlocked?: SshManagedServerNodeDownloadBlocked } { + if (failure instanceof Error && failure.name === 'NodeRuntimeUnreachableError') { + const mirror = 'mirror' in failure ? failure.mirror : undefined + return { nodeDownloadBlocked: { mirror: isNodeDistMirrorAttempt(mirror) ? mirror : 'tried' } } + } + return depth < 4 && failure instanceof Error ? nodeDownloadBlocked(failure.cause, depth + 1) : {} +} + // Why only a closed session or an unanswered channel open: other unconfirmed results (a host's // busy or deadline answer) came from a host that answered. Why the cause chain: install steps wrap // the transport error they hit. diff --git a/src/main/ssh/ssh-host-unserved-status.ts b/src/main/ssh/ssh-host-unserved-status.ts index 9f68d844f35..dc4cdb60f80 100644 --- a/src/main/ssh/ssh-host-unserved-status.ts +++ b/src/main/ssh/ssh-host-unserved-status.ts @@ -1,4 +1,5 @@ /** What a connect publishes when the managed server doesn't serve the host; there is no fallback. */ +import type { NodeDistMirrorAttempt } from '../../shared/node-dist-mirror' import type { SshManagedServerStatus } from '../../shared/ssh-types' import type { HostServerOnConnectResult } from './ssh-host-server-on-connect' @@ -17,6 +18,7 @@ export function unservedServerStatus( ...(decision.canLeaveBehind ? { canLeaveBehind: true } : {}), ...(decision.held ? { held: true } : {}), ...(decision.contactLost ? { contactLost: true } : {}), + ...(decision.nodeDownloadBlocked ? { nodeDownloadBlocked: decision.nodeDownloadBlocked } : {}), ...(decision.bridged ? { bridged: true } : {}) } } @@ -30,6 +32,15 @@ const NOEXEC_MESSAGE = const CONTACT_LOST_MESSAGE = 'Orca lost contact with this host while setting up its server. Nothing on the host was stopped. Connect again to continue.' +const NODE_DOWNLOAD_BLOCKED_MESSAGES: Record = { + unset: + 'Neither this computer nor this host could download Node.js, which Orca’s server needs. Set a Node.js download mirror in Settings > Advanced > Network, or check the network on both machines, then connect again.', + tried: + 'Neither this computer nor this host could download Node.js, including from the Node.js download mirror. Check the mirror in Settings > Advanced > Network, then connect again.', + unsupported: + 'Neither this computer nor this host could download Node.js, which Orca’s server needs. Check the network or proxy on both machines, then connect again.' +} + /** Untranslated twin of the renderer's status line, for logs and callers without the catalog. */ export function unservedHostServerMessage(decision: UnservedDecision): string { if (decision.held && (decision.reason === 'failed' || decision.reason === 'deferred')) { @@ -49,8 +60,11 @@ export function unservedHostServerMessage(decision: UnservedDecision): string { return 'An older version of Orca changed this host’s projects. Move those changes to its managed server in SSH Hosts settings to continue.' case 'deferred': case 'failed': - return decision.contactLost - ? CONTACT_LOST_MESSAGE + if (decision.contactLost) { + return CONTACT_LOST_MESSAGE + } + return decision.nodeDownloadBlocked + ? NODE_DOWNLOAD_BLOCKED_MESSAGES[decision.nodeDownloadBlocked.mirror] : 'Orca couldn’t start its server on this host. Try connecting again.' } } diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 71956415d56..983c571f4aa 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -44,6 +44,7 @@ const AddProjectFromFolderDialog = lazy( ) const DiagramViewerModal = lazy(() => import('../components/editor/DiagramViewerModal')) const ProjectAddedDialog = lazy(() => import('../components/sidebar/ProjectAddedDialog')) +const NodeDistMirrorDialog = lazy(() => import('../components/settings/NodeDistMirrorDialog')) const AiVaultSessionHistoryDialog = lazy( () => import('../components/right-sidebar/AiVaultSessionHistoryDialog') ) @@ -230,6 +231,12 @@ export function AppRootSurfaces(props: { ) : null} + {/* Root-hosted: a toast, the SSH host row and a terminal pane all open it. */} + {activeModal === 'node-dist-mirror' ? ( + + + + ) : null} {/* Why: chat rows are virtualized, so the viewer must outlive the message that opened it. */} {activeModal === 'diagram-viewer' ? ( diff --git a/src/renderer/src/components/settings/NodeDistMirrorDialog.test.tsx b/src/renderer/src/components/settings/NodeDistMirrorDialog.test.tsx new file mode 100644 index 00000000000..5c92a059a0c --- /dev/null +++ b/src/renderer/src/components/settings/NodeDistMirrorDialog.test.tsx @@ -0,0 +1,197 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { SshManagedServerStatus } from '../../../../shared/ssh-types' +import { useAppStore } from '@/store' +import { resetSshConnectInFlightForTests } from '@/ssh/ssh-connect-in-flight' + +const toastError = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +import NodeDistMirrorDialog from './NodeDistMirrorDialog' +import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction' +import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast' + +const blocked: SshManagedServerStatus = { + kind: 'relay', + reason: 'failed', + detail: 'Neither this computer nor the host could download Node.', + nodeDownloadBlocked: { mirror: 'unset' } +} + +const settingsSet = vi.fn(async (updates: Partial) => ({ + ...useAppStore.getState().settings, + ...updates +})) +const connect = vi.fn(async () => ({ targetId: 'ssh-1', status: 'connected' })) + +function settingsWithMirror(nodeDistMirrorUrl: string): GlobalSettings { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these components read only nodeDistMirrorUrl. + return { nodeDistMirrorUrl } as GlobalSettings +} + +function Host({ status }: { status: SshManagedServerStatus }) { + const open = useAppStore((s) => s.activeModal === 'node-dist-mirror') + return ( + <> + + {open ? : null} + + ) +} + +beforeEach(() => { + settingsSet.mockClear() + connect.mockClear() + toastError.mockClear() + Object.assign(window, { + api: { ...window.api, settings: { set: settingsSet }, ssh: { connect } } + }) + useAppStore.setState({ settings: settingsWithMirror('') }) +}) + +afterEach(() => { + cleanup() + act(() => useAppStore.getState().closeModal()) + resetSshConnectInFlightForTests() +}) + +function openDialog(): HTMLInputElement { + fireEvent.click(screen.getByRole('button', { name: 'Use a Node.js mirror…' })) + return screen.getByLabelText('Mirror URL') +} + +describe('Use a Node.js mirror', () => { + it('opens from the host row with the suggested mirror, focused and editable', () => { + render() + const input = openDialog() + + expect(screen.getByRole('dialog', { name: 'Use a Node.js mirror' })).toBeInTheDocument() + expect(input).toHaveValue('https://npmmirror.com/mirrors/node/') + expect(input).toHaveFocus() + expect(screen.getByText(/checked against the checksum Orca pins/)).toBeInTheDocument() + }) + + it('saves to the mirror setting, then retries the connect and closes', async () => { + render() + openDialog() + + fireEvent.click(screen.getByRole('button', { name: 'Save and retry' })) + + await waitFor(() => expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-1' })) + expect(settingsSet).toHaveBeenCalledWith({ + nodeDistMirrorUrl: 'https://npmmirror.com/mirrors/node' + }) + expect(settingsSet.mock.invocationCallOrder[0]).toBeLessThan( + connect.mock.invocationCallOrder[0] + ) + expect(useAppStore.getState().settings?.nodeDistMirrorUrl).toBe( + 'https://npmmirror.com/mirrors/node' + ) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + it('submits with Enter and refuses a URL the setting would refuse', async () => { + render() + const input = openDialog() + + fireEvent.change(input, { target: { value: 'https://me:secret@mirror.example/node' } }) + fireEvent.submit(input.closest('form')!) + + expect( + await screen.findByText('Mirror URL cannot contain a user name or password.') + ).toBeInTheDocument() + expect(input).toHaveAttribute('aria-invalid', 'true') + expect(settingsSet).not.toHaveBeenCalled() + expect(connect).not.toHaveBeenCalled() + + fireEvent.change(input, { target: { value: 'https://mirror.example/node/' } }) + expect(input).not.toHaveAttribute('aria-invalid') + fireEvent.submit(input.closest('form')!) + await waitFor(() => + expect(settingsSet).toHaveBeenCalledWith({ nodeDistMirrorUrl: 'https://mirror.example/node' }) + ) + }) + + it('cancels without saving, also with Escape', async () => { + render() + openDialog() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + const input = openDialog() + fireEvent.keyDown(input, { key: 'Escape' }) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(settingsSet).not.toHaveBeenCalled() + expect(connect).not.toHaveBeenCalled() + }) + + it('shows no offer once a mirror was tried, only the setting link', () => { + const tried: SshManagedServerStatus = { ...blocked, nodeDownloadBlocked: { mirror: 'tried' } } + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'error', + error: 'x', + reconnectAttempt: 0, + managedServer: tried + }) + render() + expect(screen.queryByRole('button', { name: 'Use a Node.js mirror…' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Open mirror setting' })) + const state = useAppStore.getState() + expect(state.activeView).toBe('settings') + expect(state.settingsNavigationTarget).toMatchObject({ pane: 'advanced' }) + expect(state.settingsSearchInputQuery).toBe('mirror') + expect(state.activeModal).toBe('none') + }) + + it('offers nothing in a paired browser, whose settings are not this computer’s', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + try { + render() + expect(screen.queryByRole('button')).toBeNull() + } finally { + vi.unstubAllGlobals() + } + }) + + it('opens the setting instead when a mirror was set after the toast appeared', () => { + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'error', + error: 'x', + reconnectAttempt: 0, + managedServer: blocked + }) + toastSshConnectFailure('ssh-1', new Error('x'), 'Connection failed') + const [, options] = toastError.mock.calls[0] + useAppStore.setState({ + settings: settingsWithMirror('https://m.example/node') + }) + act(() => options.action.onClick()) + expect(useAppStore.getState().activeModal).toBe('none') + expect(useAppStore.getState().activeView).toBe('settings') + }) + + it('puts the same recovery on the connect-failure toast for this computer’s targets', () => { + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'error', + error: 'x', + reconnectAttempt: 0, + managedServer: blocked + }) + toastSshConnectFailure('ssh-1', new Error('x'), 'Connection failed') + const [text, options] = toastError.mock.calls[0] + expect(text).toContain('Use a Node.js mirror') + act(() => options.action.onClick()) + expect(useAppStore.getState().activeModal).toBe('node-dist-mirror') + expect(useAppStore.getState().modalData).toEqual({ targetId: 'ssh-1' }) + + toastError.mockClear() + toastSshConnectFailure('ssh-1', new Error('x'), 'Connection failed', 'server-env') + expect(toastError.mock.calls[0]).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/settings/NodeDistMirrorDialog.tsx b/src/renderer/src/components/settings/NodeDistMirrorDialog.tsx new file mode 100644 index 00000000000..3d517add437 --- /dev/null +++ b/src/renderer/src/components/settings/NodeDistMirrorDialog.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react' +import { useAppStore } from '@/store' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { nodeDistMirrorRefusalText } from '@/ssh/ssh-host-node-download-copy' +import { + NODE_DIST_MIRROR_SUGGESTION, + readNodeDistMirrorDialogTarget, + saveNodeDistMirrorAndRetry +} from '@/ssh/node-dist-mirror-recovery' +import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' +import { Input } from '../ui/input' +import { Label } from '../ui/label' + +const INPUT_ID = 'node-dist-mirror-dialog-url' +const HINT_ID = 'node-dist-mirror-dialog-hint' + +/** "Use a Node.js mirror" for an SSH host whose setup couldn't download Node on either machine. */ +export default function NodeDistMirrorDialog(): React.JSX.Element | null { + const targetId = useAppStore((s) => readNodeDistMirrorDialogTarget(s.modalData)) + const closeModal = useAppStore((s) => s.closeModal) + const mountedRef = useMountedRef() + const [text, setText] = useState(NODE_DIST_MIRROR_SUGGESTION) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + // Why: the modal slot has no trigger element for the dialog to hand focus back to. + const [returnFocusTo] = useState(() => + document.activeElement instanceof HTMLElement ? document.activeElement : null + ) + if (!targetId) { + return null + } + + const saveAndRetry = async (): Promise => { + setBusy(true) + setError(null) + const result = await saveNodeDistMirrorAndRetry(targetId, text) + if (result.outcome === 'retrying') { + // Why before the mounted check: another modal may have replaced this one mid-save. + result.retry.catch((cause: unknown) => + toastSshConnectFailure( + targetId, + cause, + translate( + 'auto.components.settings.nodeDistMirrorDialog.retryFailed', + 'Connection failed' + ) + ) + ) + if (mountedRef.current) { + closeModal() + } + return + } + if (!mountedRef.current) { + return + } + setBusy(false) + setError( + result.outcome === 'refused' ? nodeDistMirrorRefusalText(result.reason) : result.message + ) + } + + return ( + !open && !busy && closeModal()}> + { + if (returnFocusTo?.isConnected) { + event.preventDefault() + returnFocusTo.focus({ preventScroll: true }) + } + }} + > +
{ + event.preventDefault() + if (!busy) { + void saveAndRetry() + } + }} + > + + + {translate( + 'auto.components.settings.nodeDistMirrorDialog.title', + 'Use a Node.js mirror' + )} + + + {translate( + 'auto.components.settings.nodeDistMirrorDialog.description', + 'Orca’s server needs Node.js, and neither this computer nor this host could download it from nodejs.org. A mirror is a copy of nodejs.org/dist that Orca tries first on both machines.' + )} + + +
+ + { + setText(event.target.value) + setError(null) + }} + autoFocus + autoCapitalize="none" + autoCorrect="off" + autoComplete="off" + spellCheck={false} + disabled={busy} + aria-invalid={error ? true : undefined} + aria-describedby={HINT_ID} + /> + {error ? ( + + ) : ( +

+ {translate( + 'auto.components.settings.nodeDistMirrorDialog.checksum', + 'Each download is still checked against the checksum Orca pins for this Node.js release. Saved to Settings > Advanced > Network.' + )} +

+ )} +
+ + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/NodeDistMirrorSettingField.tsx b/src/renderer/src/components/settings/NodeDistMirrorSettingField.tsx index 264ca78285e..80e8128dc72 100644 --- a/src/renderer/src/components/settings/NodeDistMirrorSettingField.tsx +++ b/src/renderer/src/components/settings/NodeDistMirrorSettingField.tsx @@ -4,6 +4,7 @@ import { normalizeNodeDistMirrorUrl } from '../../../../shared/node-dist-mirror' import { Input } from '../ui/input' import { Label } from '../ui/label' import { translate } from '@/i18n/i18n' +import { nodeDistMirrorRefusalText } from '@/ssh/ssh-host-node-download-copy' type MirrorDraft = { source: string; text: string; error: string | null } @@ -29,7 +30,7 @@ export function NodeDistMirrorSettingField({ const commit = (): void => { const normalized = normalizeNodeDistMirrorUrl(current.text) if (!normalized.ok) { - setDraft({ ...current, error: normalized.message }) + setDraft({ ...current, error: nodeDistMirrorRefusalText(normalized.reason) }) return } setDraft({ source, text: normalized.value, error: null }) diff --git a/src/renderer/src/components/settings/SshHostNodeMirrorAction.tsx b/src/renderer/src/components/settings/SshHostNodeMirrorAction.tsx new file mode 100644 index 00000000000..2a7d6c40878 --- /dev/null +++ b/src/renderer/src/components/settings/SshHostNodeMirrorAction.tsx @@ -0,0 +1,35 @@ +import type { SshManagedServerStatus } from '../../../../shared/ssh-types' +import { useAppStore } from '@/store' +import { nodeMirrorRecoveryLabel } from '@/ssh/ssh-host-node-download-copy' +import { + openNodeMirrorRecovery, + thisComputerNodeMirrorRecovery +} from '@/ssh/node-dist-mirror-recovery' +import { Button } from '../ui/button' + +/** The way out of a setup that couldn't download Node.js: set a mirror, or check the one set. */ +export function SshHostNodeMirrorAction({ + targetId, + status, + ownerEnvironmentId = null +}: { + targetId: string + status: SshManagedServerStatus | undefined + ownerEnvironmentId?: string | null +}): React.JSX.Element | null { + const configuredMirror = useAppStore((s) => s.settings?.nodeDistMirrorUrl) + const recovery = thisComputerNodeMirrorRecovery(status, ownerEnvironmentId, configuredMirror) + if (!recovery) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index faf99ed8c09..0f712bff434 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { Plus, Upload } from 'lucide-react' import type { SshTarget } from '../../../../shared/ssh-types' import { useAppStore } from '@/store' -import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy' +import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast' import { useMountedRef } from '@/hooks/useMountedRef' import { Button } from '../ui/button' import { SshTargetCard } from './SshTargetCard' @@ -201,12 +201,10 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem await window.api.ssh.connect({ targetId }) recordFeatureInteraction('ssh') } catch (err) { - toast.error( - sshTargetConnectFailureText( - targetId, - err, - translate('auto.components.settings.SshPane.e95d5ae10e', 'Connection failed') - ) + toastSshConnectFailure( + targetId, + err, + translate('auto.components.settings.SshPane.e95d5ae10e', 'Connection failed') ) } } diff --git a/src/renderer/src/components/settings/SshTargetServerStatus.tsx b/src/renderer/src/components/settings/SshTargetServerStatus.tsx index 35eb64b37d3..24f48c9a04a 100644 --- a/src/renderer/src/components/settings/SshTargetServerStatus.tsx +++ b/src/renderer/src/components/settings/SshTargetServerStatus.tsx @@ -8,6 +8,7 @@ import { Button } from '../ui/button' import { sshHostServerStatusLine } from '@/ssh/ssh-host-server-status-copy' import { SshHostChangedActions } from './SshHostChangedActions' import { SshHostMoveAnywayAction } from './SshHostMoveAnywayAction' +import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction' import { SshHostRestartServerAction } from './SshHostRestartServerAction' /** Whether this SSH host's managed Orca server serves it, and why not when it doesn't. */ @@ -23,6 +24,9 @@ export function SshTargetServerStatus({ if (!line) { return null } + const mirrorAction = ( + + ) return (

{line.detail ? ( - - - + {/* Why one row: the recovery sits beside Details, so it reads as part of this failure. */} +

+ + + + {mirrorAction} +
               {line.detail}
             
- ) : null} + ) : ( +
{mirrorAction}
+ )} store.setSshConnectionState) + const managedServer = useAppStore( + (store) => store.sshConnectionStates.get(targetId)?.managedServer + ) // Why: shared registry, not local state — the sidebar card control can dial the same // target, and the store status lags a click by one IPC hop. const connecting = useSshConnectInFlight(targetId) @@ -122,15 +125,14 @@ export function TerminalSshReconnectOverlay({ } } } catch (err) { - toast.error( - sshTargetConnectFailureText( - targetId, - err, - translate( - 'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectFailed', - 'SSH connection failed' - ) - ) + toastSshConnectFailure( + targetId, + err, + translate( + 'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectFailed', + 'SSH connection failed' + ), + sshOwnerEnvironmentId ) // Why: a failed connect usually means the renderer's target metadata is // stale (target removed, or re-added under a new id). Resync it so the @@ -201,6 +203,15 @@ export function TerminalSshReconnectOverlay({ {error}
) : null} + {!targetRemoved ? ( +
+ +
+ ) : null} {targetRemoved ? (