mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
feat(ssh): offer to move a host whose open terminals keep it on the relay (#25097)
A host with any open SSH terminal never converted: the connect gate keeps the relay while relay terminals are live, and an open tab respawns them on every connect. The first such connect per host per app version now marks the relay status with offerMove (recorded as managedServerMoveOffered), which toasts "Move <host> ... Its N open terminals will restart." The SSH Hosts status line keeps a "Move to managed server" action while terminals are live. Confirming runs ssh:moveToManagedServer: stop the host's relay terminals through the extracted ssh:terminateSessions path, re-run the connect gate's terminal census, refuse on anything but exited, then reconnect so the connect-time decision runs the journaled conversion. Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -11,8 +11,11 @@ import {
|
||||
} from '../ssh/ssh-provider-authority'
|
||||
import { allowsDirectSshRelay } from '../ssh/ssh-connection-store'
|
||||
import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry'
|
||||
import { setSshHostServerStatus } from '../ssh/ssh-host-server-status'
|
||||
import { decideHostServer, publishManagedServerConnect } from './ssh-host-server-connect'
|
||||
import {
|
||||
decideHostServer,
|
||||
publishManagedServerConnect,
|
||||
recordRelayDecision
|
||||
} from './ssh-host-server-connect'
|
||||
import { activeSessions } from './ssh-active-relay-sessions'
|
||||
import {
|
||||
assertSshConnectsNotFenced,
|
||||
@@ -179,12 +182,7 @@ async function doConnect(
|
||||
return publishManagedServerConnect(targetId, server.environmentId)
|
||||
}
|
||||
if (server) {
|
||||
setSshHostServerStatus(targetId, {
|
||||
kind: 'relay',
|
||||
reason: server.reason,
|
||||
...(server.detail ? { detail: server.detail } : {}),
|
||||
...(server.terminals !== undefined ? { terminals: server.terminals } : {})
|
||||
})
|
||||
recordRelayDecision(target, server)
|
||||
}
|
||||
// Re-read: a conversion attempt may have fenced the host since the lookup above.
|
||||
const relayTarget = getSshTargetRegistryStore()!.getTarget(targetId) ?? target
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
sshRemotePtyLeaseAllowsReattach,
|
||||
type SshTarget,
|
||||
type SshTerminateSessionsResult
|
||||
} from '../../shared/ssh-types'
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
|
||||
import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
|
||||
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import { toAppSshPtyId } from '../providers/ssh-pty-id'
|
||||
import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority'
|
||||
import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset'
|
||||
import { setSshTargetRegistryHandlers, getSshTargetRegistryStore } from '../ssh/ssh-target-registry'
|
||||
import {
|
||||
clearProviderPtyState,
|
||||
deletePtyOwnership,
|
||||
getPtyIdsForConnection,
|
||||
getSshPtyProvider
|
||||
} from './pty'
|
||||
import { clearProviderPtyState, deletePtyOwnership, getPtyIdsForConnection } from './pty'
|
||||
import { activeSessions } from './ssh-active-relay-sessions'
|
||||
import {
|
||||
assertSshConnectsNotFenced,
|
||||
connectInFlight,
|
||||
credentialRequestedForTarget,
|
||||
invalidateConnectAttempt,
|
||||
resetRelayInFlight,
|
||||
runSshTestConnectionProbe
|
||||
} from './ssh-connect-attempt-registry'
|
||||
import { connectTarget } from './ssh-connect-flow'
|
||||
import { connectionManager, persistedStore } from './ssh-ipc-context'
|
||||
import { getPublicSshState } from './ssh-renderer-broadcast'
|
||||
import {
|
||||
disconnectRegisteredSshTarget,
|
||||
teardownActiveSshSession,
|
||||
teardownSshTargetTransport
|
||||
} from './ssh-session-teardown'
|
||||
import { disconnectRegisteredSshTarget, teardownActiveSshSession } from './ssh-session-teardown'
|
||||
import { terminateSshTargetSessions } from './ssh-terminate-sessions'
|
||||
import { moveSshHostToManagedServer } from './ssh-managed-server-move'
|
||||
import { runTargetLifecycle } from './ssh-target-lifecycle-queue'
|
||||
|
||||
async function doResetRelay(targetId: string, target: SshTarget): Promise<void> {
|
||||
@@ -115,85 +101,13 @@ export function registerSshConnectionHandlers(): void {
|
||||
await disconnectRegisteredSshTarget(args.targetId)
|
||||
})
|
||||
|
||||
ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => {
|
||||
invalidateConnectAttempt(args.targetId)
|
||||
// Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell
|
||||
// "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue.
|
||||
let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 }
|
||||
await runTargetLifecycle(args.targetId, async () => {
|
||||
const provider = getSshPtyProvider(args.targetId)
|
||||
const leases = persistedStore!.getSshRemotePtyLeases(args.targetId)
|
||||
const ptyIdsByRelayId = new Map<string, string>()
|
||||
// Why: only leases the app still believes it owns may force a reconnect; a lease whose route
|
||||
// died for good is swept opportunistically instead, so a target that can no longer answer
|
||||
// never blocks its own removal (issue #2626, and the renderer tolerates the refusal there).
|
||||
const ownedRelayIds = new Set<string>()
|
||||
const trackPtyId = (ptyId: string, owned: boolean): void => {
|
||||
const relayPtyId = toRelaySshPtyId(args.targetId, ptyId)
|
||||
if (!ptyIdsByRelayId.has(relayPtyId)) {
|
||||
ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId))
|
||||
}
|
||||
if (owned) {
|
||||
ownedRelayIds.add(relayPtyId)
|
||||
}
|
||||
}
|
||||
for (const ptyId of getPtyIdsForConnection(args.targetId)) {
|
||||
trackPtyId(ptyId, true)
|
||||
}
|
||||
for (const lease of leases) {
|
||||
if (lease.state === 'terminated') {
|
||||
continue
|
||||
}
|
||||
// Why the predicate and not `state !== 'expired'`: an `expired` lease carrying no
|
||||
// retirement mark records only that reattach gave up, never that the remote shell died, so
|
||||
// it is exactly the orphan the user's terminate must reach — and reaching it needs the
|
||||
// relay, which is what the fence below demands. Only `supersededBy` / `relayIdRecycled`
|
||||
// prove the route is dead for good, and those stay unowned.
|
||||
trackPtyId(lease.ptyId, sshRemotePtyLeaseAllowsReattach(lease))
|
||||
}
|
||||
const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({
|
||||
relayPtyId,
|
||||
appPtyId
|
||||
}))
|
||||
ipcMain.handle('ssh:terminateSessions', (_event, args: { targetId: string }) =>
|
||||
terminateSshTargetSessions(args.targetId)
|
||||
)
|
||||
|
||||
if (ownedRelayIds.size > 0 && !provider) {
|
||||
throw new Error(
|
||||
`${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.`
|
||||
)
|
||||
}
|
||||
const shutdownResults = provider
|
||||
? await Promise.allSettled(
|
||||
ptyIds.map(({ appPtyId }) =>
|
||||
provider.shutdown(appPtyId, { immediate: true, keepHistory: false })
|
||||
)
|
||||
)
|
||||
: []
|
||||
if (!provider) {
|
||||
// Nothing observed these remote shells, so their state is unknown — not "nothing to do".
|
||||
outcome = { terminated: 0, unverifiable: ptyIds.length }
|
||||
}
|
||||
const shutdownFailures: string[] = []
|
||||
for (const [index, result] of shutdownResults.entries()) {
|
||||
const { appPtyId, relayPtyId } = ptyIds[index]
|
||||
if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) {
|
||||
shutdownFailures.push(
|
||||
`${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
clearProviderPtyState(appPtyId)
|
||||
deletePtyOwnership(appPtyId)
|
||||
persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
|
||||
outcome = { ...outcome, terminated: outcome.terminated + 1 }
|
||||
}
|
||||
if (shutdownFailures.length > 0) {
|
||||
// Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry.
|
||||
throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`)
|
||||
}
|
||||
await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist())
|
||||
})
|
||||
return outcome
|
||||
})
|
||||
ipcMain.handle('ssh:moveToManagedServer', (_event, args: { targetId: string }) =>
|
||||
moveSshHostToManagedServer(args.targetId)
|
||||
)
|
||||
|
||||
ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => {
|
||||
const existingReset = resetRelayInFlight.get(args.targetId)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import type { SshConnectionState, SshTarget } from '../../shared/ssh-types'
|
||||
import type { HostServerOnConnectResult } from '../ssh/ssh-host-server-on-connect'
|
||||
import { relayServerStatus, shouldToastManagedServerMove } from '../ssh/ssh-host-server-move-offer'
|
||||
import { setSshHostServerStatus } from '../ssh/ssh-host-server-status'
|
||||
import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry'
|
||||
import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context'
|
||||
import { broadcastSshState, getPublicSshState } from './ssh-renderer-broadcast'
|
||||
|
||||
@@ -43,3 +45,18 @@ export function publishManagedServerConnect(
|
||||
broadcastSshState(getCurrentMainWindow, targetId, state)
|
||||
return getPublicSshState(targetId) ?? state
|
||||
}
|
||||
|
||||
/** Records why the host keeps the relay; the first live-terminals stop this version offers a move. */
|
||||
export function recordRelayDecision(
|
||||
target: SshTarget,
|
||||
decision: Extract<HostServerOnConnectResult, { route: 'relay' }>
|
||||
): void {
|
||||
const appVersion = getAppEnvironment().getVersion()
|
||||
const offerMove = shouldToastManagedServerMove(target, decision, appVersion)
|
||||
if (offerMove) {
|
||||
getSshTargetRegistryStore()!.updateTarget(target.id, {
|
||||
managedServerMoveOffered: { appVersion }
|
||||
})
|
||||
}
|
||||
setSshHostServerStatus(target.id, relayServerStatus(decision, offerMove))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
updateTarget: vi.fn(),
|
||||
setStatus: vi.fn()
|
||||
}))
|
||||
vi.mock('../../shared/app-environment', () => ({
|
||||
getAppEnvironment: () => ({ getVersion: () => '1.5.0' })
|
||||
}))
|
||||
vi.mock('../ssh/ssh-target-registry', () => ({
|
||||
getSshTargetRegistryStore: () => ({ updateTarget: mocks.updateTarget })
|
||||
}))
|
||||
vi.mock('../ssh/ssh-host-server-status', () => ({ setSshHostServerStatus: mocks.setStatus }))
|
||||
vi.mock('./ssh-ipc-context', () => ({ connectionManager: null, getCurrentMainWindow: () => null }))
|
||||
vi.mock('./ssh-renderer-broadcast', () => ({
|
||||
broadcastSshState: vi.fn(),
|
||||
getPublicSshState: vi.fn()
|
||||
}))
|
||||
|
||||
const { recordRelayDecision } = await import('./ssh-host-server-connect')
|
||||
|
||||
const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' }
|
||||
const live = { route: 'relay' as const, reason: 'relay_terminals_live' as const, terminals: 2 }
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('recording why a connect kept the relay', () => {
|
||||
it('records the first offer this version and marks the status for the toast', () => {
|
||||
recordRelayDecision(target, live)
|
||||
expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', {
|
||||
managedServerMoveOffered: { appVersion: '1.5.0' }
|
||||
})
|
||||
expect(mocks.setStatus).toHaveBeenCalledWith('ssh-1', {
|
||||
kind: 'relay',
|
||||
reason: 'relay_terminals_live',
|
||||
terminals: 2,
|
||||
offerMove: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the status line offer but skips the toast once this version offered it', () => {
|
||||
recordRelayDecision({ ...target, managedServerMoveOffered: { appVersion: '1.5.0' } }, live)
|
||||
expect(mocks.updateTarget).not.toHaveBeenCalled()
|
||||
expect(mocks.setStatus).toHaveBeenCalledWith('ssh-1', {
|
||||
kind: 'relay',
|
||||
reason: 'relay_terminals_live',
|
||||
terminals: 2
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -146,7 +146,8 @@ export function createSshIpcMocks(): SshIpcMocks {
|
||||
// Null keeps today's relay path; the real decision is covered by its own tests.
|
||||
hostServerConnect: {
|
||||
decideHostServer: vi.fn(async () => null),
|
||||
publishManagedServerConnect: vi.fn()
|
||||
publishManagedServerConnect: vi.fn(),
|
||||
recordRelayDecision: vi.fn()
|
||||
},
|
||||
sshConnectionStore: {
|
||||
isRuntimeOwnedSshTarget: (target: { owner?: unknown }) => target.owner !== undefined,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
|
||||
import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types'
|
||||
import type { HostServerTerminalVerdict } from '../ssh/ssh-host-server-on-connect'
|
||||
|
||||
vi.mock('./ssh-connect-flow', () => ({ connectTarget: vi.fn() }))
|
||||
vi.mock('./ssh-terminate-sessions', () => ({ terminateSshTargetSessions: vi.fn() }))
|
||||
|
||||
const { moveSshHostToManagedServer } = await import('./ssh-managed-server-move')
|
||||
|
||||
const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' }
|
||||
|
||||
function deps(
|
||||
options: {
|
||||
census?: HostServerTerminalVerdict
|
||||
unverifiable?: number
|
||||
afterConnect?: SshManagedServerStatus
|
||||
} = {}
|
||||
) {
|
||||
const calls: string[] = []
|
||||
let status: SshManagedServerStatus | undefined = {
|
||||
kind: 'relay',
|
||||
reason: 'relay_terminals_live',
|
||||
terminals: 2
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
getTarget: vi.fn(() => target),
|
||||
terminate: vi.fn(async () => {
|
||||
calls.push('terminate')
|
||||
return { terminated: 2, unverifiable: options.unverifiable ?? 0 }
|
||||
}),
|
||||
relayTerminals: vi.fn(async () => {
|
||||
calls.push('census')
|
||||
return options.census ?? { verdict: 'exited' as const, count: 0 }
|
||||
}),
|
||||
connect: vi.fn(async () => {
|
||||
calls.push('connect')
|
||||
status = options.afterConnect ?? { kind: 'managed', environmentId: 'env-1' }
|
||||
}),
|
||||
serverStatus: vi.fn(() => status)
|
||||
}
|
||||
}
|
||||
|
||||
describe('moving an SSH host to its managed server on request', () => {
|
||||
it('stops the relay terminals, proves them exited, then reconnects through the conversion', async () => {
|
||||
const move = deps()
|
||||
await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({
|
||||
outcome: 'moved',
|
||||
environmentId: 'env-1'
|
||||
})
|
||||
expect(move.calls).toEqual(['terminate', 'census', 'connect'])
|
||||
expect(move.relayTerminals).toHaveBeenCalledWith(target)
|
||||
})
|
||||
|
||||
it('refuses without converting when the stop could not reach every terminal', async () => {
|
||||
const move = deps({ unverifiable: 1 })
|
||||
await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
terminals: 1
|
||||
})
|
||||
expect(move.calls).toEqual(['terminate'])
|
||||
})
|
||||
|
||||
it('refuses without converting when the census is unverifiable or still live', async () => {
|
||||
const unverifiable = deps({ census: { verdict: 'unverifiable', count: 3 } })
|
||||
await expect(moveSshHostToManagedServer('ssh-1', unverifiable)).resolves.toEqual({
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
terminals: 3
|
||||
})
|
||||
expect(unverifiable.connect).not.toHaveBeenCalled()
|
||||
|
||||
const live = deps({ census: { verdict: 'live', count: 1 } })
|
||||
await expect(moveSshHostToManagedServer('ssh-1', live)).resolves.toMatchObject({
|
||||
outcome: 'refused',
|
||||
verdict: 'live'
|
||||
})
|
||||
expect(live.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a connect that kept the relay for another reason', async () => {
|
||||
const move = deps({ afterConnect: { kind: 'relay', reason: 'refused', detail: 'blocked' } })
|
||||
await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ outcome: 'stayed' })
|
||||
})
|
||||
|
||||
it('reattaches the relay first when preserved terminals need one to be stopped', async () => {
|
||||
const move = deps()
|
||||
move.terminate.mockRejectedValueOnce(
|
||||
new Error(`${SSH_TERMINATE_RECONNECT_REQUIRED}: reconnect`)
|
||||
)
|
||||
await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toMatchObject({
|
||||
outcome: 'moved'
|
||||
})
|
||||
expect(move.calls).toEqual(['connect', 'terminate', 'census', 'connect'])
|
||||
})
|
||||
|
||||
it('surfaces any other stop failure without converting', async () => {
|
||||
const move = deps()
|
||||
move.terminate.mockRejectedValueOnce(new Error('Failed to terminate SSH host sessions'))
|
||||
await expect(moveSshHostToManagedServer('ssh-1', move)).rejects.toThrow('Failed to terminate')
|
||||
expect(move.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The user's "Move to managed server": stop the host's relay terminals, prove them exited with the
|
||||
* connect gate's own census, then reconnect so the connect-time decision runs the conversion.
|
||||
*/
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
|
||||
import type { SshManagedServerMoveResult } from '../../shared/ssh-managed-server-move'
|
||||
import type {
|
||||
SshManagedServerStatus,
|
||||
SshTarget,
|
||||
SshTerminateSessionsResult
|
||||
} from '../../shared/ssh-types'
|
||||
import type { HostServerTerminalVerdict } from '../ssh/ssh-host-server-on-connect'
|
||||
import { getSshHostServerStatus } from '../ssh/ssh-host-server-status'
|
||||
import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry'
|
||||
import { connectTarget } from './ssh-connect-flow'
|
||||
import { terminateSshTargetSessions } from './ssh-terminate-sessions'
|
||||
|
||||
export type SshManagedServerMoveDeps = {
|
||||
getTarget: (targetId: string) => SshTarget | undefined
|
||||
terminate: (targetId: string) => Promise<SshTerminateSessionsResult>
|
||||
relayTerminals: (target: SshTarget) => Promise<HostServerTerminalVerdict>
|
||||
connect: (targetId: string) => Promise<unknown>
|
||||
serverStatus: (targetId: string) => SshManagedServerStatus | undefined
|
||||
}
|
||||
|
||||
export async function moveSshHostToManagedServer(
|
||||
targetId: string,
|
||||
deps: SshManagedServerMoveDeps = defaultMoveDeps()
|
||||
): Promise<SshManagedServerMoveResult> {
|
||||
const target = deps.getTarget(targetId)
|
||||
if (!target) {
|
||||
throw new Error(`SSH target "${targetId}" not found`)
|
||||
}
|
||||
const stopped = await stopRelayTerminals(targetId, deps)
|
||||
if (stopped.unverifiable > 0) {
|
||||
// Why: an unreached shell is never evidence that it exited (ssh-execution-boundary.md).
|
||||
return { outcome: 'refused', verdict: 'unverifiable', terminals: stopped.unverifiable }
|
||||
}
|
||||
const census = await deps.relayTerminals(deps.getTarget(targetId) ?? target)
|
||||
if (census.verdict !== 'exited') {
|
||||
return { outcome: 'refused', verdict: census.verdict, terminals: census.count }
|
||||
}
|
||||
await deps.connect(targetId)
|
||||
const status = deps.serverStatus(targetId)
|
||||
return status?.kind === 'managed'
|
||||
? { outcome: 'moved', environmentId: status.environmentId }
|
||||
: { outcome: 'stayed' }
|
||||
}
|
||||
|
||||
/** Mirrors the renderer's terminate: preserved shells need a fresh relay before they can be stopped. */
|
||||
async function stopRelayTerminals(
|
||||
targetId: string,
|
||||
deps: SshManagedServerMoveDeps
|
||||
): Promise<SshTerminateSessionsResult> {
|
||||
try {
|
||||
return await deps.terminate(targetId)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.includes(SSH_TERMINATE_RECONNECT_REQUIRED)) {
|
||||
throw error
|
||||
}
|
||||
await deps.connect(targetId)
|
||||
return deps.terminate(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMoveDeps(): SshManagedServerMoveDeps {
|
||||
return {
|
||||
getTarget: (targetId) => getSshTargetRegistryStore()!.getTarget(targetId),
|
||||
terminate: terminateSshTargetSessions,
|
||||
relayTerminals: async (target) => {
|
||||
// Why lazy, like the connect: the managed-server graph loads only when it is needed.
|
||||
const { hostServerOnConnectDeps } = await import('./ssh-host-server-on-connect-wiring')
|
||||
return hostServerOnConnectDeps(getAppEnvironment().getPath('userData')).relayTerminals(target)
|
||||
},
|
||||
connect: connectTarget,
|
||||
serverStatus: getSshHostServerStatus
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ function omitRendererSshTargetGeneration<
|
||||
orcadProvisioning?: unknown
|
||||
orcadFence?: unknown
|
||||
managedServerUnavailable?: unknown
|
||||
managedServerMoveOffered?: unknown
|
||||
remoteRuntimeResolution?: unknown
|
||||
}
|
||||
>(
|
||||
@@ -53,6 +54,7 @@ function omitRendererSshTargetGeneration<
|
||||
| 'orcadProvisioning'
|
||||
| 'orcadFence'
|
||||
| 'managedServerUnavailable'
|
||||
| 'managedServerMoveOffered'
|
||||
| 'remoteRuntimeResolution'
|
||||
> {
|
||||
const {
|
||||
@@ -60,6 +62,7 @@ function omitRendererSshTargetGeneration<
|
||||
orcadProvisioning: _orcadProvisioning,
|
||||
orcadFence: _orcadFence,
|
||||
managedServerUnavailable: _managedServerUnavailable,
|
||||
managedServerMoveOffered: _managedServerMoveOffered,
|
||||
remoteRuntimeResolution: _remoteRuntimeResolution,
|
||||
...rest
|
||||
} = value
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('SSH IPC handlers', () => {
|
||||
expect(channels).toContain('ssh:connect')
|
||||
expect(channels).toContain('ssh:disconnect')
|
||||
expect(channels).toContain('ssh:terminateSessions')
|
||||
expect(channels).toContain('ssh:moveToManagedServer')
|
||||
expect(channels).toContain('ssh:resetRelay')
|
||||
expect(channels).toContain('ssh:getState')
|
||||
expect(channels).toContain('ssh:testConnection')
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
sshRemotePtyLeaseAllowsReattach,
|
||||
type SshTerminateSessionsResult
|
||||
} from '../../shared/ssh-types'
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
|
||||
import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
|
||||
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
import {
|
||||
clearProviderPtyState,
|
||||
deletePtyOwnership,
|
||||
getPtyIdsForConnection,
|
||||
getSshPtyProvider
|
||||
} from './pty'
|
||||
import { invalidateConnectAttempt } from './ssh-connect-attempt-registry'
|
||||
import { persistedStore } from './ssh-ipc-context'
|
||||
import { teardownSshTargetTransport } from './ssh-session-teardown'
|
||||
import { runTargetLifecycle } from './ssh-target-lifecycle-queue'
|
||||
|
||||
/** Stops every relay terminal on the target and closes its transport (`ssh:terminateSessions`). */
|
||||
export async function terminateSshTargetSessions(
|
||||
targetId: string
|
||||
): Promise<SshTerminateSessionsResult> {
|
||||
invalidateConnectAttempt(targetId)
|
||||
// Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell
|
||||
// "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue.
|
||||
let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 }
|
||||
await runTargetLifecycle(targetId, async () => {
|
||||
const provider = getSshPtyProvider(targetId)
|
||||
const leases = persistedStore!.getSshRemotePtyLeases(targetId)
|
||||
const ptyIdsByRelayId = new Map<string, string>()
|
||||
// Why: only leases the app still believes it owns may force a reconnect; a lease whose route
|
||||
// died for good is swept opportunistically instead, so a target that can no longer answer
|
||||
// never blocks its own removal (issue #2626, and the renderer tolerates the refusal there).
|
||||
const ownedRelayIds = new Set<string>()
|
||||
const trackPtyId = (ptyId: string, owned: boolean): void => {
|
||||
const relayPtyId = toRelaySshPtyId(targetId, ptyId)
|
||||
if (!ptyIdsByRelayId.has(relayPtyId)) {
|
||||
ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(targetId, ptyId))
|
||||
}
|
||||
if (owned) {
|
||||
ownedRelayIds.add(relayPtyId)
|
||||
}
|
||||
}
|
||||
for (const ptyId of getPtyIdsForConnection(targetId)) {
|
||||
trackPtyId(ptyId, true)
|
||||
}
|
||||
for (const lease of leases) {
|
||||
if (lease.state === 'terminated') {
|
||||
continue
|
||||
}
|
||||
// Why the predicate and not `state !== 'expired'`: an `expired` lease carrying no
|
||||
// retirement mark records only that reattach gave up, never that the remote shell died, so
|
||||
// it is exactly the orphan the user's terminate must reach — and reaching it needs the
|
||||
// relay, which is what the fence below demands. Only `supersededBy` / `relayIdRecycled`
|
||||
// prove the route is dead for good, and those stay unowned.
|
||||
trackPtyId(lease.ptyId, sshRemotePtyLeaseAllowsReattach(lease))
|
||||
}
|
||||
const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({
|
||||
relayPtyId,
|
||||
appPtyId
|
||||
}))
|
||||
|
||||
if (ownedRelayIds.size > 0 && !provider) {
|
||||
throw new Error(
|
||||
`${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.`
|
||||
)
|
||||
}
|
||||
const shutdownResults = provider
|
||||
? await Promise.allSettled(
|
||||
ptyIds.map(({ appPtyId }) =>
|
||||
provider.shutdown(appPtyId, { immediate: true, keepHistory: false })
|
||||
)
|
||||
)
|
||||
: []
|
||||
if (!provider) {
|
||||
// Nothing observed these remote shells, so their state is unknown — not "nothing to do".
|
||||
outcome = { terminated: 0, unverifiable: ptyIds.length }
|
||||
}
|
||||
const shutdownFailures: string[] = []
|
||||
for (const [index, result] of shutdownResults.entries()) {
|
||||
const { appPtyId, relayPtyId } = ptyIds[index]
|
||||
if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) {
|
||||
shutdownFailures.push(
|
||||
`${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
clearProviderPtyState(appPtyId)
|
||||
deletePtyOwnership(appPtyId)
|
||||
persistedStore!.markSshRemotePtyLease(targetId, relayPtyId, 'terminated')
|
||||
outcome = { ...outcome, terminated: outcome.terminated + 1 }
|
||||
}
|
||||
if (shutdownFailures.length > 0) {
|
||||
// Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry.
|
||||
throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`)
|
||||
}
|
||||
await teardownSshTargetTransport(targetId, (session) => session.disposeAndPersist())
|
||||
})
|
||||
return outcome
|
||||
}
|
||||
@@ -93,6 +93,7 @@ const SSH_IPC_CHANNELS = [
|
||||
'ssh:connect',
|
||||
'ssh:disconnect',
|
||||
'ssh:terminateSessions',
|
||||
'ssh:moveToManagedServer',
|
||||
'ssh:resetRelay',
|
||||
'ssh:getState',
|
||||
'ssh:needsPassphrasePrompt',
|
||||
|
||||
@@ -30,4 +30,17 @@ describe('loading a managed Orca server fence', () => {
|
||||
const malformed = { ...base, orcadFence: { environmentId: 7 } } as unknown as SshTarget
|
||||
expect(normalizeSshTarget(malformed).orcadFence).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the recorded move offer and drops a malformed one', () => {
|
||||
expect(
|
||||
normalizeSshTarget({ ...base, managedServerMoveOffered: { appVersion: '1.5.0' } })
|
||||
.managedServerMoveOffered
|
||||
).toEqual({ appVersion: '1.5.0' })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: simulates a hand-edited or corrupt stored record.
|
||||
const malformed = {
|
||||
...base,
|
||||
managedServerMoveOffered: { appVersion: 7 }
|
||||
} as unknown as SshTarget
|
||||
expect(normalizeSshTarget(malformed).managedServerMoveOffered).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,7 +66,9 @@ export function normalizeSshTarget(t: SshTarget): SshTarget {
|
||||
if (remoteRuntimeResolution) {
|
||||
normalized.remoteRuntimeResolution = remoteRuntimeResolution
|
||||
}
|
||||
return normalizeManagedServerUnavailable(migrateLegacyManagedOrcadOwner(normalized))
|
||||
return normalizeManagedServerMoveOffered(
|
||||
normalizeManagedServerUnavailable(migrateLegacyManagedOrcadOwner(normalized))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +96,18 @@ function normalizeManagedServerUnavailable(target: SshTarget): SshTarget {
|
||||
return rest
|
||||
}
|
||||
|
||||
function normalizeManagedServerMoveOffered(target: SshTarget): SshTarget {
|
||||
if (target.managedServerMoveOffered === undefined) {
|
||||
return target
|
||||
}
|
||||
const { appVersion } = target.managedServerMoveOffered ?? {}
|
||||
if (typeof appVersion === 'string') {
|
||||
return { ...target, managedServerMoveOffered: { appVersion } }
|
||||
}
|
||||
const { managedServerMoveOffered: _malformed, ...rest } = target
|
||||
return rest
|
||||
}
|
||||
|
||||
function normalizeOrcadFence(target: SshTarget): SshTarget {
|
||||
if (target.orcadFence === undefined) {
|
||||
return target
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canOfferManagedServerMove,
|
||||
relayServerStatus,
|
||||
shouldToastManagedServerMove
|
||||
} from './ssh-host-server-move-offer'
|
||||
|
||||
const live = { route: 'relay' as const, reason: 'relay_terminals_live' as const, terminals: 3 }
|
||||
|
||||
describe('offering to move a host whose relay terminals keep it on the relay', () => {
|
||||
it('offers only when live terminals are the reason', () => {
|
||||
expect(canOfferManagedServerMove(live)).toBe(true)
|
||||
expect(canOfferManagedServerMove({ reason: 'relay_terminals_unverifiable' })).toBe(false)
|
||||
expect(canOfferManagedServerMove({ reason: 'refused' })).toBe(false)
|
||||
expect(canOfferManagedServerMove({ reason: 'orcad_unavailable' })).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts once per host per app version', () => {
|
||||
expect(shouldToastManagedServerMove({}, live, '1.5.0')).toBe(true)
|
||||
expect(
|
||||
shouldToastManagedServerMove(
|
||||
{ managedServerMoveOffered: { appVersion: '1.5.0' } },
|
||||
live,
|
||||
'1.5.0'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldToastManagedServerMove(
|
||||
{ managedServerMoveOffered: { appVersion: '1.4.0' } },
|
||||
live,
|
||||
'1.5.0'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldToastManagedServerMove({}, { reason: 'relay_terminals_unverifiable' }, '1.5.0')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('carries the offer and terminal count on the published relay status', () => {
|
||||
expect(relayServerStatus(live, true)).toEqual({
|
||||
kind: 'relay',
|
||||
reason: 'relay_terminals_live',
|
||||
terminals: 3,
|
||||
offerMove: true
|
||||
})
|
||||
expect(relayServerStatus(live, false)).not.toHaveProperty('offerMove')
|
||||
expect(
|
||||
relayServerStatus(
|
||||
{ route: 'relay', reason: 'refused', detail: 'An automation runs here.' },
|
||||
false
|
||||
)
|
||||
).toEqual({ kind: 'relay', reason: 'refused', detail: 'An automation runs here.' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
/** When a connect that kept the relay offers to move the host now, restarting its terminals. */
|
||||
import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types'
|
||||
import type { HostServerOnConnectResult } from './ssh-host-server-on-connect'
|
||||
|
||||
type RelayDecision = Extract<HostServerOnConnectResult, { route: 'relay' }>
|
||||
|
||||
/** Only live terminals: an unverifiable census can't be fixed by stopping them from here. */
|
||||
export function canOfferManagedServerMove(decision: Pick<RelayDecision, 'reason'>): boolean {
|
||||
return decision.reason === 'relay_terminals_live'
|
||||
}
|
||||
|
||||
/** The toast shows once per host per app version; the SSH Hosts action stays regardless. */
|
||||
export function shouldToastManagedServerMove(
|
||||
target: Pick<SshTarget, 'managedServerMoveOffered'>,
|
||||
decision: Pick<RelayDecision, 'reason'>,
|
||||
appVersion: string
|
||||
): boolean {
|
||||
return (
|
||||
canOfferManagedServerMove(decision) &&
|
||||
target.managedServerMoveOffered?.appVersion !== appVersion
|
||||
)
|
||||
}
|
||||
|
||||
export function relayServerStatus(
|
||||
decision: RelayDecision,
|
||||
offerMove: boolean
|
||||
): Extract<SshManagedServerStatus, { kind: 'relay' }> {
|
||||
return {
|
||||
kind: 'relay',
|
||||
reason: decision.reason,
|
||||
...(decision.detail ? { detail: decision.detail } : {}),
|
||||
...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}),
|
||||
...(offerMove ? { offerMove: true } : {})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
SshTargetUpdateInput,
|
||||
SshTerminateSessionsResult
|
||||
} from '../../shared/ssh-types'
|
||||
import type { SshManagedServerMoveResult } from '../../shared/ssh-managed-server-move'
|
||||
import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types'
|
||||
|
||||
export type SshApi = {
|
||||
@@ -27,6 +28,8 @@ export type SshApi = {
|
||||
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
|
||||
disconnect: (args: { targetId: string }) => Promise<void>
|
||||
terminateSessions: (args: { targetId: string }) => Promise<SshTerminateSessionsResult>
|
||||
/** Desktop-only: stops the host's relay terminals and moves it to a managed Orca server. */
|
||||
moveToManagedServer?: (args: { targetId: string }) => Promise<SshManagedServerMoveResult>
|
||||
resetRelay: (args: { targetId: string }) => Promise<void>
|
||||
getState: (args: { targetId: string }) => Promise<SshConnectionState | null>
|
||||
needsPassphrasePrompt: (args: { targetId: string }) => Promise<boolean>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
admitSshDetectedPorts
|
||||
} from '../../shared/ssh-retained-payload-admission'
|
||||
import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types'
|
||||
import type { SshManagedServerMoveResult } from '../../shared/ssh-managed-server-move'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const sshApi = {
|
||||
@@ -55,6 +56,9 @@ export const sshApi = {
|
||||
terminateSessions: (args: { targetId: string }): Promise<SshTerminateSessionsResult> =>
|
||||
ipcRenderer.invoke('ssh:terminateSessions', args),
|
||||
|
||||
moveToManagedServer: (args: { targetId: string }): Promise<SshManagedServerMoveResult> =>
|
||||
ipcRenderer.invoke('ssh:moveToManagedServer', args),
|
||||
|
||||
resetRelay: (args: { targetId: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('ssh:resetRelay', args),
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SshManagedServerMoveDialog } from './SshManagedServerMoveDialog'
|
||||
|
||||
vi.mock('../ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
const moveToManagedServer = vi.fn()
|
||||
const roots: Root[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
moveToManagedServer.mockReset()
|
||||
Object.assign(window, { api: { ssh: { moveToManagedServer } } })
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
function render(onClose = vi.fn()): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
act(() =>
|
||||
root.render(
|
||||
<SshManagedServerMoveDialog
|
||||
open
|
||||
targetId="ssh-1"
|
||||
host="Box"
|
||||
terminals={3}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
)
|
||||
return container
|
||||
}
|
||||
|
||||
function button(container: HTMLElement, label: string): HTMLButtonElement {
|
||||
const found = Array.from(container.querySelectorAll('button')).find(
|
||||
(entry) => entry.textContent === label
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`No "${label}" button`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe('SshManagedServerMoveDialog', () => {
|
||||
it('explains the reliability gain and that the open terminals restart', () => {
|
||||
const container = render()
|
||||
expect(container.textContent).toContain('Move to managed server')
|
||||
expect(container.textContent).toContain(
|
||||
'Move Box to a managed Orca server for more reliable connections. Its 3 open terminals will restart.'
|
||||
)
|
||||
expect(button(container, 'Not now')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('"Not now" closes without moving', () => {
|
||||
const onClose = vi.fn()
|
||||
const container = render(onClose)
|
||||
act(() => button(container, 'Not now').click())
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(moveToManagedServer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes after the host moved', async () => {
|
||||
moveToManagedServer.mockResolvedValue({ outcome: 'moved', environmentId: 'env-1' })
|
||||
const onClose = vi.fn()
|
||||
const container = render(onClose)
|
||||
await act(async () => button(container, 'Move').click())
|
||||
expect(moveToManagedServer).toHaveBeenCalledWith({ targetId: 'ssh-1' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows why a move was refused and keeps the dialog open', async () => {
|
||||
moveToManagedServer.mockResolvedValue({
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
terminals: 2
|
||||
})
|
||||
const onClose = vi.fn()
|
||||
const container = render(onClose)
|
||||
await act(async () => button(container, 'Move').click())
|
||||
expect(container.textContent).toContain(
|
||||
'Not moved: Orca couldn’t confirm that 2 terminals on Box stopped.'
|
||||
)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(button(container, 'Close')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a move that failed outright', async () => {
|
||||
moveToManagedServer.mockRejectedValue(new Error('relay offline'))
|
||||
const container = render()
|
||||
await act(async () => button(container, 'Move').click())
|
||||
expect(container.textContent).toContain('Could not move Box: relay offline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
describeManagedServerMove,
|
||||
managedServerMoveErrorText,
|
||||
managedServerMoveOfferText
|
||||
} from '@/ssh/ssh-managed-server-move'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
|
||||
type SshManagedServerMoveDialogProps = {
|
||||
open: boolean
|
||||
targetId: string
|
||||
host: string
|
||||
terminals: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Confirms moving a host whose live relay terminals keep it off its managed server. */
|
||||
export function SshManagedServerMoveDialog({
|
||||
open,
|
||||
targetId,
|
||||
host,
|
||||
terminals,
|
||||
onClose
|
||||
}: SshManagedServerMoveDialogProps): React.JSX.Element {
|
||||
const mountedRef = useMountedRef()
|
||||
const [running, setRunning] = useState(false)
|
||||
const [refusal, setRefusal] = useState<string | null>(null)
|
||||
|
||||
const close = (): void => {
|
||||
setRefusal(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const move = async (): Promise<void> => {
|
||||
const moveToManagedServer = window.api.ssh.moveToManagedServer
|
||||
if (!moveToManagedServer) {
|
||||
return
|
||||
}
|
||||
setRunning(true)
|
||||
setRefusal(null)
|
||||
let message: string | null
|
||||
try {
|
||||
const report = describeManagedServerMove(host, await moveToManagedServer({ targetId }))
|
||||
message = report.level === 'success' ? null : report.message
|
||||
} catch (error) {
|
||||
message = managedServerMoveErrorText(host, error)
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setRunning(false)
|
||||
if (message) {
|
||||
setRefusal(message)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && !running && close()}>
|
||||
<DialogContent showCloseButton={!running}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.ssh.managedServerMove.title', 'Move to managed server')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{managedServerMoveOfferText(host, terminals)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{running ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{translate(
|
||||
'auto.ssh.managedServerMove.running',
|
||||
'Moving {{host}} to a managed Orca server…',
|
||||
{ host }
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{refusal ? <p className="text-sm text-destructive">{refusal}</p> : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" disabled={running} onClick={close}>
|
||||
{refusal
|
||||
? translate('auto.ssh.managedServerMove.close', 'Close')
|
||||
: translate('auto.ssh.managedServerMove.notNow', 'Not now')}
|
||||
</Button>
|
||||
{refusal ? null : (
|
||||
<Button type="button" disabled={running} onClick={() => void move()}>
|
||||
{translate('auto.ssh.managedServerMove.confirm', 'Move')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { ArrowRightLeft } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { canMoveSshHostToManagedServer } from '@/ssh/ssh-managed-server-move'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '../ui/button'
|
||||
import { SshManagedServerMoveDialog } from './SshManagedServerMoveDialog'
|
||||
import { sshHostServerStatusLine } from './ssh-host-server-status-copy'
|
||||
import { SshHostChangedActions } from './SshHostChangedActions'
|
||||
|
||||
@@ -13,23 +19,44 @@ export function SshTargetServerStatus({
|
||||
onChanged: () => void
|
||||
}): React.JSX.Element | null {
|
||||
const state = useAppStore((s) => s.sshConnectionStates.get(target.id))
|
||||
const [moveOpen, setMoveOpen] = useState(false)
|
||||
const line = sshHostServerStatusLine(target, state)
|
||||
if (!line) {
|
||||
return null
|
||||
}
|
||||
const status = state?.managedServer
|
||||
const terminals = status?.kind === 'relay' ? (status.terminals ?? 0) : 0
|
||||
const canMove = line.action === 'move' && canMoveSshHostToManagedServer()
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p
|
||||
className={cn(
|
||||
'px-1 text-xs',
|
||||
line.tone === 'muted' && 'text-muted-foreground',
|
||||
line.tone === 'warning' && 'text-status-warning',
|
||||
line.tone === 'destructive' && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{line.text}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
'px-1 text-xs',
|
||||
line.tone === 'muted' && 'text-muted-foreground',
|
||||
line.tone === 'warning' && 'text-status-warning',
|
||||
line.tone === 'destructive' && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{line.text}
|
||||
</p>
|
||||
{canMove ? (
|
||||
<Button type="button" size="xs" variant="ghost" onClick={() => setMoveOpen(true)}>
|
||||
<ArrowRightLeft />
|
||||
{translate('auto.ssh.managedServerMove.title', 'Move to managed server')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<SshHostChangedActions target={target} onChanged={onChanged} />
|
||||
{canMove ? (
|
||||
<SshManagedServerMoveDialog
|
||||
open={moveOpen}
|
||||
targetId={target.id}
|
||||
host={target.label}
|
||||
terminals={terminals}
|
||||
onClose={() => setMoveOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,22 @@ describe('SSH host server status line', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('offers the move only while live relay terminals keep the host on the relay', () => {
|
||||
expect(
|
||||
sshHostServerStatusLine(plain, {
|
||||
managedServer: { kind: 'relay', reason: 'relay_terminals_live', terminals: 3 }
|
||||
})
|
||||
).toMatchObject({ action: 'move', text: expect.stringContaining('3 open terminals') })
|
||||
expect(
|
||||
sshHostServerStatusLine(plain, {
|
||||
managedServer: { kind: 'relay', reason: 'relay_terminals_unverifiable' }
|
||||
})
|
||||
).not.toHaveProperty('action')
|
||||
expect(
|
||||
sshHostServerStatusLine(plain, { managedServer: { kind: 'managed', environmentId: 'e' } })
|
||||
).not.toHaveProperty('action')
|
||||
})
|
||||
|
||||
it('keeps durable reasons visible without a live state', () => {
|
||||
expect(sshHostServerStatusLine({ orcadFence: { environmentId: 'e' } }, undefined)?.text).toBe(
|
||||
'Runs a managed Orca server'
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type SshHostServerStatusLine = { text: string; tone: 'muted' | 'warning' | 'destructive' }
|
||||
export type SshHostServerStatusLine = {
|
||||
text: string
|
||||
tone: 'muted' | 'warning' | 'destructive'
|
||||
/** Offers "Move to managed server", which restarts the live relay terminals. */
|
||||
action?: 'move'
|
||||
}
|
||||
|
||||
export function sshHostServerStatusLine(
|
||||
target: Pick<SshTarget, 'orcadFence' | 'managedServerUnavailable'>,
|
||||
@@ -93,7 +98,8 @@ function relayLine(
|
||||
'auto.components.settings.sshHostServer.terminalsLive',
|
||||
'Runs the relay until its {{count}} open terminals are closed, then moves to a managed server.',
|
||||
{ count: status.terminals ?? 0 }
|
||||
)
|
||||
),
|
||||
action: 'move'
|
||||
}
|
||||
case 'relay_terminals_unverifiable':
|
||||
return {
|
||||
|
||||
@@ -157,7 +157,7 @@ export function registerDirectSshStateIpcBridge(
|
||||
const store = useAppStore.getState()
|
||||
const previousManagedServer = store.sshConnectionStates.get(targetId)?.managedServer
|
||||
store.setSshConnectionState(targetId, state)
|
||||
applySshManagedServerTransition(previousManagedServer, state.managedServer)
|
||||
applySshManagedServerTransition(targetId, previousManagedServer, state.managedServer)
|
||||
|
||||
if (canConnectSshStatus(state.status)) {
|
||||
routedAuthorityByTarget.delete(targetId)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
loading: vi.fn(() => 'progress'),
|
||||
dismiss: vi.fn()
|
||||
}),
|
||||
fetchReposForAllHosts: vi.fn(async () => undefined)
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: mocks.toast }))
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
fetchReposForAllHosts: mocks.fetchReposForAllHosts,
|
||||
sshTargetLabels: new Map([['ssh-1', 'Box']])
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
const { applySshManagedServerTransition } = await import('./ssh-managed-server-state-effects')
|
||||
|
||||
type Status = SshConnectionState['managedServer']
|
||||
type ToastOptions = {
|
||||
action: { label: string; onClick: () => void }
|
||||
cancel: { label: string; onClick: () => void }
|
||||
}
|
||||
|
||||
const offer: Status = {
|
||||
kind: 'relay',
|
||||
reason: 'relay_terminals_live',
|
||||
terminals: 3,
|
||||
offerMove: true
|
||||
}
|
||||
const moveToManagedServer = vi.fn()
|
||||
|
||||
function lastToastOptions(): ToastOptions {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the offer toast always passes options shaped like sonner's ExternalToast.
|
||||
return mocks.toast.mock.calls.at(-1)?.[1] as ToastOptions
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.assign(window, { api: { ssh: { moveToManagedServer } } })
|
||||
})
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
describe('the one-time move offer toast', () => {
|
||||
it('names the host and the terminals that will restart', () => {
|
||||
applySshManagedServerTransition('ssh-1', undefined, offer)
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toast.mock.calls[0][0]).toBe(
|
||||
'Move Box to a managed Orca server for more reliable connections. Its 3 open terminals will restart.'
|
||||
)
|
||||
expect(lastToastOptions().action.label).toBe('Move')
|
||||
expect(lastToastOptions().cancel.label).toBe('Not now')
|
||||
})
|
||||
|
||||
it('shows only for a newly marked offer, never for a plain live-terminals status', () => {
|
||||
applySshManagedServerTransition('ssh-1', offer, offer)
|
||||
applySshManagedServerTransition('ssh-1', undefined, {
|
||||
kind: 'relay',
|
||||
reason: 'relay_terminals_live',
|
||||
terminals: 3
|
||||
})
|
||||
expect(mocks.toast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays silent where the move is not available', () => {
|
||||
Object.assign(window, { api: { ssh: {} } })
|
||||
applySshManagedServerTransition('ssh-1', undefined, offer)
|
||||
expect(mocks.toast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Not now" only closes the toast; "Move" runs the move and reports it', async () => {
|
||||
applySshManagedServerTransition('ssh-1', undefined, offer)
|
||||
lastToastOptions().cancel.onClick()
|
||||
expect(moveToManagedServer).not.toHaveBeenCalled()
|
||||
|
||||
moveToManagedServer.mockResolvedValueOnce({ outcome: 'moved', environmentId: 'env-1' })
|
||||
lastToastOptions().action.onClick()
|
||||
await vi.waitFor(() => expect(mocks.toast.success).toHaveBeenCalled())
|
||||
expect(moveToManagedServer).toHaveBeenCalledWith({ targetId: 'ssh-1' })
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith('Box now runs a managed Orca server.', {
|
||||
id: 'progress'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a refusal from the move', async () => {
|
||||
applySshManagedServerTransition('ssh-1', undefined, offer)
|
||||
moveToManagedServer.mockResolvedValueOnce({
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
terminals: 1
|
||||
})
|
||||
lastToastOptions().action.onClick()
|
||||
await vi.waitFor(() => expect(mocks.toast.error).toHaveBeenCalled())
|
||||
expect(mocks.toast.error.mock.calls[0][0]).toContain('couldn’t confirm that 1 terminals')
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,19 @@
|
||||
import { toast } from 'sonner'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
canMoveSshHostToManagedServer,
|
||||
managedServerMoveOfferText,
|
||||
moveSshHostFromToast
|
||||
} from '@/ssh/ssh-managed-server-move'
|
||||
import { useAppStore } from '../../store'
|
||||
|
||||
type ManagedServerStatus = SshConnectionState['managedServer']
|
||||
|
||||
export function applySshManagedServerTransition(
|
||||
previous: SshConnectionState['managedServer'],
|
||||
next: SshConnectionState['managedServer']
|
||||
targetId: string,
|
||||
previous: ManagedServerStatus,
|
||||
next: ManagedServerStatus
|
||||
): void {
|
||||
if (
|
||||
next?.kind === 'managed' &&
|
||||
@@ -16,6 +24,10 @@ export function applySshManagedServerTransition(
|
||||
void useAppStore.getState().fetchReposForAllHosts()
|
||||
return
|
||||
}
|
||||
if (isNewMoveOffer(previous, next) && canMoveSshHostToManagedServer()) {
|
||||
offerManagedServerMove(targetId, next.terminals ?? 0)
|
||||
return
|
||||
}
|
||||
if (
|
||||
next?.kind === 'relay' &&
|
||||
next.reason === 'refused' &&
|
||||
@@ -34,3 +46,29 @@ export function applySshManagedServerTransition(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Main marks only the first live-terminals stop per host per app version with `offerMove`. */
|
||||
function isNewMoveOffer(
|
||||
previous: ManagedServerStatus,
|
||||
next: ManagedServerStatus
|
||||
): next is Extract<NonNullable<ManagedServerStatus>, { kind: 'relay' }> {
|
||||
return (
|
||||
next?.kind === 'relay' &&
|
||||
next.offerMove === true &&
|
||||
!(previous?.kind === 'relay' && previous.offerMove)
|
||||
)
|
||||
}
|
||||
|
||||
function offerManagedServerMove(targetId: string, terminals: number): void {
|
||||
const host = useAppStore.getState().sshTargetLabels.get(targetId) ?? targetId
|
||||
toast(managedServerMoveOfferText(host, terminals), {
|
||||
id: `ssh-managed-server-move:${targetId}`,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: translate('auto.ssh.managedServerMove.confirm', 'Move'),
|
||||
onClick: () => void moveSshHostFromToast(targetId, host)
|
||||
},
|
||||
// "Not now" only closes the toast; the SSH Hosts status line keeps the action.
|
||||
cancel: { label: translate('auto.ssh.managedServerMove.notNow', 'Not now'), onClick: () => {} }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17658,6 +17658,19 @@
|
||||
"retry": "Retry",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting…"
|
||||
},
|
||||
"managedServerMove": {
|
||||
"offer": "Move {{host}} to a managed Orca server for more reliable connections. Its {{count}} open terminals will restart.",
|
||||
"moved": "{{host}} now runs a managed Orca server.",
|
||||
"refusedLive": "Not moved: {{count}} terminals on {{host}} are still running.",
|
||||
"refusedUnverifiable": "Not moved: Orca couldn’t confirm that {{count}} terminals on {{host}} stopped.",
|
||||
"stayed": "{{host}} still runs the relay. SSH Hosts in Settings shows why.",
|
||||
"failed": "Could not move {{host}}: {{reason}}",
|
||||
"running": "Moving {{host}} to a managed Orca server…",
|
||||
"title": "Move to managed server",
|
||||
"confirm": "Move",
|
||||
"notNow": "Not now",
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Moving an SSH host to its managed Orca server on request: the offer's words and the run. */
|
||||
import { toast } from 'sonner'
|
||||
import type { SshManagedServerMoveResult } from '../../../shared/ssh-managed-server-move'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function canMoveSshHostToManagedServer(): boolean {
|
||||
return typeof window.api.ssh.moveToManagedServer === 'function'
|
||||
}
|
||||
|
||||
export function managedServerMoveOfferText(host: string, terminals: number): string {
|
||||
return translate(
|
||||
'auto.ssh.managedServerMove.offer',
|
||||
'Move {{host}} to a managed Orca server for more reliable connections. Its {{count}} open terminals will restart.',
|
||||
{ host, count: terminals }
|
||||
)
|
||||
}
|
||||
|
||||
export function describeManagedServerMove(
|
||||
host: string,
|
||||
result: SshManagedServerMoveResult
|
||||
): { level: 'success' | 'error'; message: string } {
|
||||
switch (result.outcome) {
|
||||
case 'moved':
|
||||
return {
|
||||
level: 'success',
|
||||
message: translate(
|
||||
'auto.ssh.managedServerMove.moved',
|
||||
'{{host}} now runs a managed Orca server.',
|
||||
{ host }
|
||||
)
|
||||
}
|
||||
case 'refused':
|
||||
return {
|
||||
level: 'error',
|
||||
message:
|
||||
result.verdict === 'live'
|
||||
? translate(
|
||||
'auto.ssh.managedServerMove.refusedLive',
|
||||
'Not moved: {{count}} terminals on {{host}} are still running.',
|
||||
{ host, count: result.terminals }
|
||||
)
|
||||
: translate(
|
||||
'auto.ssh.managedServerMove.refusedUnverifiable',
|
||||
'Not moved: Orca couldn’t confirm that {{count}} terminals on {{host}} stopped.',
|
||||
{ host, count: result.terminals }
|
||||
)
|
||||
}
|
||||
case 'stayed':
|
||||
return {
|
||||
level: 'error',
|
||||
message: translate(
|
||||
'auto.ssh.managedServerMove.stayed',
|
||||
'{{host}} still runs the relay. SSH Hosts in Settings shows why.',
|
||||
{ host }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function managedServerMoveErrorText(host: string, error: unknown): string {
|
||||
return translate('auto.ssh.managedServerMove.failed', 'Could not move {{host}}: {{reason}}', {
|
||||
host,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
/** The toast's "Move" path: progress and outcome are reported as toasts. */
|
||||
export async function moveSshHostFromToast(targetId: string, host: string): Promise<void> {
|
||||
const move = window.api.ssh.moveToManagedServer
|
||||
if (!move) {
|
||||
return
|
||||
}
|
||||
const progressId = toast.loading(
|
||||
translate('auto.ssh.managedServerMove.running', 'Moving {{host}} to a managed Orca server…', {
|
||||
host
|
||||
})
|
||||
)
|
||||
try {
|
||||
const report = describeManagedServerMove(host, await move({ targetId }))
|
||||
toast[report.level](report.message, { id: progressId })
|
||||
} catch (error) {
|
||||
toast.error(managedServerMoveErrorText(host, error), { id: progressId })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Outcome of moving an SSH host to its managed Orca server when the user asks to. */
|
||||
export type SshManagedServerMoveResult =
|
||||
| { outcome: 'moved'; environmentId: string }
|
||||
/** Its relay terminals weren't proven exited after the stop, so nothing converted. */
|
||||
| { outcome: 'refused'; verdict: 'live' | 'unverifiable'; terminals: number }
|
||||
/** The terminals stopped but the connect kept the relay; the host's status line says why. */
|
||||
| { outcome: 'stayed' }
|
||||
@@ -52,6 +52,20 @@ describe('SSH retained payload admission', () => {
|
||||
expect(malformed).not.toHaveProperty('plainSsh')
|
||||
})
|
||||
|
||||
it('admits only a literal move offer on a relay server status', () => {
|
||||
const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 }
|
||||
const relay = { kind: 'relay', reason: 'relay_terminals_live', terminals: 2 }
|
||||
|
||||
expect(
|
||||
admitSshConnectionState({ ...state, managedServer: { ...relay, offerMove: true } }, 'ssh-a')
|
||||
?.managedServer
|
||||
).toEqual({ ...relay, offerMove: true })
|
||||
expect(
|
||||
admitSshConnectionState({ ...state, managedServer: { ...relay, offerMove: 'yes' } }, 'ssh-a')
|
||||
?.managedServer
|
||||
).toEqual(relay)
|
||||
})
|
||||
|
||||
it('rejects partial and malformed provider authority', () => {
|
||||
const state = {
|
||||
targetId: 'ssh-a',
|
||||
|
||||
@@ -132,7 +132,8 @@ function admitSshManagedServerStatus(value: unknown): { managedServer?: SshManag
|
||||
...(detail
|
||||
? { detail: clampUtf8TextPrefix(detail, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) }
|
||||
: {}),
|
||||
...(isNonNegativeSafeInteger(terminals) ? { terminals } : {})
|
||||
...(isNonNegativeSafeInteger(terminals) ? { terminals } : {}),
|
||||
...('offerMove' in value && value.offerMove === true ? { offerMove: true } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -97,6 +97,8 @@ export type SshTarget = {
|
||||
*/
|
||||
/** Main-owned: managed orcad can't run on this host; retried once this app version changes. */
|
||||
managedServerUnavailable?: { reason: string; appVersion: string }
|
||||
/** Main-owned: the one-time "move now" offer was shown under this app version. */
|
||||
managedServerMoveOffered?: { appVersion: string }
|
||||
orcadFence?: {
|
||||
environmentId: string
|
||||
/** An older build changed the retained source rows: the host stays on the relay until moved again. */
|
||||
@@ -107,7 +109,12 @@ export type SshTarget = {
|
||||
/** Renderer-authored target fields; registration generations are allocated and owned by main. */
|
||||
export type SshTargetCreateInput = Omit<
|
||||
SshTarget,
|
||||
'id' | 'generation' | 'orcadProvisioning' | 'orcadFence' | 'managedServerUnavailable'
|
||||
| 'id'
|
||||
| 'generation'
|
||||
| 'orcadProvisioning'
|
||||
| 'orcadFence'
|
||||
| 'managedServerUnavailable'
|
||||
| 'managedServerMoveOffered'
|
||||
>
|
||||
export type SshTargetUpdateInput = Partial<SshTargetCreateInput>
|
||||
|
||||
@@ -274,6 +281,8 @@ export type SshManagedServerStatus =
|
||||
detail?: string
|
||||
/** Relay terminals still running, when that is what keeps the host on the relay. */
|
||||
terminals?: number
|
||||
/** Show the one-time offer to move now, restarting those terminals. */
|
||||
offerMove?: boolean
|
||||
}
|
||||
|
||||
/** Plain SSH terminals and SFTP browsing only; `reason` is the ladder's classified cause. */
|
||||
|
||||
Reference in New Issue
Block a user