From b8870e7a9981db6fad0d60dae310be5729fd2d33 Mon Sep 17 00:00:00 2001 From: m4air Date: Sun, 4 Oct 2026 02:55:54 -0700 Subject: [PATCH 1/3] fix(phase3): close e2e routing holes, localize managed-server outcomes, trim harness and CI - Route the auto-convert spec into needs_build and route the new orcad/serve e2e helpers to the specs that use them, with routing tests. - Run the auto-convert Docker lane only when routed; fold the missing-AppData check into the Windows mode-switch job and drop crash-hunt diagnostic env. - Delta-move dialog keys its preview on the target id and cannot close or resubmit mid-move; Resume has an in-flight guard. - Managed-server toasts and status lines show localized messages instead of raw codes or main-process English; add singular and zero-count wording. - Settings style fixes (quiet Cancel, section header, labelled fields, progress labels); delete dead i18n keys and the unused previewConversion. - Harness: kill the serve child on readiness timeout, share the isolated profile and spawn-until-ready helpers, hooks and a condition wait in the auto-convert spec, shared cross-version exec helper. Co-authored-by: m4air --- .github/workflows/e2e.yml | 59 +-- config/scripts/ci-e2e-job-selection.mjs | 3 +- config/scripts/orcad-e2e-routing.test.mjs | 84 ++++ config/scripts/pr-e2e-source-routing.mjs | 23 +- .../release-cut-token-permissions.test.mjs | 1 - .../orcad-runtime-conversion-handlers.test.ts | 61 +-- .../ipc/orcad-runtime-conversion-handlers.ts | 49 --- .../runtime-environment-handler-channels.ts | 1 - .../ipc/runtime-environments-pairing.test.ts | 2 - src/preload/api/managed-orcad-api.ts | 4 - .../settings/ManagedServerRow.test.tsx | 4 +- .../components/settings/ManagedServerRow.tsx | 23 +- .../settings/ManagedServersSection.tsx | 63 ++- .../settings/RuntimeSshAccessControl.tsx | 16 +- .../settings/SshHostChangedActions.tsx | 16 +- .../settings/SshHostDeltaMoveDialog.test.tsx | 29 +- .../settings/SshHostDeltaMoveDialog.tsx | 24 +- .../components/settings/SshTargetSelect.tsx | 4 +- .../settings/managed-server-copy.ts | 64 +++ .../ssh-host-server-status-copy.test.ts | 22 +- .../settings/ssh-host-server-status-copy.ts | 165 +++++--- .../src/i18n/en-runtime-required.json | 47 +-- src/renderer/src/i18n/locales/en.json | 76 ++-- .../src/ssh/ssh-managed-server-move.ts | 17 +- src/shared/orcad-managed-runtime.ts | 17 - ...ous-relay-live-terminal-probe.unit.test.ts | 25 +- ...revious-relay-terminal-bridge.unit.test.ts | 11 +- .../released-relay-daemon.ts | 13 +- tests/e2e/helpers/orca-serve-cli-host.ts | 131 +++++-- tests/e2e/ssh-orcad-auto-convert.spec.ts | 366 +++++++++--------- .../windows-missing-appdata-startup.spec.ts | 28 +- 31 files changed, 786 insertions(+), 662 deletions(-) create mode 100644 config/scripts/orcad-e2e-routing.test.mjs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0bf5b83812e6..989f9d69323a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -557,51 +557,13 @@ jobs: retention-days: 7 if-no-files-found: ignore - # A profile-less Windows session has no roaming AppData; Electron 43 used to crash natively there. - windows-missing-appdata-startup: - name: orca serve starts without AppData on Windows - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/windows-missing-appdata-startup.spec.ts') - runs-on: windows-2022 - timeout-minutes: 30 - env: - NODE_OPTIONS: --max-old-space-size=4096 - ORCA_BACKGROUND_LAUNCH: '1' - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - name: Build the e2e app and CLI - shell: bash - run: | - pnpm exec electron-vite build --mode e2e - pnpm run build:cli - - name: Start serve with no AppData folder - env: - SKIP_BUILD: '1' - ORCA_E2E_FORWARD_APP_LOGS: '1' - ORCA_STARTUP_DIAGNOSTICS: '1' - ELECTRON_ENABLE_LOGGING: '1' - ELECTRON_ENABLE_STACK_DUMPING: '1' - run: pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/windows-missing-appdata-startup.spec.ts --project=electron-headless --workers=1 - - uses: actions/upload-artifact@v7 - if: failure() - with: - name: windows-missing-appdata-startup-traces - path: test-results/ - retention-days: 7 - if-no-files-found: ignore - # #24979 on a real host: a relay-era Docker host converts to managed orcad on connect, and a managed # orcad idles out and restarts on the next connect. Needs the # orcad template for the fixture's target (Debian, linux-x64-glibc), which only this job builds. orcad-auto-convert-docker: name: ssh host auto-converts to managed orcad (Docker) needs: [build, prepare-native-cache] - if: inputs.test_files == '' || inputs.ssh_source_changed == 'true' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -647,10 +609,11 @@ jobs: if-no-files-found: ignore # D7 on Windows: both hosts share \daemon and so one daemon pipe. Built here rather than - # from the Linux e2e build because the slot, template and addons are Windows-native. + # from the Linux e2e build because the slot, template and addons are Windows-native. Also runs the + # missing-AppData startup check, which needs the same Windows e2e build. orcad-serve-mode-switch-windows: - name: orca serve Electron/orcad mode switch on Windows (D7) - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') + name: orca serve on Windows (D7 mode switch, missing AppData) + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') || contains(inputs.test_files, 'tests/e2e/windows-missing-appdata-startup.spec.ts') runs-on: windows-2022 timeout-minutes: 45 env: @@ -677,16 +640,20 @@ jobs: pnpm build:orcad node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 node config/scripts/build-orcad-template.mjs --targets win32-x64 + # A profile-less Windows session has no roaming AppData; Electron 43 used to crash natively there. + - name: Start serve with no AppData folder + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/windows-missing-appdata-startup.spec.ts') + env: + SKIP_BUILD: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + run: pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/windows-missing-appdata-startup.spec.ts --project=electron-headless --workers=1 - name: Switch serve hosts on one profile + if: ${{ !cancelled() && (inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts')) }} env: SKIP_BUILD: '1' ORCA_E2E_ORCAD_SERVE: '1' ORCA_E2E_FORWARD_APP_LOGS: '1' ORCA_E2E_PRESERVE_PROFILE_LOGS_DIR: ${{ github.workspace }}\test-results\profile-logs - # A launch that dies before its window prints this trail into Playwright's call log. - ORCA_STARTUP_DIAGNOSTICS: '1' - ELECTRON_ENABLE_LOGGING: '1' - ELECTRON_ENABLE_STACK_DUMPING: '1' run: pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/orcad-serve-mode-switch.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 if: failure() diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 62050b9dbcbc..9afa78ce0077 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -92,7 +92,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { (spec) => dockerSpecs.has(spec) || spec === LOCALHOST_SSH_E2E_SPEC || - spec === ORCAD_SERVE_MODE_SWITCH_E2E_SPEC + spec === ORCAD_SERVE_MODE_SWITCH_E2E_SPEC || + spec === ORCAD_AUTO_CONVERT_E2E_SPEC ) } } diff --git a/config/scripts/orcad-e2e-routing.test.mjs b/config/scripts/orcad-e2e-routing.test.mjs new file mode 100644 index 000000000000..76e9f69e8cd8 --- /dev/null +++ b/config/scripts/orcad-e2e-routing.test.mjs @@ -0,0 +1,84 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { expect, it } from 'vitest' +import { parse } from 'yaml' +import { + classifyE2eJobs, + ORCAD_AUTO_CONVERT_E2E_SPEC, + ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, + WINDOWS_MISSING_APPDATA_E2E_SPEC +} from './ci-e2e-job-selection.mjs' +import { selectPrE2eSpecs, shouldRunReusablePrE2e } from './pr-e2e-source-routing.mjs' + +const root = resolve(import.meta.dirname, '../..') +const jobs = parse(readFileSync(resolve(root, '.github/workflows/e2e.yml'), 'utf8')).jobs + +function expectRouted(files, spec) { + for (const file of files) { + expect(existsSync(resolve(root, file)), file).toBe(true) + expect(selectPrE2eSpecs([file]), file).toContain(spec) + expect(shouldRunReusablePrE2e([file]), file).toBe(true) + } +} + +it('routes the mode-switch spec from its serve sources and harness', () => { + expectRouted( + [ + 'src/main/orcad/orcad-lifecycle.ts', + 'src/main/daemon/daemon-spawner.ts', + 'tests/e2e/helpers/orca-serve-cli-host.ts', + 'tests/e2e/helpers/headless-paired-runtime-host.ts' + ], + ORCAD_SERVE_MODE_SWITCH_E2E_SPEC + ) +}) + +it('routes the missing-AppData spec from its startup sources and harness', () => { + expectRouted( + ['src/main/startup/windows-app-data-path.ts', 'tests/e2e/helpers/orca-serve-cli-host.ts'], + WINDOWS_MISSING_APPDATA_E2E_SPEC + ) +}) + +it('routes the auto-convert spec from its conversion sources and harness', () => { + expectRouted( + [ + 'src/main/ssh/orcad-runtime-conversion.ts', + 'tests/e2e/helpers/orcad-convert-flow.ts', + 'tests/e2e/helpers/orcad-convert-host.ts', + 'tests/e2e/helpers/orcad-template-variant.ts', + 'tests/e2e/helpers/orcad-upgrade-profile.ts' + ], + ORCAD_AUTO_CONVERT_E2E_SPEC + ) + expect(selectPrE2eSpecs(['src/main/ssh/orcad-runtime-conversion.test.ts'])).not.toContain( + ORCAD_AUTO_CONVERT_E2E_SPEC + ) +}) + +it('builds the e2e app when only a build-dependent orcad spec is requested', () => { + for (const spec of [ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, ORCAD_AUTO_CONVERT_E2E_SPEC]) { + expect(classifyE2eJobs(JSON.stringify([spec])), spec).toEqual({ + e2e_run_changed: false, + e2e_needs_build: true + }) + } + expect(jobs['orcad-auto-convert-docker'].needs).toEqual(['build', 'prepare-native-cache']) +}) + +it('runs the auto-convert lane only when routed, not on every SSH source change', () => { + const condition = jobs['orcad-auto-convert-docker'].if + expect(condition).toContain(`contains(inputs.test_files, '${ORCAD_AUTO_CONVERT_E2E_SPEC}')`) + expect(condition).not.toContain('ssh_source_changed') +}) + +it('runs both Windows serve specs on one runner, each only when requested', () => { + expect(jobs['windows-missing-appdata-startup']).toBeUndefined() + const job = jobs['orcad-serve-mode-switch-windows'] + for (const spec of [ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, WINDOWS_MISSING_APPDATA_E2E_SPEC]) { + expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`) + const step = job.steps.find((candidate) => candidate.run?.includes(spec)) + expect(step.if, spec).toContain(`contains(inputs.test_files, '${spec}')`) + expect(step.env.ORCA_STARTUP_DIAGNOSTICS, spec).toBeUndefined() + } +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 2284b0ca99c2..3c123c1f7243 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -17,26 +17,33 @@ export const PR_E2E_SOURCE_ROUTES = [ id: 'serve.orcad-mode-switch', specs: ['tests/e2e/orcad-serve-mode-switch.spec.ts'], matches: (file) => - isProductSource(file) && - /^src\/(?:cli\/runtime\/(?:launch|serve-)|main\/orcad\/(?:main|orcad-entry|orcad-instance-lock|orcad-command-arguments|orcad-lifecycle)\.ts$|main\/startup\/desktop-profile-instance-lock\.ts$|main\/daemon\/daemon-(?:spawner|endpoint-adoption|init)|main\/server\/serve-)/.test( + /^tests\/e2e\/helpers\/(?:orca-serve-cli-host|headless-paired-runtime-host)\.ts$/.test( file - ) + ) || + (isProductSource(file) && + /^src\/(?:cli\/runtime\/(?:launch|serve-)|main\/orcad\/(?:main|orcad-entry|orcad-instance-lock|orcad-command-arguments|orcad-lifecycle)\.ts$|main\/startup\/desktop-profile-instance-lock\.ts$|main\/daemon\/daemon-(?:spawner|endpoint-adoption|init)|main\/server\/serve-)/.test( + file + )) }, { id: 'startup.windows-missing-appdata', specs: ['tests/e2e/windows-missing-appdata-startup.spec.ts'], matches: (file) => - isProductSource(file) && - /^src\/main\/startup\/(?:windows-app-data-path|main-process-preflight)\.ts$/.test(file) + file === 'tests/e2e/helpers/orca-serve-cli-host.ts' || + (isProductSource(file) && + /^src\/main\/startup\/(?:windows-app-data-path|main-process-preflight)\.ts$/.test(file)) }, { id: 'ssh.orcad-auto-convert', specs: ['tests/e2e/ssh-orcad-auto-convert.spec.ts'], matches: (file) => - isProductSource(file) && - /^src\/main\/(?:ipc\/ssh-host-server-|ssh\/(?:ssh-host-server-|orcad-runtime-conversion|orcad-migration-|orcad-retained-source|orcad-runtime-deployment))/.test( + /^tests\/e2e\/helpers\/(?:orcad-convert-(?:flow|host)|orcad-template-variant|orcad-upgrade-profile)\.ts$/.test( file - ) + ) || + (isProductSource(file) && + /^src\/main\/(?:ipc\/ssh-host-server-|ssh\/(?:ssh-host-server-|orcad-runtime-conversion|orcad-migration-|orcad-retained-source|orcad-runtime-deployment))/.test( + file + )) }, { id: 'ssh.orcad-idle-exit', diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 522b26efe937..808e52194981 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -18,7 +18,6 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' }, - '.github/workflows/e2e.yml#windows-missing-appdata-startup': { contents: 'read' }, '.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' }, '.github/workflows/node-server-tests.yml#changes': { contents: 'read' }, '.github/workflows/node-server-tests.yml#desktop_template': { contents: 'read' }, diff --git a/src/main/ipc/orcad-runtime-conversion-handlers.test.ts b/src/main/ipc/orcad-runtime-conversion-handlers.test.ts index e5535988d689..5f570e5c586d 100644 --- a/src/main/ipc/orcad-runtime-conversion-handlers.test.ts +++ b/src/main/ipc/orcad-runtime-conversion-handlers.test.ts @@ -3,11 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ handle: vi.fn(), convert: vi.fn(), - pending: vi.fn(), - preflight: vi.fn(), - manifest: vi.fn(), - terminals: vi.fn(), - inventory: vi.fn() + pending: vi.fn() })) vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) @@ -17,25 +13,6 @@ vi.mock('../ssh/orcad-runtime-conversion', () => ({ vi.mock('../ssh/orcad-managed-migration-status', () => ({ listPendingManagedOrcadMigrations: mocks.pending })) -vi.mock('../ssh/orcad-managed-runtime-context', () => ({ - requireManagedOrcadInfrastructure: () => ({ - targetStore: { - getOrcadMigrationSource: () => ({ getSshTarget: () => ({ id: 'ssh-1', label: 'Builder' }) }) - } - }) -})) -vi.mock('../ssh/ssh-target-orcad-preflight', () => ({ - preflightOrcadMigrationExport: mocks.preflight -})) -vi.mock('../ssh/orcad-migration-manifest-export', () => ({ - createOrcadMigrationManifest: mocks.manifest -})) -vi.mock('../ssh/orcad-migration-terminal-gate', () => ({ - assessOrcadMigrationTerminals: mocks.terminals -})) -vi.mock('../ssh/orcad-migration-relay-pty-lister', () => ({ - orcadMigrationRelayPtyLister: mocks.inventory -})) vi.mock('../ssh/orcad-runtime-conversion-wiring', () => ({ conversionCollaborators: () => ({ marker: 'live-collaborators' }) })) @@ -57,42 +34,6 @@ describe('managed server conversion IPC', () => { registerOrcadRuntimeConversionHandlers(() => '/profile') }) - it('previews what moves and keeps only blockers that stop the move', async () => { - mocks.preflight.mockReturnValue({ - targetLabel: 'Builder', - blockers: [ - { code: 'orcad_migration_direct_ssh_repositories', category: 'drainable-static-state' }, - { code: 'orcad_migration_saved_port_forwards', category: 'client-owned-state' }, - { code: 'orcad_migration_target_owned', category: 'exclusive-ownership' } - ] - }) - mocks.manifest.mockReturnValue({ - payload: { - repositories: [{}, {}], - projectGroups: [{}], - folderWorkspaces: [{}], - dormantState: { automations: [{}], workspaceSession: {} } - } - }) - mocks.terminals.mockResolvedValue({ verdict: 'live', ptyIds: ['p'], reason: 'running' }) - await expect( - handler('runtimeEnvironments:previewOrcadConversion')(null, { sshTargetId: 'ssh-1' }) - ).resolves.toEqual({ - sshTargetId: 'ssh-1', - targetLabel: 'Builder', - moves: { - repositories: 2, - projectGroups: 1, - folderWorkspaces: 1, - automations: 1, - workspaceSession: true - }, - blockers: [{ code: 'orcad_migration_target_owned', category: 'exclusive-ownership' }], - terminals: { verdict: 'live', ptyIds: ['p'], reason: 'running' } - }) - expect(mocks.inventory).toHaveBeenCalledWith('ssh-1') - }) - it('converts with the live collaborators and lists pending migrations from the profile', async () => { mocks.convert.mockResolvedValue({ outcome: 'converted' }) await handler('runtimeEnvironments:convertSshHostToManagedOrcad')(null, { diff --git a/src/main/ipc/orcad-runtime-conversion-handlers.ts b/src/main/ipc/orcad-runtime-conversion-handlers.ts index 26542bf34c3b..fa2591f379e2 100644 --- a/src/main/ipc/orcad-runtime-conversion-handlers.ts +++ b/src/main/ipc/orcad-runtime-conversion-handlers.ts @@ -1,25 +1,14 @@ import { ipcMain } from 'electron' import type { - OrcadManagedConversionPreview, OrcadManagedConversionResult, OrcadManagedPendingMigrationRow } from '../../shared/orcad-managed-runtime' import { listPendingManagedOrcadMigrations } from '../ssh/orcad-managed-migration-status' -import { requireManagedOrcadInfrastructure } from '../ssh/orcad-managed-runtime-context' -import { createOrcadMigrationManifest } from '../ssh/orcad-migration-manifest-export' -import { assessOrcadMigrationTerminals } from '../ssh/orcad-migration-terminal-gate' import { convertSshTargetToManagedOrcad } from '../ssh/orcad-runtime-conversion' -import { orcadMigrationRelayPtyLister } from '../ssh/orcad-migration-relay-pty-lister' import { conversionCollaborators } from '../ssh/orcad-runtime-conversion-wiring' -import { preflightOrcadMigrationExport } from '../ssh/ssh-target-orcad-preflight' import { requiredString } from './orcad-runtime-lifecycle-handlers' export function registerOrcadRuntimeConversionHandlers(getUserDataPath: () => string): void { - ipcMain.handle( - 'runtimeEnvironments:previewOrcadConversion', - (_event, args: { sshTargetId: string }): Promise => - previewConversion(requiredString(args?.sshTargetId, 'SSH target')) - ) ipcMain.handle( 'runtimeEnvironments:convertSshHostToManagedOrcad', ( @@ -39,41 +28,3 @@ export function registerOrcadRuntimeConversionHandlers(getUserDataPath: () => st (): OrcadManagedPendingMigrationRow[] => listPendingManagedOrcadMigrations(getUserDataPath()) ) } - -/** Read-only: exports nothing and fences nothing. */ -async function previewConversion(sshTargetId: string): Promise { - const { targetStore } = requireManagedOrcadInfrastructure() - const store = targetStore.getOrcadMigrationSource() - const target = store.getSshTarget(sshTargetId) - if (!target) { - throw new Error('SSH target is required.') - } - const preflight = preflightOrcadMigrationExport(store, sshTargetId) - const manifest = createOrcadMigrationManifest(store, target) - const dormant = manifest.payload.dormantState - const terminals = await assessOrcadMigrationTerminals( - store, - sshTargetId, - orcadMigrationRelayPtyLister(sshTargetId) - ) - return { - sshTargetId, - targetLabel: preflight.targetLabel, - moves: { - repositories: manifest.payload.repositories.length, - projectGroups: manifest.payload.projectGroups.length, - folderWorkspaces: manifest.payload.folderWorkspaces.length, - automations: dormant?.automations?.length ?? 0, - workspaceSession: Boolean(dormant?.workspaceSession) - }, - blockers: preflight.blockers.filter( - (blocker) => - blocker.category !== 'drainable-static-state' && - blocker.code !== 'orcad_migration_saved_port_forwards' - ), - terminals: - terminals.verdict === 'exited' - ? { verdict: 'exited' } - : { verdict: terminals.verdict, ptyIds: terminals.ptyIds, reason: terminals.reason } - } -} diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index afbcd1747c39..3ca79bc3aa51 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -22,7 +22,6 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:recoverOrcad', 'runtimeEnvironments:stopOrcad', 'runtimeEnvironments:cancelOrcadStop', - 'runtimeEnvironments:previewOrcadConversion', 'runtimeEnvironments:convertSshHostToManagedOrcad', 'runtimeEnvironments:listPendingOrcadMigrations', 'runtimeEnvironments:previewOrcadDeltaMove', diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 7ba19f0e9ad8..81eae1e2a14d 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -159,7 +159,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:listPendingOrcadSshProvisioning', 'runtimeEnvironments:deployOrcad', 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:previewOrcadConversion', 'runtimeEnvironments:convertSshHostToManagedOrcad', 'runtimeEnvironments:listPendingOrcadMigrations', 'runtimeEnvironments:previewOrcadDeltaMove', @@ -205,7 +204,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:recoverOrcad', 'runtimeEnvironments:stopOrcad', 'runtimeEnvironments:cancelOrcadStop', - 'runtimeEnvironments:previewOrcadConversion', 'runtimeEnvironments:convertSshHostToManagedOrcad', 'runtimeEnvironments:listPendingOrcadMigrations', 'runtimeEnvironments:previewOrcadDeltaMove', diff --git a/src/preload/api/managed-orcad-api.ts b/src/preload/api/managed-orcad-api.ts index 310464330272..09d67fb9808a 100644 --- a/src/preload/api/managed-orcad-api.ts +++ b/src/preload/api/managed-orcad-api.ts @@ -4,7 +4,6 @@ import type { OrcadDeltaMovePreview, OrcadDeltaMoveResult, OrcadManagedCancelStopResult, - OrcadManagedConversionPreview, OrcadManagedConversionResult, OrcadManagedDeployResult, OrcadManagedPendingMigrationRow, @@ -33,7 +32,6 @@ export type ManagedOrcadPreloadApi = { cancelStop: (args: { selector: string }) => Promise linkSshAccess: (args: RuntimeSshAccessLinkRequest) => Promise unlinkSshAccess: (args: RuntimeSshAccessUnlinkRequest) => Promise - previewConversion: (args: { sshTargetId: string }) => Promise convertSshHost: (args: { sshTargetId: string name: string @@ -54,8 +52,6 @@ export const managedOrcadApi: ManagedOrcadPreloadApi = { cancelStop: (args) => ipcRenderer.invoke('runtimeEnvironments:cancelOrcadStop', args), linkSshAccess: (args) => ipcRenderer.invoke('runtimeEnvironments:linkSshAccess', args), unlinkSshAccess: (args) => ipcRenderer.invoke('runtimeEnvironments:unlinkSshAccess', args), - previewConversion: (args) => - ipcRenderer.invoke('runtimeEnvironments:previewOrcadConversion', args), convertSshHost: (args) => ipcRenderer.invoke('runtimeEnvironments:convertSshHostToManagedOrcad', args), listPendingMigrations: () => ipcRenderer.invoke('runtimeEnvironments:listPendingOrcadMigrations'), diff --git a/src/renderer/src/components/settings/ManagedServerRow.test.tsx b/src/renderer/src/components/settings/ManagedServerRow.test.tsx index a89945722f14..1522b2e72634 100644 --- a/src/renderer/src/components/settings/ManagedServerRow.test.tsx +++ b/src/renderer/src/components/settings/ManagedServerRow.test.tsx @@ -61,7 +61,9 @@ describe('managed server row', () => { const { container } = await render() expect(container.textContent).toContain('Running terminals: unknown') expect(container.textContent).toContain('Migration: Copy staged') - expect(container.textContent).toContain('Update to 1.3.0 deferred: the daemon did not answer') + expect(container.textContent).toContain('Update to 1.3.0 deferred.') + expect(container.textContent).toContain('couldn’t confirm what is running') + expect(container.textContent).not.toContain('the daemon did not answer') expect(button(container, 'Roll back')).toBeUndefined() }) diff --git a/src/renderer/src/components/settings/ManagedServerRow.tsx b/src/renderer/src/components/settings/ManagedServerRow.tsx index 624ee8fe4e22..7a5ba28df7b1 100644 --- a/src/renderer/src/components/settings/ManagedServerRow.tsx +++ b/src/renderer/src/components/settings/ManagedServerRow.tsx @@ -7,7 +7,12 @@ import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orc import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' import { Button } from '../ui/button' -import { migrationPhaseLabel, recoveryLabel, terminalCensusLabel } from './managed-server-copy' +import { + managedServerOutcomeLabel, + migrationPhaseLabel, + recoveryLabel, + terminalCensusLabel +} from './managed-server-copy' type ManagedServerAction = 'update' | 'rollback' | 'recover' | 'stop' | 'cancelStop' @@ -18,11 +23,11 @@ type ManagedServerRowProps = { } /** Turns a refusal or deferral into the one line the user acts on. */ -function outcomeMessage(result: { outcome: string; reason?: string }): string | null { +function outcomeMessage(result: Parameters[0]): string | null { return result.outcome === 'deferred' || result.outcome === 'refused' || result.outcome === 'failed' - ? (result.reason ?? result.outcome) + ? managedServerOutcomeLabel(result) : null } @@ -137,13 +142,11 @@ export function ManagedServerRow({ {status.deferredUpdate ? (

