Fix SSH relay persistence and reset controls (#2274)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
993d0ae599
commit
c3bfc796af
|
|
@ -8,6 +8,7 @@ const {
|
|||
mockSshStore,
|
||||
mockConnectionManager,
|
||||
mockDeployAndLaunchRelay,
|
||||
mockForceStopRelayForTarget,
|
||||
mockMux,
|
||||
mockPtyProvider,
|
||||
mockFsProvider,
|
||||
|
|
@ -31,6 +32,7 @@ const {
|
|||
disconnectAll: vi.fn()
|
||||
},
|
||||
mockDeployAndLaunchRelay: vi.fn(),
|
||||
mockForceStopRelayForTarget: vi.fn(),
|
||||
mockMux: {
|
||||
dispose: vi.fn(),
|
||||
isDisposed: vi.fn().mockReturnValue(false),
|
||||
|
|
@ -87,6 +89,10 @@ vi.mock('../ssh/ssh-relay-deploy', () => ({
|
|||
deployAndLaunchRelay: mockDeployAndLaunchRelay
|
||||
}))
|
||||
|
||||
vi.mock('../ssh/ssh-relay-reset', () => ({
|
||||
forceStopRelayForTarget: mockForceStopRelayForTarget
|
||||
}))
|
||||
|
||||
vi.mock('../ssh/ssh-channel-multiplexer', () => ({
|
||||
SshChannelMultiplexer: class MockSshChannelMultiplexer {
|
||||
constructor() {
|
||||
|
|
@ -192,6 +198,7 @@ describe('SSH IPC handlers', () => {
|
|||
mockConnectionManager.getConnection.mockReset()
|
||||
mockConnectionManager.getState.mockReset()
|
||||
mockConnectionManager.disconnectAll.mockReset()
|
||||
mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined)
|
||||
|
||||
mockDeployAndLaunchRelay.mockReset().mockResolvedValue({
|
||||
transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() },
|
||||
|
|
@ -205,6 +212,11 @@ describe('SSH IPC handlers', () => {
|
|||
mockPtyProvider.onExit.mockReset()
|
||||
mockPtyProvider.onReplay.mockReset()
|
||||
mockPtyProvider.shutdown.mockReset()
|
||||
mockPortForwardManager.addForward.mockReset()
|
||||
mockPortForwardManager.removeForward.mockReset()
|
||||
mockPortForwardManager.listForwards.mockReset().mockReturnValue([])
|
||||
mockPortForwardManager.removeAllForwards.mockReset()
|
||||
mockPortForwardManager.dispose.mockReset()
|
||||
vi.mocked(getSshPtyProvider).mockReset()
|
||||
vi.mocked(getPtyIdsForConnection).mockReset().mockReturnValue([])
|
||||
|
||||
|
|
@ -221,6 +233,7 @@ describe('SSH IPC handlers', () => {
|
|||
expect(channels).toContain('ssh:connect')
|
||||
expect(channels).toContain('ssh:disconnect')
|
||||
expect(channels).toContain('ssh:terminateSessions')
|
||||
expect(channels).toContain('ssh:resetRelay')
|
||||
expect(channels).toContain('ssh:getState')
|
||||
expect(channels).toContain('ssh:testConnection')
|
||||
})
|
||||
|
|
@ -483,6 +496,206 @@ describe('SSH IPC handlers', () => {
|
|||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('ssh:resetRelay force-stops the remote relay and expires tracked leases', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(undefined)
|
||||
mockStore.getSshRemotePtyLeases.mockReturnValue([
|
||||
{ targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' },
|
||||
{ targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' }
|
||||
])
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-2'])
|
||||
|
||||
await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
expect(mockConnectionManager.connect).toHaveBeenCalledWith(target)
|
||||
expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1')
|
||||
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired')
|
||||
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'pty-expired',
|
||||
'expired'
|
||||
)
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('ssh:resetRelay waits for an in-flight connect before tearing down the session', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
let resolveConnect!: (value: unknown) => void
|
||||
const connectResult = new Promise((resolve) => {
|
||||
resolveConnect = resolve
|
||||
})
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockReturnValue(connectResult)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
const connectPromise = handlers.get('ssh:connect')!(null, {
|
||||
targetId: 'ssh-1'
|
||||
}) as Promise<unknown>
|
||||
await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1))
|
||||
|
||||
const resetPromise = handlers.get('ssh:resetRelay')!(null, {
|
||||
targetId: 'ssh-1'
|
||||
}) as Promise<void>
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled()
|
||||
expect(mockForceStopRelayForTarget).not.toHaveBeenCalled()
|
||||
|
||||
resolveConnect(conn)
|
||||
await connectPromise
|
||||
await resetPromise
|
||||
|
||||
expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)
|
||||
expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1')
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('ssh:connect waits for an in-flight reset before starting a new connection', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const resetConn = {}
|
||||
const connectConn = {}
|
||||
let resolveForceStop!: () => void
|
||||
const forceStopResult = new Promise<void>((resolve) => {
|
||||
resolveForceStop = resolve
|
||||
})
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.getConnection.mockReturnValue(resetConn)
|
||||
mockConnectionManager.connect.mockResolvedValue(connectConn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
mockForceStopRelayForTarget.mockReturnValue(forceStopResult)
|
||||
|
||||
const resetPromise = handlers.get('ssh:resetRelay')!(null, {
|
||||
targetId: 'ssh-1'
|
||||
}) as Promise<void>
|
||||
const connectPromise = handlers.get('ssh:connect')!(null, {
|
||||
targetId: 'ssh-1'
|
||||
}) as Promise<unknown>
|
||||
|
||||
await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockConnectionManager.connect).not.toHaveBeenCalled()
|
||||
|
||||
resolveForceStop()
|
||||
await resetPromise
|
||||
await connectPromise
|
||||
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)
|
||||
expect(mockConnectionManager.connect).toHaveBeenCalledWith(target)
|
||||
})
|
||||
|
||||
it('ssh:resetRelay reuses duplicate in-flight resets for the same target', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
let resolveForceStop!: () => void
|
||||
let activeForceStops = 0
|
||||
let maxConcurrentForceStops = 0
|
||||
const forceStopResult = new Promise<void>((resolve) => {
|
||||
resolveForceStop = resolve
|
||||
})
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockForceStopRelayForTarget.mockImplementation(async () => {
|
||||
activeForceStops += 1
|
||||
maxConcurrentForceStops = Math.max(maxConcurrentForceStops, activeForceStops)
|
||||
await forceStopResult
|
||||
activeForceStops -= 1
|
||||
})
|
||||
|
||||
const firstReset = handlers.get('ssh:resetRelay')!(null, {
|
||||
targetId: 'ssh-1'
|
||||
}) as Promise<void>
|
||||
const secondReset = handlers.get('ssh:resetRelay')!(null, {
|
||||
targetId: 'ssh-1'
|
||||
}) as Promise<void>
|
||||
|
||||
expect(secondReset).toBe(firstReset)
|
||||
await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1))
|
||||
|
||||
resolveForceStop()
|
||||
await Promise.all([firstReset, secondReset])
|
||||
|
||||
expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1)
|
||||
expect(maxConcurrentForceStops).toBe(1)
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledTimes(1)
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('ssh:resetRelay expires active-session leases instead of marking them terminated', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
mockStore.markSshRemotePtyLeases.mockClear()
|
||||
mockStore.markSshRemotePtyLease.mockClear()
|
||||
mockStore.getSshRemotePtyLeases.mockReturnValue([
|
||||
{ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' }
|
||||
])
|
||||
|
||||
await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalledWith('ssh-1', 'terminated')
|
||||
expect(mockStore.markSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1', 'detached')
|
||||
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired')
|
||||
expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1')
|
||||
})
|
||||
|
||||
it('ssh:getState returns connection state', async () => {
|
||||
const state = {
|
||||
targetId: 'ssh-1',
|
||||
|
|
|
|||
|
|
@ -7,15 +7,17 @@ import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-co
|
|||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { SshRelaySession } from '../ssh/ssh-relay-session'
|
||||
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
|
||||
import type {
|
||||
SshTarget,
|
||||
SshConnectionState,
|
||||
SshConnectionStatus,
|
||||
DetectedPort,
|
||||
SavedPortForward
|
||||
import {
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
|
||||
type DetectedPort,
|
||||
type SavedPortForward,
|
||||
type SshTarget,
|
||||
type SshConnectionStatus,
|
||||
type SshConnectionState
|
||||
} from '../../shared/ssh-types'
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
|
||||
import { isAuthError } from '../ssh/ssh-connection-utils'
|
||||
import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset'
|
||||
import { isSshPtyNotFoundError } from '../providers/ssh-pty-provider'
|
||||
import { registerSshBrowseHandler } from './ssh-browse'
|
||||
import { requestCredential, registerCredentialHandler } from './ssh-passphrase'
|
||||
|
|
@ -42,7 +44,10 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number
|
|||
}
|
||||
// Why: cross-device sync should survive transient app closes, but an
|
||||
// unset value must not mean "keep remote PTYs forever" after disconnect.
|
||||
return target.remoteWorkspaceSyncGracePeriodSeconds ?? 300
|
||||
return (
|
||||
target.remoteWorkspaceSyncGracePeriodSeconds ??
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS
|
||||
)
|
||||
}
|
||||
|
||||
// Why: multiple renderer tabs for the same SSH target can fire ssh:connect
|
||||
|
|
@ -52,6 +57,10 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number
|
|||
// awaits the first rather than racing.
|
||||
const connectInFlight = new Map<string, Promise<SshConnectionState>>()
|
||||
|
||||
// Why: reset tears down and force-stops the relay, then disconnects SSH.
|
||||
// Publish that lifecycle so new connects and duplicate resets cannot race it.
|
||||
const resetRelayInFlight = new Map<string, Promise<void>>()
|
||||
|
||||
// Why: ssh:testConnection calls connect() then disconnect(), which fires
|
||||
// state-change events to the renderer. This causes worktree cards to briefly
|
||||
// flash "connected" then "disconnected". Suppressing broadcasts during tests
|
||||
|
|
@ -244,6 +253,7 @@ export function registerSshHandlers(
|
|||
'ssh:connect',
|
||||
'ssh:disconnect',
|
||||
'ssh:terminateSessions',
|
||||
'ssh:resetRelay',
|
||||
'ssh:getState',
|
||||
'ssh:needsPassphrasePrompt',
|
||||
'ssh:testConnection',
|
||||
|
|
@ -369,6 +379,11 @@ export function registerSshHandlers(
|
|||
// ── Connection lifecycle ───────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => {
|
||||
const reset = resetRelayInFlight.get(args.targetId)
|
||||
if (reset) {
|
||||
await reset
|
||||
}
|
||||
|
||||
// Why: serialize concurrent ssh:connect calls for the same target.
|
||||
// Multiple tabs can fire connect simultaneously; without this, they
|
||||
// interleave and the first session leaks.
|
||||
|
|
@ -674,6 +689,74 @@ export function registerSshHandlers(
|
|||
await connectionManager!.disconnect(args.targetId)
|
||||
})
|
||||
|
||||
async function doResetRelay(targetId: string, target: SshTarget): Promise<void> {
|
||||
const inFlightConnect = connectInFlight.get(targetId)
|
||||
if (inFlightConnect) {
|
||||
try {
|
||||
// Why: reset tears down activeSessions; doing that while doConnect is
|
||||
// still deploying can dispose the session doConnect is about to use.
|
||||
await inFlightConnect
|
||||
} catch {
|
||||
// The reset can still recover a stale remote relay after a failed connect.
|
||||
}
|
||||
}
|
||||
|
||||
const session = activeSessions.get(targetId)
|
||||
if (session) {
|
||||
await portForwardManager!.removeAllForwards(targetId)
|
||||
// Why: reset has its own stale-relay lease semantics below. dispose()
|
||||
// records clean PTY termination, which hides reset-affected leases.
|
||||
session.detach()
|
||||
activeSessions.delete(targetId)
|
||||
clearRelayLostBackoff(targetId)
|
||||
}
|
||||
|
||||
const existingConn = connectionManager!.getConnection(targetId)
|
||||
const conn = existingConn ?? (await connectionManager!.connect(target))
|
||||
try {
|
||||
await forceStopRelayForTarget(conn, targetId)
|
||||
} finally {
|
||||
const ptyIds = new Set(getPtyIdsForConnection(targetId))
|
||||
for (const lease of store.getSshRemotePtyLeases(targetId)) {
|
||||
if (lease.state !== 'terminated' && lease.state !== 'expired') {
|
||||
ptyIds.add(lease.ptyId)
|
||||
store.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
|
||||
}
|
||||
}
|
||||
// Why: reset force-kills the remote relay daemon, so every local PTY
|
||||
// handle owned by that relay is stale even if the reset command failed
|
||||
// after the remote process accepted SIGTERM.
|
||||
for (const ptyId of ptyIds) {
|
||||
clearProviderPtyState(ptyId)
|
||||
deletePtyOwnership(ptyId)
|
||||
}
|
||||
await connectionManager!.disconnect(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => {
|
||||
const existingReset = resetRelayInFlight.get(args.targetId)
|
||||
if (existingReset) {
|
||||
return existingReset
|
||||
}
|
||||
|
||||
const target = sshStore!.getTarget(args.targetId)
|
||||
if (!target) {
|
||||
throw new Error(`SSH target "${args.targetId}" not found`)
|
||||
}
|
||||
|
||||
let resetPromise: Promise<void>
|
||||
resetPromise = Promise.resolve()
|
||||
.then(() => doResetRelay(args.targetId, target))
|
||||
.finally(() => {
|
||||
if (resetRelayInFlight.get(args.targetId) === resetPromise) {
|
||||
resetRelayInFlight.delete(args.targetId)
|
||||
}
|
||||
})
|
||||
resetRelayInFlight.set(args.targetId, resetPromise)
|
||||
return resetPromise
|
||||
})
|
||||
|
||||
ipcMain.handle('ssh:getState', (_event, args: { targetId: string }) => {
|
||||
return getPublicSshState(args.targetId)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const PTY_FLOW_HIGH_WATERMARK = 100_000
|
|||
export const PTY_FLOW_LOW_WATERMARK = 5_000
|
||||
|
||||
/** Reconnection grace period (default, overridable by relay --grace-time). */
|
||||
export const DEFAULT_GRACE_TIME_MS = 5 * 60 * 1000 // 5 minutes
|
||||
export const DEFAULT_GRACE_TIME_MS = 3 * 60 * 60 * 1000 // 3 hours
|
||||
|
||||
// ── Relay error codes ───────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ vi.mock('./ssh-connection-utils', () => ({
|
|||
import { deployAndLaunchRelay } from './ssh-relay-deploy'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types'
|
||||
|
||||
function makeMockConnection(): SshConnection {
|
||||
return {
|
||||
|
|
@ -112,6 +113,44 @@ describe('deployAndLaunchRelay', () => {
|
|||
expect(progress).toContain('Starting relay...')
|
||||
})
|
||||
|
||||
it('defaults fresh relays to the three-hour SSH disconnect grace window', async () => {
|
||||
const conn = makeMockConnection()
|
||||
const mockExecCommand = vi.mocked(execCommand)
|
||||
mockExecCommand.mockResolvedValueOnce('Linux x86_64')
|
||||
mockExecCommand.mockResolvedValueOnce('/home/user')
|
||||
mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK')
|
||||
mockExecCommand.mockResolvedValueOnce('DEAD')
|
||||
mockExecCommand.mockResolvedValueOnce('READY')
|
||||
|
||||
await deployAndLaunchRelay(conn)
|
||||
|
||||
const launchCommand = vi
|
||||
.mocked(conn.exec)
|
||||
.mock.calls.map(([cmd]) => cmd as string)
|
||||
.find((cmd) => cmd.includes('--detached'))
|
||||
|
||||
expect(launchCommand).toContain(`--grace-time ${DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS}`)
|
||||
})
|
||||
|
||||
it('allows an unlimited SSH disconnect grace window', async () => {
|
||||
const conn = makeMockConnection()
|
||||
const mockExecCommand = vi.mocked(execCommand)
|
||||
mockExecCommand.mockResolvedValueOnce('Linux x86_64')
|
||||
mockExecCommand.mockResolvedValueOnce('/home/user')
|
||||
mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK')
|
||||
mockExecCommand.mockResolvedValueOnce('DEAD')
|
||||
mockExecCommand.mockResolvedValueOnce('READY')
|
||||
|
||||
await deployAndLaunchRelay(conn, undefined, 0, 'target-a')
|
||||
|
||||
const launchCommand = vi
|
||||
.mocked(conn.exec)
|
||||
.mock.calls.map(([cmd]) => cmd as string)
|
||||
.find((cmd) => cmd.includes('--detached'))
|
||||
|
||||
expect(launchCommand).toContain('--grace-time 0')
|
||||
})
|
||||
|
||||
it('uses a content-hashed versioned remote install directory', async () => {
|
||||
const conn = makeMockConnection()
|
||||
const mockExecCommand = vi.mocked(execCommand)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { join } from 'path'
|
|||
sequence and the GC's live-socket invariant. */
|
||||
import { existsSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol'
|
||||
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
|
||||
|
|
@ -25,6 +24,12 @@ import {
|
|||
gcOldRelayVersions
|
||||
} from './ssh-relay-versioned-install'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import { relaySocketNameForInstanceId } from './ssh-relay-instance-id'
|
||||
import {
|
||||
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
} from '../../shared/ssh-types'
|
||||
|
||||
export type RelayDeployResult = {
|
||||
transport: MultiplexerTransport
|
||||
|
|
@ -413,16 +418,20 @@ async function launchRelay(
|
|||
const nodePath = await resolveRemoteNodePath(conn)
|
||||
// Why: graceTimeSeconds originates from user-editable SshTarget config.
|
||||
// Clamping to integer prevents shell injection if the type ever loosened.
|
||||
const requestedGraceTime = Math.floor(graceTimeSeconds ?? 300)
|
||||
const graceTime = requestedGraceTime === 0 ? 0 : Math.max(60, Math.min(3600, requestedGraceTime))
|
||||
const requestedGraceTime = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)
|
||||
const graceTime =
|
||||
requestedGraceTime === 0
|
||||
? 0
|
||||
: Math.max(
|
||||
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requestedGraceTime)
|
||||
)
|
||||
const escapedDir = shellEscape(remoteDir)
|
||||
const escapedNode = shellEscape(nodePath)
|
||||
// Why: remoteRelayDir is shared by every Orca target for the same remote
|
||||
// account. Hashing the target ID into the socket name prevents one target
|
||||
// from attaching to another target's live relay.
|
||||
const sockName = relayInstanceId
|
||||
? `relay-${hashRelayInstanceId(relayInstanceId)}.sock`
|
||||
: 'relay.sock'
|
||||
const sockName = relaySocketNameForInstanceId(relayInstanceId)
|
||||
const sockFile = `${remoteDir}/${sockName}`
|
||||
|
||||
// Why: after an app restart a relay may still be running in its grace
|
||||
|
|
@ -534,7 +543,3 @@ async function launchRelay(
|
|||
)
|
||||
return waitForSentinel(channel)
|
||||
}
|
||||
|
||||
function hashRelayInstanceId(relayInstanceId: string): string {
|
||||
return createHash('sha256').update(relayInstanceId).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { createHash } from 'crypto'
|
||||
|
||||
export function hashRelayInstanceId(relayInstanceId: string): string {
|
||||
return createHash('sha256').update(relayInstanceId).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
export function relaySocketNameForInstanceId(relayInstanceId: string | undefined): string {
|
||||
return relayInstanceId ? `relay-${hashRelayInstanceId(relayInstanceId)}.sock` : 'relay.sock'
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({
|
||||
execCommand: vi.fn().mockResolvedValue('')
|
||||
}))
|
||||
|
||||
import { forceStopRelayForTarget } from './ssh-relay-reset'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { relaySocketNameForInstanceId } from './ssh-relay-instance-id'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
|
||||
describe('forceStopRelayForTarget', () => {
|
||||
it('targets only the relay socket for the requested SSH target', async () => {
|
||||
const conn = {} as SshConnection
|
||||
|
||||
await forceStopRelayForTarget(conn, 'ssh-1')
|
||||
|
||||
const command = vi.mocked(execCommand).mock.calls[0]?.[1] ?? ''
|
||||
expect(execCommand).toHaveBeenCalledWith(conn, expect.any(String))
|
||||
expect(command).toContain(`sock_name='${relaySocketNameForInstanceId('ssh-1')}'`)
|
||||
expect(command).toContain('lsof -t -U "$sock"')
|
||||
expect(command).toContain('pgrep -f "$sock_name"')
|
||||
expect(command).toContain('rm -f "$sock"')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { SshConnection } from './ssh-connection'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { relaySocketNameForInstanceId } from './ssh-relay-instance-id'
|
||||
|
||||
export async function forceStopRelayForTarget(
|
||||
conn: SshConnection,
|
||||
relayInstanceId: string
|
||||
): Promise<void> {
|
||||
const sockName = relaySocketNameForInstanceId(relayInstanceId)
|
||||
const escapedSockName = shellEscape(sockName)
|
||||
const script = [
|
||||
`sock_name=${escapedSockName}`,
|
||||
'base="${HOME}/.orca-remote"',
|
||||
'if [ -d "$base" ]; then',
|
||||
' for sock in "$base"/relay-*/"$sock_name" "$base"/"$sock_name"; do',
|
||||
' [ -S "$sock" ] || continue',
|
||||
' pid=""',
|
||||
' if command -v lsof >/dev/null 2>&1; then',
|
||||
' pid=$(lsof -t -U "$sock" 2>/dev/null | tr "\\n" " ")',
|
||||
' fi',
|
||||
' if [ -z "$pid" ] && command -v pgrep >/dev/null 2>&1; then',
|
||||
' pid=$(pgrep -f "$sock_name" 2>/dev/null | ' +
|
||||
'awk -v self="$$" -v parent="$PPID" \'$1 != self && $1 != parent\' | tr "\\n" " ")',
|
||||
' fi',
|
||||
' if [ -n "$pid" ]; then',
|
||||
' kill -TERM $pid 2>/dev/null || true',
|
||||
' sleep 0.2',
|
||||
' kill -KILL $pid 2>/dev/null || true',
|
||||
' fi',
|
||||
' rm -f "$sock"',
|
||||
' done',
|
||||
'fi'
|
||||
].join('\n')
|
||||
|
||||
await execCommand(conn, script)
|
||||
}
|
||||
|
|
@ -1787,6 +1787,7 @@ export type PreloadApi = {
|
|||
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
|
||||
disconnect: (args: { targetId: string }) => Promise<void>
|
||||
terminateSessions: (args: { targetId: string }) => Promise<void>
|
||||
resetRelay: (args: { targetId: string }) => Promise<void>
|
||||
getState: (args: { targetId: string }) => Promise<SshConnectionState | null>
|
||||
needsPassphrasePrompt: (args: { targetId: string }) => Promise<boolean>
|
||||
testConnection: (args: {
|
||||
|
|
|
|||
|
|
@ -2783,6 +2783,9 @@ const api = {
|
|||
terminateSessions: (args: { targetId: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('ssh:terminateSessions', args),
|
||||
|
||||
resetRelay: (args: { targetId: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('ssh:resetRelay', args),
|
||||
|
||||
getState: (args: { targetId: string }): Promise<SshConnectionState | null> =>
|
||||
ipcRenderer.invoke('ssh:getState', args),
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
|
||||
|
||||
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
|
||||
mockPtySpawn: vi.fn(),
|
||||
|
|
@ -332,9 +333,12 @@ describe('PtyHandler', () => {
|
|||
|
||||
it('grace timer waits full period even when no PTYs exist', () => {
|
||||
const onExpire = vi.fn()
|
||||
const defaultGraceMs = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
handler.startGraceTimer(onExpire)
|
||||
expect(onExpire).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
vi.advanceTimersByTime(defaultGraceMs - 1)
|
||||
expect(onExpire).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(onExpire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
|
@ -347,10 +351,13 @@ describe('PtyHandler', () => {
|
|||
await dispatcher.callRequest('pty.spawn', {})
|
||||
|
||||
const onExpire = vi.fn()
|
||||
const defaultGraceMs = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
handler.startGraceTimer(onExpire)
|
||||
expect(onExpire).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
vi.advanceTimersByTime(defaultGraceMs - 1)
|
||||
expect(onExpire).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(onExpire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
|
@ -363,12 +370,13 @@ describe('PtyHandler', () => {
|
|||
await dispatcher.callRequest('pty.spawn', {})
|
||||
|
||||
const onExpire = vi.fn()
|
||||
const defaultGraceMs = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
handler.startGraceTimer(onExpire)
|
||||
|
||||
vi.advanceTimersByTime(60_000)
|
||||
handler.cancelGraceTimer()
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
vi.advanceTimersByTime(defaultGraceMs)
|
||||
expect(onExpire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
listShellProfiles
|
||||
} from './pty-shell-utils'
|
||||
import { getRelayShellLaunchConfig } from './pty-shell-launch'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
|
||||
|
||||
// Why: node-pty is a native addon that may not be installed on the remote.
|
||||
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
|
||||
|
|
@ -81,7 +82,7 @@ function disposeManagedPty(managed: ManagedPty): void {
|
|||
/* swallow */
|
||||
}
|
||||
}
|
||||
const DEFAULT_GRACE_TIME_MS = 5 * 60 * 1000
|
||||
const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
export const REPLAY_BUFFER_MAX = 100 * 1024
|
||||
const ALLOWED_SIGNALS = new Set([
|
||||
'SIGINT',
|
||||
|
|
|
|||
|
|
@ -42,10 +42,11 @@ import {
|
|||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
AGENT_HOOK_REQUEST_REPLAY_METHOD
|
||||
} from '../shared/agent-hook-relay'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
|
||||
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
|
||||
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
|
||||
|
||||
const DEFAULT_GRACE_MS = 5 * 60 * 1000
|
||||
const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
const SOCK_NAME = 'relay.sock'
|
||||
const CONNECT_TIMEOUT_MS = 5_000
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
|
||||
type SshDestructiveActionDialogProps = {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
targetLabel?: string
|
||||
actionLabel: string
|
||||
busyLabel?: string
|
||||
isBusy?: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function SshDestructiveActionDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
targetLabel,
|
||||
actionLabel,
|
||||
busyLabel,
|
||||
isBusy = false,
|
||||
onOpenChange,
|
||||
onConfirm
|
||||
}: SshDestructiveActionDialogProps): React.JSX.Element {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isBusy && !nextOpen) {
|
||||
return
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{title}</DialogTitle>
|
||||
<DialogDescription className="text-xs">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{targetLabel ? (
|
||||
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
|
||||
<div className="break-all text-muted-foreground">{targetLabel}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isBusy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={isBusy} className="gap-1.5">
|
||||
{isBusy ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
{isBusy ? (busyLabel ?? actionLabel) : actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,20 +1,19 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, Upload } from 'lucide-react'
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import {
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
|
||||
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
type SshTarget
|
||||
} from '../../../../shared/ssh-types'
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { SshTargetCard } from './SshTargetCard'
|
||||
import { SshTargetDestructiveActions } from './SshTargetDestructiveActions'
|
||||
import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm'
|
||||
|
||||
export const SSH_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
|
|
@ -52,10 +51,6 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<EditingTarget>(EMPTY_FORM)
|
||||
const [testingIds, setTestingIds] = useState<Set<string>>(new Set())
|
||||
const [pendingRemove, setPendingRemove] = useState<{ id: string; label: string } | null>(null)
|
||||
const [pendingTerminate, setPendingTerminate] = useState<{ id: string; label: string } | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata)
|
||||
|
||||
|
|
@ -96,16 +91,22 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
}
|
||||
|
||||
const graceSeconds = parseInt(form.relayGracePeriodSeconds, 10)
|
||||
if (isNaN(graceSeconds) || graceSeconds < 60 || graceSeconds > 3600) {
|
||||
toast.error('Relay grace period must be between 60 and 3600 seconds')
|
||||
if (
|
||||
isNaN(graceSeconds) ||
|
||||
(graceSeconds !== 0 && graceSeconds < MIN_SSH_RELAY_GRACE_PERIOD_SECONDS) ||
|
||||
graceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
) {
|
||||
toast.error('Relay grace period must be 0 or between 60 and 10800 seconds')
|
||||
return
|
||||
}
|
||||
const remoteGraceSeconds = parseInt(form.remoteWorkspaceSyncGracePeriodSeconds, 10)
|
||||
if (
|
||||
form.remoteWorkspaceSyncEnabled &&
|
||||
(isNaN(remoteGraceSeconds) || remoteGraceSeconds < 0 || remoteGraceSeconds > 3600)
|
||||
(isNaN(remoteGraceSeconds) ||
|
||||
remoteGraceSeconds < 0 ||
|
||||
remoteGraceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS)
|
||||
) {
|
||||
toast.error('Synced relay grace period must be between 0 and 3600 seconds')
|
||||
toast.error('Synced relay grace period must be between 0 and 10800 seconds')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +120,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
remoteWorkspaceSyncEnabled: form.remoteWorkspaceSyncEnabled,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: form.remoteWorkspaceSyncEnabled
|
||||
? remoteGraceSeconds
|
||||
: 300,
|
||||
: DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
|
||||
...(form.identityFile.trim() ? { identityFile: form.identityFile.trim() } : {}),
|
||||
...(form.proxyCommand.trim() ? { proxyCommand: form.proxyCommand.trim() } : {}),
|
||||
...(form.jumpHost.trim() ? { jumpHost: form.jumpHost.trim() } : {})
|
||||
|
|
@ -181,10 +182,13 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
identityFile: target.identityFile ?? '',
|
||||
proxyCommand: target.proxyCommand ?? '',
|
||||
jumpHost: target.jumpHost ?? '',
|
||||
relayGracePeriodSeconds: String(target.relayGracePeriodSeconds ?? 300),
|
||||
relayGracePeriodSeconds: String(
|
||||
target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
),
|
||||
remoteWorkspaceSyncEnabled: target.remoteWorkspaceSyncEnabled === true,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: String(
|
||||
target.remoteWorkspaceSyncGracePeriodSeconds ?? 300
|
||||
target.remoteWorkspaceSyncGracePeriodSeconds ??
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS
|
||||
)
|
||||
})
|
||||
setShowForm(true)
|
||||
|
|
@ -215,6 +219,16 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
}
|
||||
}
|
||||
|
||||
const handleResetRelay = async (targetId: string): Promise<void> => {
|
||||
try {
|
||||
await window.api.ssh.resetRelay({ targetId })
|
||||
toast.success('Remote relay reset')
|
||||
await loadTargets()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to reset remote relay')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (targetId: string): Promise<void> => {
|
||||
setTestingIds((prev) => new Set(prev).add(targetId))
|
||||
try {
|
||||
|
|
@ -293,125 +307,55 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target list */}
|
||||
{targets.length === 0 && !showForm ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
|
||||
No SSH targets configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{targets.map((target) => (
|
||||
<SshTargetCard
|
||||
key={target.id}
|
||||
target={target}
|
||||
state={sshConnectionStates.get(target.id)}
|
||||
testing={testingIds.has(target.id)}
|
||||
onConnect={(id) => void handleConnect(id)}
|
||||
onDisconnect={(id) => void handleDisconnect(id)}
|
||||
onTerminateSessions={(id) => setPendingTerminate({ id, label: target.label })}
|
||||
onTest={(id) => void handleTest(id)}
|
||||
onEdit={handleEdit}
|
||||
onRemove={(id) => setPendingRemove({ id, label: target.label })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit form */}
|
||||
{showForm ? (
|
||||
<SshTargetForm
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFormChange={setForm}
|
||||
onSave={() => void handleSave()}
|
||||
onCancel={cancelForm}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Remove confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!pendingRemove}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingRemove(null)
|
||||
}
|
||||
}}
|
||||
<SshTargetDestructiveActions
|
||||
connectionStates={sshConnectionStates}
|
||||
onRemove={handleRemove}
|
||||
onResetRelay={handleResetRelay}
|
||||
onTerminateSessions={handleTerminateSessions}
|
||||
>
|
||||
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Remove SSH Target</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
This will remove the target and end any active remote terminals.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{({ busyActionForTarget, requestRemove, requestResetRelay, requestTerminateSessions }) => (
|
||||
<>
|
||||
{/* Target list */}
|
||||
{targets.length === 0 && !showForm ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
|
||||
No SSH targets configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{targets.map((target) => (
|
||||
<SshTargetCard
|
||||
key={target.id}
|
||||
target={target}
|
||||
state={sshConnectionStates.get(target.id)}
|
||||
testing={testingIds.has(target.id)}
|
||||
busyAction={busyActionForTarget(target.id)}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onTerminateSessions={(id) =>
|
||||
requestTerminateSessions({ id, label: target.label })
|
||||
}
|
||||
onResetRelay={(id) => requestResetRelay({ id, label: target.label })}
|
||||
onTest={handleTest}
|
||||
onEdit={handleEdit}
|
||||
onRemove={(id) => requestRemove({ id, label: target.label })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingRemove ? (
|
||||
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
|
||||
<div className="break-all text-muted-foreground">{pendingRemove.label}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPendingRemove(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (pendingRemove) {
|
||||
void handleRemove(pendingRemove.id)
|
||||
setPendingRemove(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* End remote terminals confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!pendingTerminate}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingTerminate(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">End Remote Terminals?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
This will stop active terminal sessions on this SSH target. Reconnecting will not
|
||||
restore them.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{pendingTerminate ? (
|
||||
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
|
||||
<div className="break-all text-muted-foreground">{pendingTerminate.label}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPendingTerminate(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (pendingTerminate) {
|
||||
void handleTerminateSessions(pendingTerminate.id)
|
||||
setPendingTerminate(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
End Terminals
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Add/Edit form */}
|
||||
{showForm ? (
|
||||
<SshTargetForm
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFormChange={setForm}
|
||||
onSave={() => void handleSave()}
|
||||
onCancel={cancelForm}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</SshTargetDestructiveActions>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Loader2,
|
||||
MonitorSmartphone,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Server,
|
||||
ServerOff,
|
||||
Trash2
|
||||
|
|
@ -15,6 +16,7 @@ import type {
|
|||
} from '../../../../shared/ssh-types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { isSshTargetConnecting, type SshTargetBusyAction } from './ssh-target-action-state'
|
||||
|
||||
// ── Shared status helpers ────────────────────────────────────────────
|
||||
|
||||
|
|
@ -46,20 +48,18 @@ export function statusColor(status: SshConnectionStatus): string {
|
|||
}
|
||||
}
|
||||
|
||||
export function isConnecting(status: SshConnectionStatus): boolean {
|
||||
return ['connecting', 'deploying-relay', 'reconnecting'].includes(status)
|
||||
}
|
||||
|
||||
// ── SshTargetCard ────────────────────────────────────────────────────
|
||||
|
||||
type SshTargetCardProps = {
|
||||
target: SshTarget
|
||||
state: SshConnectionState | undefined
|
||||
testing: boolean
|
||||
onConnect: (targetId: string) => void
|
||||
onDisconnect: (targetId: string) => void
|
||||
onTerminateSessions: (targetId: string) => void
|
||||
onTest: (targetId: string) => void
|
||||
busyAction?: SshTargetBusyAction
|
||||
onConnect: (targetId: string) => void | Promise<void>
|
||||
onDisconnect: (targetId: string) => void | Promise<void>
|
||||
onTerminateSessions: (targetId: string) => void | Promise<void>
|
||||
onResetRelay: (targetId: string) => void | Promise<void>
|
||||
onTest: (targetId: string) => void | Promise<void>
|
||||
onEdit: (target: SshTarget) => void
|
||||
onRemove: (targetId: string) => void
|
||||
}
|
||||
|
|
@ -68,17 +68,23 @@ export function SshTargetCard({
|
|||
target,
|
||||
state,
|
||||
testing,
|
||||
busyAction,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onTerminateSessions,
|
||||
onResetRelay,
|
||||
onTest,
|
||||
onEdit,
|
||||
onRemove
|
||||
}: SshTargetCardProps): React.JSX.Element {
|
||||
const status: SshConnectionStatus = state?.status ?? 'disconnected'
|
||||
const [actionInFlight, setActionInFlight] = useState<
|
||||
'connect' | 'disconnect' | 'terminate' | null
|
||||
'connect' | 'disconnect' | 'terminate' | 'reset' | null
|
||||
>(null)
|
||||
const hasActionInFlight = actionInFlight !== null || busyAction !== undefined
|
||||
const terminateInFlight = actionInFlight === 'terminate' || busyAction === 'terminate'
|
||||
const resetInFlight = actionInFlight === 'reset' || busyAction === 'reset'
|
||||
const removeInFlight = busyAction === 'remove'
|
||||
|
||||
const handleConnect = (): void => {
|
||||
if (actionInFlight) {
|
||||
|
|
@ -104,6 +110,14 @@ export function SshTargetCard({
|
|||
Promise.resolve(onTerminateSessions(target.id)).finally(() => setActionInFlight(null))
|
||||
}
|
||||
|
||||
const handleResetRelay = (): void => {
|
||||
if (actionInFlight) {
|
||||
return
|
||||
}
|
||||
setActionInFlight('reset')
|
||||
Promise.resolve(onResetRelay(target.id)).finally(() => setActionInFlight(null))
|
||||
}
|
||||
|
||||
const renderEndRemoteTerminalsButton = (): React.JSX.Element => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -112,12 +126,10 @@ export function SshTargetCard({
|
|||
size="icon"
|
||||
onClick={handleTerminateSessions}
|
||||
className="size-7 text-muted-foreground hover:text-red-400"
|
||||
disabled={actionInFlight !== null}
|
||||
aria-label={
|
||||
actionInFlight === 'terminate' ? 'Ending remote terminals' : 'End remote terminals'
|
||||
}
|
||||
disabled={hasActionInFlight}
|
||||
aria-label={terminateInFlight ? 'Ending remote terminals' : 'End remote terminals'}
|
||||
>
|
||||
{actionInFlight === 'terminate' ? (
|
||||
{terminateInFlight ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<CircleStop className="size-3" />
|
||||
|
|
@ -130,9 +142,34 @@ export function SshTargetCard({
|
|||
</Tooltip>
|
||||
)
|
||||
|
||||
const renderResetRelayButton = (): React.JSX.Element => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleResetRelay}
|
||||
className="size-7 text-muted-foreground hover:text-red-400"
|
||||
disabled={hasActionInFlight}
|
||||
aria-label={resetInFlight ? 'Resetting remote relay' : 'Reset remote relay'}
|
||||
>
|
||||
{resetInFlight ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Reset remote relay
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const renderSecondaryIconActions = (includeEndRemoteTerminals: boolean): React.JSX.Element => (
|
||||
<div className="flex items-center gap-1">
|
||||
{includeEndRemoteTerminals ? renderEndRemoteTerminalsButton() : null}
|
||||
{isSshTargetConnecting(status) ? null : renderResetRelayButton()}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -140,6 +177,7 @@ export function SshTargetCard({
|
|||
size="icon"
|
||||
onClick={() => onEdit(target)}
|
||||
className="size-7"
|
||||
disabled={hasActionInFlight}
|
||||
aria-label="Edit target"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
|
|
@ -156,9 +194,14 @@ export function SshTargetCard({
|
|||
size="icon"
|
||||
onClick={() => onRemove(target.id)}
|
||||
className="size-7 text-muted-foreground hover:text-red-400"
|
||||
aria-label="Remove target"
|
||||
disabled={hasActionInFlight}
|
||||
aria-label={removeInFlight ? 'Removing target' : 'Remove target'}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
{removeInFlight ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
|
|
@ -196,13 +239,13 @@ export function SshTargetCard({
|
|||
size="xs"
|
||||
onClick={handleDisconnect}
|
||||
className="gap-1.5"
|
||||
disabled={actionInFlight !== null}
|
||||
disabled={hasActionInFlight}
|
||||
>
|
||||
<ServerOff className="size-3" />
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
) : isConnecting(status) ? (
|
||||
) : isSshTargetConnecting(status) ? (
|
||||
<>
|
||||
{renderSecondaryIconActions(false)}
|
||||
<Button variant="ghost" size="xs" disabled className="gap-1.5">
|
||||
|
|
@ -217,7 +260,7 @@ export function SshTargetCard({
|
|||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onTest(target.id)}
|
||||
disabled={testing}
|
||||
disabled={testing || hasActionInFlight}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{testing ? (
|
||||
|
|
@ -232,7 +275,7 @@ export function SshTargetCard({
|
|||
size="xs"
|
||||
onClick={handleConnect}
|
||||
className="gap-1.5"
|
||||
disabled={actionInFlight !== null}
|
||||
disabled={hasActionInFlight}
|
||||
>
|
||||
{actionInFlight === 'connect' ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import { SshDestructiveActionDialog } from './SshDestructiveActionDialog'
|
||||
import { isSshTargetConnecting, type SshTargetBusyAction } from './ssh-target-action-state'
|
||||
|
||||
type PendingTargetAction = { id: string; label: string }
|
||||
|
||||
type SshTargetDestructiveActionsRenderProps = {
|
||||
busyActionForTarget: (targetId: string) => SshTargetBusyAction | undefined
|
||||
requestRemove: (target: PendingTargetAction) => void
|
||||
requestResetRelay: (target: PendingTargetAction) => void
|
||||
requestTerminateSessions: (target: PendingTargetAction) => void
|
||||
}
|
||||
|
||||
type SshTargetDestructiveActionsProps = {
|
||||
connectionStates: Map<string, SshConnectionState>
|
||||
onRemove: (targetId: string) => Promise<void>
|
||||
onResetRelay: (targetId: string) => Promise<void>
|
||||
onTerminateSessions: (targetId: string) => Promise<void>
|
||||
children: (actions: SshTargetDestructiveActionsRenderProps) => ReactNode
|
||||
}
|
||||
|
||||
export function SshTargetDestructiveActions({
|
||||
connectionStates,
|
||||
onRemove,
|
||||
onResetRelay,
|
||||
onTerminateSessions,
|
||||
children
|
||||
}: SshTargetDestructiveActionsProps): React.JSX.Element {
|
||||
const [pendingRemove, setPendingRemove] = useState<PendingTargetAction | null>(null)
|
||||
const [pendingReset, setPendingReset] = useState<PendingTargetAction | null>(null)
|
||||
const [pendingTerminate, setPendingTerminate] = useState<PendingTargetAction | null>(null)
|
||||
// Why: confirmed SSH actions keep running after the dialog click, so this
|
||||
// state blocks overlapping relay/session teardown for the same target.
|
||||
const targetActionsInFlightRef = useRef(new Map<string, SshTargetBusyAction>())
|
||||
const [targetActionsInFlight, setTargetActionsInFlight] = useState<
|
||||
Map<string, SshTargetBusyAction>
|
||||
>(new Map())
|
||||
const connectionStatesRef = useRef(connectionStates)
|
||||
connectionStatesRef.current = connectionStates
|
||||
|
||||
const beginTargetAction = useCallback(
|
||||
(targetId: string, action: SshTargetBusyAction): boolean => {
|
||||
if (targetActionsInFlightRef.current.has(targetId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextActions = new Map(targetActionsInFlightRef.current)
|
||||
nextActions.set(targetId, action)
|
||||
targetActionsInFlightRef.current = nextActions
|
||||
setTargetActionsInFlight(nextActions)
|
||||
return true
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const finishTargetAction = useCallback((targetId: string): void => {
|
||||
const nextActions = new Map(targetActionsInFlightRef.current)
|
||||
nextActions.delete(targetId)
|
||||
targetActionsInFlightRef.current = nextActions
|
||||
setTargetActionsInFlight(nextActions)
|
||||
}, [])
|
||||
|
||||
const runConfirmedTargetAction = async (
|
||||
pendingTarget: PendingTargetAction | null,
|
||||
action: SshTargetBusyAction,
|
||||
operation: (targetId: string) => Promise<void>,
|
||||
clearPendingTarget: () => void
|
||||
): Promise<void> => {
|
||||
if (!pendingTarget || !beginTargetAction(pendingTarget.id, action)) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetId = pendingTarget.id
|
||||
try {
|
||||
await operation(targetId)
|
||||
clearPendingTarget()
|
||||
} finally {
|
||||
finishTargetAction(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRemoveIsBusy =
|
||||
pendingRemove !== null && targetActionsInFlight.get(pendingRemove.id) === 'remove'
|
||||
const pendingResetIsBusy =
|
||||
pendingReset !== null && targetActionsInFlight.get(pendingReset.id) === 'reset'
|
||||
const pendingResetStatus =
|
||||
pendingReset !== null
|
||||
? (connectionStates.get(pendingReset.id)?.status ?? 'disconnected')
|
||||
: 'disconnected'
|
||||
const pendingResetBlockedByConnection =
|
||||
pendingReset !== null && isSshTargetConnecting(pendingResetStatus)
|
||||
const pendingTerminateIsBusy =
|
||||
pendingTerminate !== null && targetActionsInFlight.get(pendingTerminate.id) === 'terminate'
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingResetBlockedByConnection && !pendingResetIsBusy) {
|
||||
setPendingReset(null)
|
||||
}
|
||||
}, [pendingResetBlockedByConnection, pendingResetIsBusy])
|
||||
|
||||
const confirmResetRelay = async (): Promise<void> => {
|
||||
if (!pendingReset) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestStatus = connectionStatesRef.current.get(pendingReset.id)?.status ?? 'disconnected'
|
||||
if (isSshTargetConnecting(latestStatus)) {
|
||||
setPendingReset(null)
|
||||
return
|
||||
}
|
||||
|
||||
await runConfirmedTargetAction(pendingReset, 'reset', onResetRelay, () => setPendingReset(null))
|
||||
}
|
||||
|
||||
const actions: SshTargetDestructiveActionsRenderProps = {
|
||||
busyActionForTarget: (targetId) => targetActionsInFlight.get(targetId),
|
||||
requestRemove: (target) => {
|
||||
if (!targetActionsInFlightRef.current.has(target.id)) {
|
||||
setPendingRemove(target)
|
||||
}
|
||||
},
|
||||
requestResetRelay: (target) => {
|
||||
const status = connectionStatesRef.current.get(target.id)?.status ?? 'disconnected'
|
||||
if (!isSshTargetConnecting(status) && !targetActionsInFlightRef.current.has(target.id)) {
|
||||
setPendingReset(target)
|
||||
}
|
||||
},
|
||||
requestTerminateSessions: (target) => {
|
||||
if (!targetActionsInFlightRef.current.has(target.id)) {
|
||||
setPendingTerminate(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(actions)}
|
||||
|
||||
<SshDestructiveActionDialog
|
||||
open={!!pendingRemove}
|
||||
title="Remove SSH Target"
|
||||
description="This will remove the target and end any active remote terminals."
|
||||
targetLabel={pendingRemove?.label}
|
||||
actionLabel="Remove"
|
||||
busyLabel="Removing"
|
||||
isBusy={pendingRemoveIsBusy}
|
||||
onOpenChange={(open) => {
|
||||
if (pendingRemoveIsBusy) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setPendingRemove(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={() =>
|
||||
runConfirmedTargetAction(pendingRemove, 'remove', onRemove, () => setPendingRemove(null))
|
||||
}
|
||||
/>
|
||||
|
||||
<SshDestructiveActionDialog
|
||||
open={!!pendingReset && (!pendingResetBlockedByConnection || pendingResetIsBusy)}
|
||||
title="Reset Remote Relay?"
|
||||
description="This force-stops the remote relay for this SSH target. Active remote terminals and port forwards for this target will end."
|
||||
targetLabel={pendingReset?.label}
|
||||
actionLabel="Reset Relay"
|
||||
busyLabel="Resetting"
|
||||
isBusy={pendingResetIsBusy}
|
||||
onOpenChange={(open) => {
|
||||
if (pendingResetIsBusy) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setPendingReset(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={confirmResetRelay}
|
||||
/>
|
||||
|
||||
<SshDestructiveActionDialog
|
||||
open={!!pendingTerminate}
|
||||
title="End Remote Terminals?"
|
||||
description="This will stop active terminal sessions on this SSH target. Reconnecting will not restore them."
|
||||
targetLabel={pendingTerminate?.label}
|
||||
actionLabel="End Terminals"
|
||||
busyLabel="Ending"
|
||||
isBusy={pendingTerminateIsBusy}
|
||||
onOpenChange={(open) => {
|
||||
if (pendingTerminateIsBusy) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setPendingTerminate(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={() =>
|
||||
runConfirmedTargetAction(pendingTerminate, 'terminate', onTerminateSessions, () =>
|
||||
setPendingTerminate(null)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import { FileKey } from 'lucide-react'
|
||||
import {
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
|
||||
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
} from '../../../../shared/ssh-types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
|
|
@ -26,9 +31,9 @@ export const EMPTY_FORM: EditingTarget = {
|
|||
identityFile: '',
|
||||
proxyCommand: '',
|
||||
jumpHost: '',
|
||||
relayGracePeriodSeconds: '300',
|
||||
relayGracePeriodSeconds: String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS),
|
||||
remoteWorkspaceSyncEnabled: false,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: '300'
|
||||
remoteWorkspaceSyncGracePeriodSeconds: String(DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS)
|
||||
}
|
||||
|
||||
type SshTargetFormProps = {
|
||||
|
|
@ -136,12 +141,13 @@ export function SshTargetForm({
|
|||
onChange={(e) =>
|
||||
onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value }))
|
||||
}
|
||||
placeholder="300"
|
||||
min={60}
|
||||
max={3600}
|
||||
placeholder={String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)}
|
||||
min={0}
|
||||
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
How long the relay keeps terminals alive after disconnect. Default: 300 (5 minutes).
|
||||
How long the relay keeps terminals alive after disconnect. Default: 10800 (3 hours). 0
|
||||
keeps it alive until terminals are ended or the relay is reset.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-3 border-t border-border/50 pt-3">
|
||||
|
|
@ -176,7 +182,7 @@ export function SshTargetForm({
|
|||
}
|
||||
placeholder="0"
|
||||
min={0}
|
||||
max={3600}
|
||||
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
How long synced remote workspace terminals stay alive after all clients disconnect.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
|
||||
export type SshTargetBusyAction = 'terminate' | 'reset' | 'remove'
|
||||
|
||||
const SSH_TARGET_CONNECTING_STATUSES: ReadonlySet<SshConnectionStatus> = new Set([
|
||||
'connecting',
|
||||
'deploying-relay',
|
||||
'reconnecting'
|
||||
])
|
||||
|
||||
export function isSshTargetConnecting(status: SshConnectionStatus): boolean {
|
||||
return SSH_TARGET_CONNECTING_STATUSES.has(status)
|
||||
}
|
||||
|
|
@ -1112,6 +1112,7 @@ function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
|
|||
connect: () => Promise.resolve(null),
|
||||
disconnect: () => Promise.resolve(),
|
||||
terminateSessions: () => Promise.resolve(),
|
||||
resetRelay: () => Promise.resolve(),
|
||||
getState: () => Promise.resolve(null),
|
||||
needsPassphrasePrompt: () => Promise.resolve(false),
|
||||
testConnection: () =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
// ─── SSH Connection Types ───────────────────────────────────────────
|
||||
|
||||
export const MIN_SSH_RELAY_GRACE_PERIOD_SECONDS = 60
|
||||
export const MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60
|
||||
export const DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60
|
||||
export const DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS =
|
||||
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
|
||||
export type SshTarget = {
|
||||
id: string
|
||||
label: string
|
||||
|
|
@ -15,13 +21,14 @@ export type SshTarget = {
|
|||
/** Jump host (ProxyJump), if any. */
|
||||
jumpHost?: string
|
||||
/** Grace period in seconds before relay shuts down after disconnect.
|
||||
* Default: 300 (5 minutes). */
|
||||
* 0 disables expiry. Default: 10800 (3 hours). */
|
||||
relayGracePeriodSeconds?: number
|
||||
/** Opt in to remote-host-owned workspace/session state for this SSH target.
|
||||
* Classic SSH remains local-session-backed when this is false/absent. */
|
||||
remoteWorkspaceSyncEnabled?: boolean
|
||||
/** Grace period in seconds for synced remote workspace relays.
|
||||
* 0 disables expiry. Only applies when remoteWorkspaceSyncEnabled is true. */
|
||||
* 0 disables expiry. Default: 10800 (3 hours). Only applies when
|
||||
* remoteWorkspaceSyncEnabled is true. */
|
||||
remoteWorkspaceSyncGracePeriodSeconds?: number
|
||||
/** Set to true after a successful connection that triggered a credential
|
||||
* prompt (passphrase or password). Persisted so startup reconnect can
|
||||
|
|
|
|||
Loading…
Reference in New Issue