{translate( - 'auto.components.settings.managedServers.row.deferred', - 'Update to {{version}} deferred: {{reason}}', - { - version: status.deferredUpdate.candidateVersion, - reason: status.deferredUpdate.reason - } - )} + 'auto.components.settings.managedServers.row.deferredVersion', + 'Update to {{version}} deferred.', + { version: status.deferredUpdate.candidateVersion } + )}{' '} + {managedServerOutcomeLabel(status.deferredUpdate)}

) : null} diff --git a/src/renderer/src/components/settings/ManagedServersSection.tsx b/src/renderer/src/components/settings/ManagedServersSection.tsx index 2cc1c7da8232..cf687591712b 100644 --- a/src/renderer/src/components/settings/ManagedServersSection.tsx +++ b/src/renderer/src/components/settings/ManagedServersSection.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { useCallback, useEffect, useId, useState } from 'react' import { toast } from 'sonner' import type { OrcadManagedPendingMigrationRow } from '../../../../shared/orcad-managed-runtime' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' @@ -11,7 +12,7 @@ import { Label } from '../ui/label' import { ManagedServerRow } from './ManagedServerRow' import { RuntimeSshAccessControl } from './RuntimeSshAccessControl' import { SshTargetSelect } from './SshTargetSelect' -import { migrationPhaseLabel } from './managed-server-copy' +import { managedServerOutcomeLabel, migrationPhaseLabel } from './managed-server-copy' type ManagedServersSectionProps = { environments: PublicKnownRuntimeEnvironment[] @@ -29,6 +30,10 @@ export function ManagedServersSection({ const [name, setName] = useState('') const [targetId, setTargetId] = useState('') const [deploying, setDeploying] = useState(false) + const [resumingId, setResumingId] = useState(null) + const [loadFailed, setLoadFailed] = useState(false) + const hostFieldId = useId() + const nameFieldId = useId() const reload = useCallback(async () => { if (!api) { @@ -41,12 +46,13 @@ export function ManagedServersSection({ if (mountedRef.current) { setTargets(nextTargets) setPending(nextPending) + setLoadFailed(false) } }, [api, mountedRef]) useEffect(() => { - void reload().catch(() => undefined) - }, [reload]) + void reload().catch(() => mountedRef.current && setLoadFailed(true)) + }, [mountedRef, reload]) if (!api) { return null @@ -59,7 +65,7 @@ export function ManagedServersSection({ try { const result = await api.deploy({ name: name.trim(), sshTargetId: targetId }) if (result.outcome === 'deferred') { - toast.message(result.reason) + toast.message(managedServerOutcomeLabel(result)) } else { setName('') setTargetId('') @@ -76,38 +82,55 @@ export function ManagedServersSection({ } const resume = async (row: OrcadManagedPendingMigrationRow): Promise => { + if (resumingId) { + return + } + setResumingId(row.migrationId) try { const result = await api.convertSshHost({ sshTargetId: row.sshTargetId, name: row.name }) if (result.outcome !== 'converted') { - toast.message(result.reason) + toast.message(managedServerOutcomeLabel(result)) } onChanged() await reload() } catch (error) { toast.error(error instanceof Error ? error.message : String(error)) + } finally { + if (mountedRef.current) { + setResumingId(null) + } } } return ( -
-
-

+
+
+
{translate('auto.components.settings.managedServers.title', 'Managed servers')} -

+

{translate( 'auto.components.settings.managedServers.description', 'Orca installs and runs a server on an empty SSH host and keeps it up to date.' )}

+ {loadFailed ? ( +

+ {translate( + 'auto.components.settings.managedServers.loadFailed', + 'Orca couldn’t load SSH hosts and unfinished moves. Reopen Settings to try again.' + )} +

+ ) : null}
-
-
@@ -140,7 +166,14 @@ export function ManagedServersSection({
{row.name}

{migrationPhaseLabel(row.phase)}

- diff --git a/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx b/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx index 72121f45b38b..a320a596898d 100644 --- a/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx +++ b/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { Loader2 } from 'lucide-react' +import { useId, useState } from 'react' import { toast } from 'sonner' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { SshTarget } from '../../../../shared/ssh-types' @@ -29,6 +30,8 @@ export function RuntimeSshAccessControl({ const [targetId, setTargetId] = useState('') const [remotePort, setRemotePort] = useState(DEFAULT_REMOTE_PORT) const [busy, setBusy] = useState(false) + const targetFieldId = useId() + const portFieldId = useId() const linked = environment.sshAccess const submit = async (): Promise => { @@ -76,8 +79,9 @@ export function RuntimeSshAccessControl({ return (
- +
-
) diff --git a/src/renderer/src/components/settings/SshHostChangedActions.tsx b/src/renderer/src/components/settings/SshHostChangedActions.tsx index 463e96fd7e72..e958dd9a715e 100644 --- a/src/renderer/src/components/settings/SshHostChangedActions.tsx +++ b/src/renderer/src/components/settings/SshHostChangedActions.tsx @@ -54,7 +54,15 @@ export function SshHostChangedActions({ - setMoving(false)} onFinished={onChanged} /> - !open && setKeeping(false)}> - + !open && !busy && setKeeping(false)}> + {translate('auto.components.settings.keepServer.title', 'Keep the server’s version')} @@ -78,7 +86,7 @@ export function SshHostChangedActions({ {error ?

{error}

: null} -