mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
feat(settings): managed servers and "Move to managed server", behind an experimental setting (#16741 T6-6 + T6-10 UI) (#24590)
* feat(settings): managed servers and "Move to managed server", behind an experimental setting (#16741 T6-6 + T6-10 UI) Adds a Managed servers section under Remote servers (deploy an empty server, status with deferred-update and migration states, update, rollback, recover, stop and cancel-stop, and SSH access for paired servers), and a Move to managed server action on connected macOS and Linux SSH hosts with a preflight summary, a terminals-closed confirmation and a resumable progress view. Main wires the conversion to the relay's process list, the direct session and the T6-9 catalog client. Everything is hidden until the new experimental setting is turned on, and Windows SSH hosts are never offered. Merges the T6-9 branch (#24521) until it lands on the integration branch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(settings): align managed-server form controls and name the section the setting reveals Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(orcad): ask the relay with an absolute deadline via the W5 terminal-gate lister The conversion wiring passed a relative 10 s as listProcesses' deadlineMs, which the provider reads as an absolute time, so every relay inventory timed out after 1 ms and the terminal gate could never prove exit. Adopt #24579's lister verbatim so the stacks merge cleanly. * feat(settings): name blocking saved state in plain, localized words The move preview listed internal dependency ids such as workspace-session; each kind now has its own catalog entry. * feat(settings): offer managed servers and the move on Windows SSH hosts W1-W5 are on the integration branch, so a Windows relay-hosted host can deploy, convert and retire like a POSIX one. The move still waits for a connected relay that reported its platform. --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
m4air
Claude Opus 5.5
parent
8393c1d6d0
commit
a280d6aedd
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handle: vi.fn(),
|
||||
convert: vi.fn(),
|
||||
pending: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
manifest: vi.fn(),
|
||||
terminals: vi.fn(),
|
||||
inventory: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
|
||||
vi.mock('../ssh/orcad-runtime-conversion', () => ({
|
||||
convertSshTargetToManagedOrcad: mocks.convert
|
||||
}))
|
||||
vi.mock('../ssh/orcad-managed-migration-status', () => ({
|
||||
listPendingManagedOrcadMigrations: mocks.pending
|
||||
}))
|
||||
vi.mock('../ssh/orcad-managed-runtime-context', () => ({
|
||||
requireManagedOrcadInfrastructure: () => ({
|
||||
targetStore: {
|
||||
getOrcadMigrationSource: () => ({ getSshTarget: () => ({ id: 'ssh-1', label: 'Builder' }) })
|
||||
}
|
||||
})
|
||||
}))
|
||||
vi.mock('../ssh/ssh-target-orcad-preflight', () => ({
|
||||
preflightOrcadMigrationExport: mocks.preflight
|
||||
}))
|
||||
vi.mock('../ssh/orcad-migration-manifest-export', () => ({
|
||||
createOrcadMigrationManifest: mocks.manifest
|
||||
}))
|
||||
vi.mock('../ssh/orcad-migration-terminal-gate', () => ({
|
||||
assessOrcadMigrationTerminals: mocks.terminals
|
||||
}))
|
||||
vi.mock('../ssh/orcad-migration-relay-pty-lister', () => ({
|
||||
orcadMigrationRelayPtyLister: mocks.inventory
|
||||
}))
|
||||
vi.mock('../ssh/orcad-runtime-conversion-wiring', () => ({
|
||||
conversionCollaborators: () => ({ marker: 'live-collaborators' })
|
||||
}))
|
||||
|
||||
const { registerOrcadRuntimeConversionHandlers } =
|
||||
await import('./orcad-runtime-conversion-handlers')
|
||||
|
||||
function handler(channel: string): (_event: unknown, args?: unknown) => unknown {
|
||||
const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
|
||||
if (!registration) {
|
||||
throw new Error(`${channel} handler was not registered`)
|
||||
}
|
||||
return registration[1]
|
||||
}
|
||||
|
||||
describe('managed server conversion IPC', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
registerOrcadRuntimeConversionHandlers(() => '/profile')
|
||||
})
|
||||
|
||||
it('previews what moves and keeps only blockers that stop the move', async () => {
|
||||
mocks.preflight.mockReturnValue({
|
||||
targetLabel: 'Builder',
|
||||
blockers: [
|
||||
{ code: 'orcad_migration_direct_ssh_repositories', category: 'drainable-static-state' },
|
||||
{ code: 'orcad_migration_saved_port_forwards', category: 'client-owned-state' },
|
||||
{ code: 'orcad_migration_target_owned', category: 'exclusive-ownership' }
|
||||
]
|
||||
})
|
||||
mocks.manifest.mockReturnValue({
|
||||
payload: {
|
||||
repositories: [{}, {}],
|
||||
projectGroups: [{}],
|
||||
folderWorkspaces: [{}],
|
||||
dormantState: { automations: [{}], workspaceSession: {} }
|
||||
}
|
||||
})
|
||||
mocks.terminals.mockResolvedValue({ verdict: 'live', ptyIds: ['p'], reason: 'running' })
|
||||
await expect(
|
||||
handler('runtimeEnvironments:previewOrcadConversion')(null, { sshTargetId: 'ssh-1' })
|
||||
).resolves.toEqual({
|
||||
sshTargetId: 'ssh-1',
|
||||
targetLabel: 'Builder',
|
||||
moves: {
|
||||
repositories: 2,
|
||||
projectGroups: 1,
|
||||
folderWorkspaces: 1,
|
||||
automations: 1,
|
||||
workspaceSession: true
|
||||
},
|
||||
blockers: [{ code: 'orcad_migration_target_owned', category: 'exclusive-ownership' }],
|
||||
terminals: { verdict: 'live', ptyIds: ['p'], reason: 'running' }
|
||||
})
|
||||
expect(mocks.inventory).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('converts with the live collaborators and lists pending migrations from the profile', async () => {
|
||||
mocks.convert.mockResolvedValue({ outcome: 'converted' })
|
||||
await handler('runtimeEnvironments:convertSshHostToManagedOrcad')(null, {
|
||||
sshTargetId: ' ssh-1 ',
|
||||
name: 'Builder'
|
||||
})
|
||||
expect(mocks.convert).toHaveBeenCalledWith('/profile', {
|
||||
sshTargetId: 'ssh-1',
|
||||
name: 'Builder',
|
||||
marker: 'live-collaborators'
|
||||
})
|
||||
handler('runtimeEnvironments:listPendingOrcadMigrations')(null)
|
||||
expect(mocks.pending).toHaveBeenCalledWith('/profile')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type {
|
||||
OrcadManagedConversionPreview,
|
||||
OrcadManagedConversionResult,
|
||||
OrcadManagedPendingMigrationRow
|
||||
} from '../../shared/orcad-managed-runtime'
|
||||
import { listPendingManagedOrcadMigrations } from '../ssh/orcad-managed-migration-status'
|
||||
import { requireManagedOrcadInfrastructure } from '../ssh/orcad-managed-runtime-context'
|
||||
import { createOrcadMigrationManifest } from '../ssh/orcad-migration-manifest-export'
|
||||
import { assessOrcadMigrationTerminals } from '../ssh/orcad-migration-terminal-gate'
|
||||
import { convertSshTargetToManagedOrcad } from '../ssh/orcad-runtime-conversion'
|
||||
import { orcadMigrationRelayPtyLister } from '../ssh/orcad-migration-relay-pty-lister'
|
||||
import { conversionCollaborators } from '../ssh/orcad-runtime-conversion-wiring'
|
||||
import { preflightOrcadMigrationExport } from '../ssh/ssh-target-orcad-preflight'
|
||||
import { requiredString } from './orcad-runtime-lifecycle-handlers'
|
||||
|
||||
export function registerOrcadRuntimeConversionHandlers(getUserDataPath: () => string): void {
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:previewOrcadConversion',
|
||||
(_event, args: { sshTargetId: string }): Promise<OrcadManagedConversionPreview> =>
|
||||
previewConversion(requiredString(args?.sshTargetId, 'SSH target'))
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:convertSshHostToManagedOrcad',
|
||||
(
|
||||
_event,
|
||||
args: { sshTargetId: string; name: string }
|
||||
): Promise<OrcadManagedConversionResult> => {
|
||||
const sshTargetId = requiredString(args?.sshTargetId, 'SSH target')
|
||||
return convertSshTargetToManagedOrcad(getUserDataPath(), {
|
||||
sshTargetId,
|
||||
name: requiredString(args?.name, 'Server name'),
|
||||
...conversionCollaborators(sshTargetId)
|
||||
})
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:listPendingOrcadMigrations',
|
||||
(): OrcadManagedPendingMigrationRow[] => listPendingManagedOrcadMigrations(getUserDataPath())
|
||||
)
|
||||
}
|
||||
|
||||
/** Read-only: exports nothing and fences nothing. */
|
||||
async function previewConversion(sshTargetId: string): Promise<OrcadManagedConversionPreview> {
|
||||
const { targetStore } = requireManagedOrcadInfrastructure()
|
||||
const store = targetStore.getOrcadMigrationSource()
|
||||
const target = store.getSshTarget(sshTargetId)
|
||||
if (!target) {
|
||||
throw new Error('SSH target is required.')
|
||||
}
|
||||
const preflight = preflightOrcadMigrationExport(store, sshTargetId)
|
||||
const manifest = createOrcadMigrationManifest(store, target)
|
||||
const dormant = manifest.payload.dormantState
|
||||
const terminals = await assessOrcadMigrationTerminals(
|
||||
store,
|
||||
sshTargetId,
|
||||
orcadMigrationRelayPtyLister(sshTargetId)
|
||||
)
|
||||
return {
|
||||
sshTargetId,
|
||||
targetLabel: preflight.targetLabel,
|
||||
moves: {
|
||||
repositories: manifest.payload.repositories.length,
|
||||
projectGroups: manifest.payload.projectGroups.length,
|
||||
folderWorkspaces: manifest.payload.folderWorkspaces.length,
|
||||
automations: dormant?.automations?.length ?? 0,
|
||||
workspaceSession: Boolean(dormant?.workspaceSession)
|
||||
},
|
||||
blockers: preflight.blockers.filter(
|
||||
(blocker) =>
|
||||
blocker.category !== 'drainable-static-state' &&
|
||||
blocker.code !== 'orcad_migration_saved_port_forwards'
|
||||
),
|
||||
terminals:
|
||||
terminals.verdict === 'exited'
|
||||
? { verdict: 'exited' }
|
||||
: { verdict: terminals.verdict, ptyIds: terminals.ptyIds, reason: terminals.reason }
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:recoverOrcad',
|
||||
'runtimeEnvironments:stopOrcad',
|
||||
'runtimeEnvironments:cancelOrcadStop',
|
||||
'runtimeEnvironments:previewOrcadConversion',
|
||||
'runtimeEnvironments:convertSshHostToManagedOrcad',
|
||||
'runtimeEnvironments:listPendingOrcadMigrations',
|
||||
'runtimeEnvironments:createOrcadSshHost',
|
||||
'runtimeEnvironments:resumeOrcadSshHost',
|
||||
'runtimeEnvironments:listPendingOrcadSshProvisioning'
|
||||
|
||||
@@ -159,6 +159,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'runtimeEnvironments:listPendingOrcadSshProvisioning',
|
||||
'runtimeEnvironments:deployOrcad',
|
||||
'runtimeEnvironments:getOrcadStatus',
|
||||
'runtimeEnvironments:previewOrcadConversion',
|
||||
'runtimeEnvironments:convertSshHostToManagedOrcad',
|
||||
'runtimeEnvironments:listPendingOrcadMigrations',
|
||||
'runtimeEnvironments:updateOrcad',
|
||||
'runtimeEnvironments:rollbackOrcad',
|
||||
'runtimeEnvironments:recoverOrcad',
|
||||
@@ -199,6 +202,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'runtimeEnvironments:recoverOrcad',
|
||||
'runtimeEnvironments:stopOrcad',
|
||||
'runtimeEnvironments:cancelOrcadStop',
|
||||
'runtimeEnvironments:previewOrcadConversion',
|
||||
'runtimeEnvironments:convertSshHostToManagedOrcad',
|
||||
'runtimeEnvironments:listPendingOrcadMigrations',
|
||||
'runtimeEnvironments:createOrcadSshHost',
|
||||
'runtimeEnvironments:resumeOrcadSshHost',
|
||||
'runtimeEnvironments:listPendingOrcadSshProvisioning',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './runtime-environment-subscriptions'
|
||||
import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels'
|
||||
import { registerOrcadRuntimeLifecycleHandlers } from './orcad-runtime-lifecycle-handlers'
|
||||
import { registerOrcadRuntimeConversionHandlers } from './orcad-runtime-conversion-handlers'
|
||||
import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers'
|
||||
import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers'
|
||||
import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime'
|
||||
@@ -75,6 +76,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
invalidateTransport: invalidateRuntimeEnvironmentTransport
|
||||
})
|
||||
registerOrcadRuntimeLifecycleHandlers({ getUserDataPath })
|
||||
registerOrcadRuntimeConversionHandlers(getUserDataPath)
|
||||
registerOrcadRuntimeMaintenanceHandlers({
|
||||
getUserDataPath,
|
||||
getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/** The live collaborators a host conversion needs: the relay, the direct session and the server. */
|
||||
import { encodePairingOffer } from '../../shared/pairing'
|
||||
import {
|
||||
getPreferredPairingOffer,
|
||||
type KnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import { disconnectRegisteredSshTarget } from '../ipc/ssh-session-teardown'
|
||||
import type { OrcadMigrationDestinationCatalog } from './orcad-migration-cutover-coordinator'
|
||||
import {
|
||||
abortRemoteOrcadMigrationCatalog,
|
||||
commitRemoteOrcadMigrationCatalog,
|
||||
readRemoteOrcadMigrationCatalogState,
|
||||
stageRemoteOrcadMigrationCatalog,
|
||||
stageRemoteOrcadMigrationSnapshotChunk
|
||||
} from './orcad-migration-catalog-client'
|
||||
import { orcadMigrationRelayPtyLister } from './orcad-migration-relay-pty-lister'
|
||||
import type { OrcadManagedConversionArgs } from './orcad-runtime-conversion'
|
||||
|
||||
/** The T6-9 client against this server, pinned to the runtime it paired with. */
|
||||
export function orcadMigrationDestinationFor(
|
||||
environment: KnownRuntimeEnvironment
|
||||
): OrcadMigrationDestinationCatalog {
|
||||
const pairingCode = encodePairingOffer(getPreferredPairingOffer(environment))
|
||||
const options = environment.runtimeId ? { expectedRuntimeId: environment.runtimeId } : {}
|
||||
return {
|
||||
readState: (manifest) => readRemoteOrcadMigrationCatalogState(pairingCode, manifest, options),
|
||||
stage: (manifest) => stageRemoteOrcadMigrationCatalog(pairingCode, manifest, options),
|
||||
commit: (manifest) => commitRemoteOrcadMigrationCatalog(pairingCode, manifest, options),
|
||||
abort: (manifest) => abortRemoteOrcadMigrationCatalog(pairingCode, manifest, options),
|
||||
stageChunk: (request) => stageRemoteOrcadMigrationSnapshotChunk(pairingCode, request, options)
|
||||
}
|
||||
}
|
||||
|
||||
export function conversionCollaborators(
|
||||
sshTargetId: string
|
||||
): Pick<OrcadManagedConversionArgs, 'destinationFor' | 'listRelayPtyIds' | 'releaseDirectSession'> {
|
||||
return {
|
||||
destinationFor: orcadMigrationDestinationFor,
|
||||
listRelayPtyIds: orcadMigrationRelayPtyLister(sshTargetId),
|
||||
releaseDirectSession: disconnectRegisteredSshTarget
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Managed orcad servers over SSH; desktop-only, so web clients leave it undefined. */
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type {
|
||||
OrcadManagedCancelStopResult,
|
||||
OrcadManagedConversionPreview,
|
||||
OrcadManagedConversionResult,
|
||||
OrcadManagedDeployResult,
|
||||
OrcadManagedPendingMigrationRow,
|
||||
OrcadManagedRecoveryResult,
|
||||
OrcadManagedRollbackResult,
|
||||
OrcadManagedRuntimeStatus,
|
||||
OrcadManagedStopResult
|
||||
} from '../../shared/orcad-managed-runtime'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import type {
|
||||
RuntimeSshAccessLinkRequest,
|
||||
RuntimeSshAccessUnlinkRequest
|
||||
} from '../../shared/runtime-ssh-access'
|
||||
|
||||
export type ManagedOrcadPreloadApi = {
|
||||
deploy: (args: {
|
||||
name: string
|
||||
sshTargetId: string
|
||||
force?: boolean
|
||||
}) => Promise<OrcadManagedDeployResult>
|
||||
getStatus: (args: { selector: string }) => Promise<OrcadManagedRuntimeStatus>
|
||||
update: (args: { selector: string; force?: boolean }) => Promise<OrcadManagedDeployResult>
|
||||
rollback: (args: { selector: string }) => Promise<OrcadManagedRollbackResult>
|
||||
recover: (args: { selector: string }) => Promise<OrcadManagedRecoveryResult>
|
||||
stop: (args: { selector: string }) => Promise<OrcadManagedStopResult>
|
||||
cancelStop: (args: { selector: string }) => Promise<OrcadManagedCancelStopResult>
|
||||
linkSshAccess: (args: RuntimeSshAccessLinkRequest) => Promise<PublicKnownRuntimeEnvironment>
|
||||
unlinkSshAccess: (args: RuntimeSshAccessUnlinkRequest) => Promise<PublicKnownRuntimeEnvironment>
|
||||
previewConversion: (args: { sshTargetId: string }) => Promise<OrcadManagedConversionPreview>
|
||||
convertSshHost: (args: {
|
||||
sshTargetId: string
|
||||
name: string
|
||||
}) => Promise<OrcadManagedConversionResult>
|
||||
listPendingMigrations: () => Promise<OrcadManagedPendingMigrationRow[]>
|
||||
}
|
||||
|
||||
export const managedOrcadApi: ManagedOrcadPreloadApi = {
|
||||
deploy: (args) => ipcRenderer.invoke('runtimeEnvironments:deployOrcad', args),
|
||||
getStatus: (args) => ipcRenderer.invoke('runtimeEnvironments:getOrcadStatus', args),
|
||||
update: (args) => ipcRenderer.invoke('runtimeEnvironments:updateOrcad', args),
|
||||
rollback: (args) => ipcRenderer.invoke('runtimeEnvironments:rollbackOrcad', args),
|
||||
recover: (args) => ipcRenderer.invoke('runtimeEnvironments:recoverOrcad', args),
|
||||
stop: (args) => ipcRenderer.invoke('runtimeEnvironments:stopOrcad', args),
|
||||
cancelStop: (args) => ipcRenderer.invoke('runtimeEnvironments:cancelOrcadStop', args),
|
||||
linkSshAccess: (args) => ipcRenderer.invoke('runtimeEnvironments:linkSshAccess', args),
|
||||
unlinkSshAccess: (args) => ipcRenderer.invoke('runtimeEnvironments:unlinkSshAccess', args),
|
||||
previewConversion: (args) =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:previewOrcadConversion', args),
|
||||
convertSshHost: (args) =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:convertSshHostToManagedOrcad', args),
|
||||
listPendingMigrations: () => ipcRenderer.invoke('runtimeEnvironments:listPendingOrcadMigrations')
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ManagedOrcadPreloadApi } from './managed-orcad-api'
|
||||
import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
@@ -141,6 +142,8 @@ export type RuntimeApi = {
|
||||
onClose?: () => void
|
||||
}
|
||||
) => Promise<RuntimeEnvironmentSubscriptionHandle>
|
||||
// Why optional: only the desktop preload deploys servers over SSH.
|
||||
managedOrcad?: ManagedOrcadPreloadApi
|
||||
}
|
||||
wsl: {
|
||||
isAvailable: () => Promise<boolean>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type RuntimeEnvironmentSubscriptionHandle
|
||||
} from '../runtime-environment-subscriptions'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
import { managedOrcadApi } from './managed-orcad-api'
|
||||
|
||||
export const runtimeEnvironmentsApi = {
|
||||
getStatusSnapshots: (): Promise<RuntimeHostStatusSnapshot[]> =>
|
||||
@@ -106,5 +107,6 @@ export const runtimeEnvironmentsApi = {
|
||||
onClose?: () => void
|
||||
}
|
||||
): Promise<RuntimeEnvironmentSubscriptionHandle> =>
|
||||
subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks)
|
||||
subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks),
|
||||
managedOrcad: managedOrcadApi
|
||||
} satisfies PreloadApi['runtimeEnvironments']
|
||||
|
||||
@@ -11,6 +11,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { NativeChatExperimentalSetting } from './NativeChatExperimentalSetting'
|
||||
import { AgentDashboardExperimentalSetting } from './AgentDashboardExperimentalSetting'
|
||||
import { EphemeralVmsExperimentalSetting } from './EphemeralVmsExperimentalSetting'
|
||||
import { ManagedServersExperimentalSetting } from './ManagedServersExperimentalSetting'
|
||||
import {
|
||||
MAX_AGENT_HIBERNATION_IDLE_MS,
|
||||
MIN_AGENT_HIBERNATION_IDLE_MS,
|
||||
@@ -268,6 +269,8 @@ export function ExperimentalPane({
|
||||
|
||||
<EphemeralVmsExperimentalSetting settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
<ManagedServersExperimentalSetting settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcadManagedRuntimeStatus } from '../../../../shared/orcad-managed-runtime'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
|
||||
import { ManagedServerRow } from './ManagedServerRow'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const roots: Root[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const status: OrcadManagedRuntimeStatus = {
|
||||
environmentId: 'env-1',
|
||||
sshTargetId: 'ssh-1',
|
||||
activeVersion: '1.2.0',
|
||||
previousVersion: '1.1.0',
|
||||
activatedAt: '2026-10-01T00:00:00.000Z',
|
||||
rollbackAvailable: false,
|
||||
recovery: null,
|
||||
terminals: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null },
|
||||
migration: { migrationId: 'm-1', phase: 'destination-staged', startedAt: 'now' },
|
||||
deferredUpdate: {
|
||||
outcome: 'deferred',
|
||||
candidateVersion: '1.3.0',
|
||||
code: 'orcad_update_terminal_census_unavailable',
|
||||
reason: 'the daemon did not answer',
|
||||
deferredAt: 'now'
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const stop = vi.fn(async () => ({ outcome: 'unlinked' }))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the row calls only getStatus and stop here.
|
||||
const api = { getStatus: vi.fn(async () => status), stop } as unknown as ManagedOrcadPreloadApi
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the row reads only id and name.
|
||||
const environment = { id: 'env-1', name: 'Builder' } as PublicKnownRuntimeEnvironment
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
await act(async () => {
|
||||
root.render(<ManagedServerRow api={api} environment={environment} onChanged={() => {}} />)
|
||||
})
|
||||
return { container, stop }
|
||||
}
|
||||
|
||||
const button = (container: HTMLElement, label: string) =>
|
||||
[...container.querySelectorAll('button')].find((entry) => entry.textContent === label)
|
||||
|
||||
describe('managed server row', () => {
|
||||
it('reports unknown terminals, the running migration and the deferred update', async () => {
|
||||
const { container } = await render()
|
||||
expect(container.textContent).toContain('Running terminals: unknown')
|
||||
expect(container.textContent).toContain('Migration: Copy staged')
|
||||
expect(container.textContent).toContain('Update to 1.3.0 deferred: the daemon did not answer')
|
||||
expect(button(container, 'Roll back')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops only after a second, explicit confirmation', async () => {
|
||||
const { container, stop } = await render()
|
||||
await act(async () => button(container, 'Stop…')?.click())
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
await act(async () => button(container, 'Stop and remove server')?.click())
|
||||
expect(stop).toHaveBeenCalledWith({ selector: 'env-1' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { OrcadManagedRuntimeStatus } from '../../../../shared/orcad-managed-runtime'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { migrationPhaseLabel, recoveryLabel, terminalCensusLabel } from './managed-server-copy'
|
||||
|
||||
type ManagedServerAction = 'update' | 'rollback' | 'recover' | 'stop' | 'cancelStop'
|
||||
|
||||
type ManagedServerRowProps = {
|
||||
api: ManagedOrcadPreloadApi
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
/** Turns a refusal or deferral into the one line the user acts on. */
|
||||
function outcomeMessage(result: { outcome: string; reason?: string }): string | null {
|
||||
return result.outcome === 'deferred' ||
|
||||
result.outcome === 'refused' ||
|
||||
result.outcome === 'failed'
|
||||
? (result.reason ?? result.outcome)
|
||||
: null
|
||||
}
|
||||
|
||||
export function ManagedServerRow({
|
||||
api,
|
||||
environment,
|
||||
onChanged
|
||||
}: ManagedServerRowProps): React.JSX.Element {
|
||||
const mountedRef = useMountedRef()
|
||||
const [status, setStatus] = useState<OrcadManagedRuntimeStatus | null>(null)
|
||||
const [statusError, setStatusError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<ManagedServerAction | 'status' | null>(null)
|
||||
const [confirmingStop, setConfirmingStop] = useState(false)
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
setBusy('status')
|
||||
try {
|
||||
const next = await api.getStatus({ selector: environment.id })
|
||||
if (mountedRef.current) {
|
||||
setStatus(next)
|
||||
setStatusError(null)
|
||||
}
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setStatusError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
}, [api, environment.id, mountedRef])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus()
|
||||
}, [loadStatus])
|
||||
|
||||
const run = async (action: ManagedServerAction): Promise<void> => {
|
||||
setBusy(action)
|
||||
try {
|
||||
const selector = { selector: environment.id }
|
||||
const result =
|
||||
action === 'update'
|
||||
? await api.update(selector)
|
||||
: action === 'rollback'
|
||||
? await api.rollback(selector)
|
||||
: action === 'recover'
|
||||
? await api.recover(selector)
|
||||
: action === 'stop'
|
||||
? await api.stop(selector)
|
||||
: await api.cancelStop(selector)
|
||||
const message = outcomeMessage(result)
|
||||
if (message) {
|
||||
toast.message(message)
|
||||
}
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(null)
|
||||
}
|
||||
void loadStatus()
|
||||
}
|
||||
}
|
||||
|
||||
const recovery = status?.recovery ?? null
|
||||
const disabled = busy !== null
|
||||
return (
|
||||
<div className="space-y-2 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="truncate text-sm font-medium">{environment.name}</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{status?.activeVersion ??
|
||||
translate('auto.components.settings.managedServers.row.notRunning', 'Not running')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto size-7"
|
||||
disabled={disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.settings.managedServers.row.refresh',
|
||||
'Refresh status'
|
||||
)}
|
||||
onClick={() => void loadStatus()}
|
||||
>
|
||||
{busy === 'status' ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{statusError ? <p className="text-xs text-destructive">{statusError}</p> : null}
|
||||
{status ? (
|
||||
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||||
<p>{terminalCensusLabel(status.terminals)}</p>
|
||||
{recovery ? <p>{recoveryLabel(recovery)}</p> : null}
|
||||
{status.migration ? (
|
||||
<p>
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.row.migration',
|
||||
'Migration: {{phase}}',
|
||||
{
|
||||
phase: migrationPhaseLabel(status.migration.phase)
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{status.deferredUpdate ? (
|
||||
<p>
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.row.deferred',
|
||||
'Update to {{version}} deferred: {{reason}}',
|
||||
{
|
||||
version: status.deferredUpdate.candidateVersion,
|
||||
reason: status.deferredUpdate.reason
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => void run('update')}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.row.update', 'Update')}
|
||||
</Button>
|
||||
{status?.rollbackAvailable ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => void run('rollback')}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.row.rollback', 'Roll back')}
|
||||
</Button>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => void run('recover')}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.row.recover', 'Recover')}
|
||||
</Button>
|
||||
) : null}
|
||||
{recovery?.operation === 'decommission' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => void run('cancelStop')}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.row.cancelStop', 'Cancel stop')}
|
||||
</Button>
|
||||
) : null}
|
||||
{confirmingStop ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setConfirmingStop(false)
|
||||
void run('stop')
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.row.confirmStop',
|
||||
'Stop and remove server'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmingStop(false)}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.row.keep', 'Keep running')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => setConfirmingStop(true)}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.row.stop', 'Stop…')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Label } from '../ui/label'
|
||||
import { getExperimentalSearchEntry } from './experimental-search'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsSwitch } from './SettingsFormControls'
|
||||
|
||||
type ManagedServersExperimentalSettingProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
export function ManagedServersExperimentalSetting({
|
||||
settings,
|
||||
updateSettings
|
||||
}: ManagedServersExperimentalSettingProps): React.JSX.Element {
|
||||
const entry = getExperimentalSearchEntry().managedServers
|
||||
const enabled = settings.experimentalManagedServers === true
|
||||
|
||||
return (
|
||||
<SearchableSetting
|
||||
title={entry.title}
|
||||
description={entry.description}
|
||||
keywords={entry.keywords}
|
||||
className="max-w-none space-y-4 py-2"
|
||||
id="managed-servers"
|
||||
>
|
||||
<div className="flex max-w-3xl items-start justify-between gap-4">
|
||||
<div className="min-w-0 shrink space-y-0.5">
|
||||
<Label>{entry.title}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.managedServersExperimentalSetting.description',
|
||||
'Shows Managed servers under Remote Orca Servers, and Move to managed server on SSH hosts. macOS and Linux hosts only.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsSwitch
|
||||
checked={enabled}
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.managedServersExperimentalSetting.toggleLabel',
|
||||
'Toggle Managed servers'
|
||||
)}
|
||||
onChange={() => updateSettings({ experimentalManagedServers: !enabled })}
|
||||
/>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { OrcadManagedPendingMigrationRow } from '../../../../shared/orcad-managed-runtime'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { ManagedServerRow } from './ManagedServerRow'
|
||||
import { RuntimeSshAccessControl } from './RuntimeSshAccessControl'
|
||||
import { SshTargetSelect } from './SshTargetSelect'
|
||||
import { migrationPhaseLabel } from './managed-server-copy'
|
||||
|
||||
type ManagedServersSectionProps = {
|
||||
environments: PublicKnownRuntimeEnvironment[]
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
export function ManagedServersSection({
|
||||
environments,
|
||||
onChanged
|
||||
}: ManagedServersSectionProps): React.JSX.Element | null {
|
||||
const api = window.api.runtimeEnvironments.managedOrcad
|
||||
const mountedRef = useMountedRef()
|
||||
const [targets, setTargets] = useState<SshTarget[]>([])
|
||||
const [pending, setPending] = useState<OrcadManagedPendingMigrationRow[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [targetId, setTargetId] = useState('')
|
||||
const [deploying, setDeploying] = useState(false)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
const [nextTargets, nextPending] = await Promise.all([
|
||||
window.api.ssh.listTargets(),
|
||||
api.listPendingMigrations()
|
||||
])
|
||||
if (mountedRef.current) {
|
||||
setTargets(nextTargets)
|
||||
setPending(nextPending)
|
||||
}
|
||||
}, [api, mountedRef])
|
||||
|
||||
useEffect(() => {
|
||||
void reload().catch(() => undefined)
|
||||
}, [reload])
|
||||
|
||||
if (!api) {
|
||||
return null
|
||||
}
|
||||
const managed = environments.filter((environment) => environment.orcadDeployment)
|
||||
const paired = environments.filter((environment) => !environment.orcadDeployment)
|
||||
|
||||
const deploy = async (): Promise<void> => {
|
||||
setDeploying(true)
|
||||
try {
|
||||
const result = await api.deploy({ name: name.trim(), sshTargetId: targetId })
|
||||
if (result.outcome === 'deferred') {
|
||||
toast.message(result.reason)
|
||||
} else {
|
||||
setName('')
|
||||
setTargetId('')
|
||||
}
|
||||
onChanged()
|
||||
await reload()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setDeploying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resume = async (row: OrcadManagedPendingMigrationRow): Promise<void> => {
|
||||
try {
|
||||
const result = await api.convertSshHost({ sshTargetId: row.sshTargetId, name: row.name })
|
||||
if (result.outcome !== 'converted') {
|
||||
toast.message(result.reason)
|
||||
}
|
||||
onChanged()
|
||||
await reload()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" id="managed-servers">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translate('auto.components.settings.managedServers.title', 'Managed servers')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.description',
|
||||
'Orca installs and runs a server on an empty SSH host and keeps it up to date.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label>
|
||||
{translate('auto.components.settings.managedServers.deploy.host', 'SSH host')}
|
||||
</Label>
|
||||
<SshTargetSelect
|
||||
targets={targets}
|
||||
value={targetId}
|
||||
onChange={setTargetId}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.managedServers.deploy.hostPlaceholder',
|
||||
'Choose an empty SSH host'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label>
|
||||
{translate('auto.components.settings.managedServers.deploy.name', 'Server name')}
|
||||
</Label>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={deploying || targetId === '' || name.trim() === ''}
|
||||
onClick={() => void deploy()}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.deploy.submit', 'Deploy server')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{pending.map((row) => (
|
||||
<div key={row.migrationId} className="flex items-center gap-2 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{row.name}</div>
|
||||
<p className="text-xs text-muted-foreground">{migrationPhaseLabel(row.phase)}</p>
|
||||
</div>
|
||||
<Button type="button" size="xs" variant="outline" onClick={() => void resume(row)}>
|
||||
{translate('auto.components.settings.managedServers.pending.resume', 'Resume')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{managed.length > 0 ? (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{managed.map((environment) => (
|
||||
<ManagedServerRow
|
||||
key={environment.id}
|
||||
api={api}
|
||||
environment={environment}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{paired.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.access.title',
|
||||
'SSH access for paired servers'
|
||||
)}
|
||||
</Label>
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{paired.map((environment) => (
|
||||
<RuntimeSshAccessControl
|
||||
key={environment.id}
|
||||
api={api}
|
||||
environment={environment}
|
||||
targets={targets}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcadManagedConversionPreview } from '../../../../shared/orcad-managed-runtime'
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
|
||||
import { MoveToManagedServerDialog } from './MoveToManagedServerDialog'
|
||||
import {
|
||||
redactRuntimeEnvironment,
|
||||
createEnvironmentFromPairingOffer
|
||||
} from '../../../../shared/runtime-environments'
|
||||
import { canMoveHostToManagedServer } from './managed-server-hosts'
|
||||
|
||||
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 roots: Root[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function createEnvironment() {
|
||||
return redactRuntimeEnvironment(
|
||||
createEnvironmentFromPairingOffer({
|
||||
id: 'env-1',
|
||||
name: 'Builder',
|
||||
now: 1,
|
||||
offer: { v: 2, endpoint: 'ws://127.0.0.1:1', deviceToken: 't', publicKeyB64: 'k' }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const target: SshTarget = { id: 'ssh-1', label: 'Builder', host: 'b', port: 22, username: 'dev' }
|
||||
|
||||
function preview(
|
||||
overrides: Partial<OrcadManagedConversionPreview> = {}
|
||||
): OrcadManagedConversionPreview {
|
||||
return {
|
||||
sshTargetId: 'ssh-1',
|
||||
targetLabel: 'Builder',
|
||||
moves: {
|
||||
repositories: 2,
|
||||
projectGroups: 1,
|
||||
folderWorkspaces: 1,
|
||||
automations: 0,
|
||||
workspaceSession: true
|
||||
},
|
||||
blockers: [],
|
||||
terminals: { verdict: 'exited' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function api(next: OrcadManagedConversionPreview): ManagedOrcadPreloadApi {
|
||||
const unused = vi.fn(async () => {
|
||||
throw new Error('not used by the move dialog')
|
||||
})
|
||||
return {
|
||||
deploy: unused,
|
||||
getStatus: unused,
|
||||
update: unused,
|
||||
rollback: unused,
|
||||
recover: unused,
|
||||
stop: unused,
|
||||
cancelStop: unused,
|
||||
linkSshAccess: unused,
|
||||
unlinkSshAccess: unused,
|
||||
previewConversion: vi.fn(async () => next),
|
||||
convertSshHost: vi.fn(async () => ({
|
||||
outcome: 'converted' as const,
|
||||
environment: createEnvironment(),
|
||||
migrationId: 'm-1'
|
||||
})),
|
||||
listPendingMigrations: vi.fn(async () => [])
|
||||
}
|
||||
}
|
||||
|
||||
async function render(managed: ManagedOrcadPreloadApi): Promise<HTMLElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MoveToManagedServerDialog
|
||||
api={managed}
|
||||
target={target}
|
||||
onClose={() => {}}
|
||||
onFinished={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
const moveButton = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll('button')].find((button) => button.textContent === 'Move host')
|
||||
|
||||
describe('Move to managed server', () => {
|
||||
it('summarizes what moves and only enables the move after the terminal confirmation', async () => {
|
||||
const managed = api(preview())
|
||||
const container = await render(managed)
|
||||
expect(container.textContent).toContain('Repositories: 2')
|
||||
expect(container.textContent).toContain('Folder workspaces: 1')
|
||||
expect(moveButton(container)?.disabled).toBe(true)
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[role="checkbox"]')?.click()
|
||||
})
|
||||
expect(moveButton(container)?.disabled).toBe(false)
|
||||
await act(async () => {
|
||||
moveButton(container)?.click()
|
||||
})
|
||||
expect(managed.convertSshHost).toHaveBeenCalledWith({ sshTargetId: 'ssh-1', name: 'Builder' })
|
||||
expect(container.textContent).toContain('Moved.')
|
||||
})
|
||||
|
||||
it('names each blocker and keeps the move disabled', async () => {
|
||||
const container = await render(
|
||||
api(
|
||||
preview({
|
||||
blockers: [
|
||||
{
|
||||
code: 'orcad_migration_dependency_unverifiable',
|
||||
category: 'live-or-unverifiable',
|
||||
sources: ['automation']
|
||||
}
|
||||
],
|
||||
terminals: { verdict: 'unverifiable', ptyIds: ['p'], reason: 'the relay did not answer' }
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(container.textContent).toContain('could not read its saved automations, so')
|
||||
expect(container.textContent).toContain('the relay did not answer')
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[role="checkbox"]')?.click()
|
||||
})
|
||||
expect(moveButton(container)?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed server host eligibility', () => {
|
||||
it('offers a move on any connected host that reported its platform, Windows included', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: eligibility reads only status and remotePlatform.
|
||||
const state = (status: string, remotePlatform?: string) => ({ status, remotePlatform }) as never
|
||||
expect(canMoveHostToManagedServer(state('connected', 'linux'))).toBe(true)
|
||||
expect(canMoveHostToManagedServer(state('connected', 'darwin'))).toBe(true)
|
||||
expect(canMoveHostToManagedServer(state('connected', 'win32'))).toBe(true)
|
||||
expect(canMoveHostToManagedServer(state('disconnected', 'win32'))).toBe(false)
|
||||
expect(canMoveHostToManagedServer(state('connected'))).toBe(false)
|
||||
expect(canMoveHostToManagedServer(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
OrcadManagedConversionPreview,
|
||||
OrcadManagedConversionResult
|
||||
} from '../../../../shared/orcad-managed-runtime'
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { Checkbox } from '../ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { conversionBlockerLabel, conversionMovesSummary } from './managed-server-copy'
|
||||
|
||||
type Phase = 'preview' | 'running' | 'done'
|
||||
|
||||
type MoveToManagedServerDialogProps = {
|
||||
api: ManagedOrcadPreloadApi
|
||||
target: SshTarget | null
|
||||
onClose: () => void
|
||||
onFinished: () => void
|
||||
}
|
||||
|
||||
export function MoveToManagedServerDialog({
|
||||
api,
|
||||
target,
|
||||
onClose,
|
||||
onFinished
|
||||
}: MoveToManagedServerDialogProps): React.JSX.Element {
|
||||
const mountedRef = useMountedRef()
|
||||
const [preview, setPreview] = useState<OrcadManagedConversionPreview | null>(null)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [phase, setPhase] = useState<Phase>('preview')
|
||||
const [result, setResult] = useState<OrcadManagedConversionResult | null>(null)
|
||||
const [runError, setRunError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setPreview(null)
|
||||
setPreviewError(null)
|
||||
setName(target.label)
|
||||
setConfirmed(false)
|
||||
setPhase('preview')
|
||||
setResult(null)
|
||||
setRunError(null)
|
||||
api.previewConversion({ sshTargetId: target.id }).then(
|
||||
(next) => mountedRef.current && setPreview(next),
|
||||
(error: unknown) =>
|
||||
mountedRef.current &&
|
||||
setPreviewError(error instanceof Error ? error.message : String(error))
|
||||
)
|
||||
}, [api, mountedRef, target])
|
||||
|
||||
const blocked = !preview || preview.blockers.length > 0 || preview.terminals.verdict !== 'exited'
|
||||
|
||||
const move = async (): Promise<void> => {
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setPhase('running')
|
||||
setRunError(null)
|
||||
try {
|
||||
const next = await api.convertSshHost({ sshTargetId: target.id, name: name.trim() })
|
||||
if (mountedRef.current) {
|
||||
setResult(next)
|
||||
}
|
||||
onFinished()
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setRunError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setPhase('done')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={target !== null}
|
||||
onOpenChange={(open) => !open && phase !== 'running' && onClose()}
|
||||
>
|
||||
<DialogContent className="max-w-md sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.move.title',
|
||||
'Move to managed server'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.move.description',
|
||||
'Orca installs a server on {{host}} and moves this host’s projects onto it. The host keeps everything until the server confirms it has a copy.',
|
||||
{ host: target?.label ?? '' }
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{phase === 'preview' ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
{previewError ? <p className="text-xs text-destructive">{previewError}</p> : null}
|
||||
{!preview && !previewError ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
<Label>
|
||||
{translate('auto.components.settings.managedServers.move.moves', 'What moves')}
|
||||
</Label>
|
||||
{conversionMovesSummary(preview.moves).map((line) => (
|
||||
<p key={line} className="text-xs text-muted-foreground">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{preview.terminals.verdict !== 'exited' ? (
|
||||
<p className="text-xs text-destructive">{preview.terminals.reason}</p>
|
||||
) : null}
|
||||
{preview.blockers.map((blocker) => (
|
||||
<p key={blocker.code} className="text-xs text-destructive">
|
||||
{conversionBlockerLabel(blocker)}
|
||||
</p>
|
||||
))}
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.deploy.name',
|
||||
'Server name'
|
||||
)}
|
||||
</Label>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={confirmed}
|
||||
onCheckedChange={(value) => setConfirmed(value === true)}
|
||||
/>
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.move.confirm',
|
||||
'I have closed every terminal on this host. Running terminals cannot move and stop the move.'
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === 'running' ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.move.running',
|
||||
'Installing the server and copying projects. You can close Settings; the move resumes from where it stopped.'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === 'done' ? (
|
||||
<p className="text-sm">
|
||||
{runError ??
|
||||
(result?.outcome === 'converted'
|
||||
? translate(
|
||||
'auto.components.settings.managedServers.move.converted',
|
||||
'Moved. {{name}} now serves these projects.',
|
||||
{
|
||||
name: result.environment.name
|
||||
}
|
||||
)
|
||||
: (result?.reason ?? ''))}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
{phase === 'preview' ? (
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{translate('auto.components.settings.managedServers.move.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={blocked || !confirmed || name.trim() === ''}
|
||||
onClick={() => void move()}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.move.submit', 'Move host')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{phase === 'done' ? (
|
||||
<>
|
||||
{result?.outcome !== 'converted' ? (
|
||||
<Button type="button" variant="outline" onClick={() => void move()}>
|
||||
{translate('auto.components.settings.managedServers.move.resume', 'Resume')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" onClick={onClose}>
|
||||
{translate('auto.components.settings.managedServers.move.close', 'Close')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { EphemeralVmRuntimesSection } from './EphemeralVmRuntimesSection'
|
||||
import { ManagedServersSection } from './ManagedServersSection'
|
||||
import { CloudVmSetupGuide } from './CloudVmSetupGuide'
|
||||
import {
|
||||
getRuntimeEnvironmentsSearchEntry,
|
||||
@@ -266,6 +267,13 @@ export function RuntimeEnvironmentsPane({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleWorkflow === 'connect' && settings.experimentalManagedServers === true ? (
|
||||
<ManagedServersSection
|
||||
environments={environments}
|
||||
onChanged={() => void loadEnvironments()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleWorkflow === 'connect' ? <RuntimeServerTroubleshooting /> : null}
|
||||
|
||||
<RuntimeEnvironmentSwitchDialog
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { SshTargetSelect } from './SshTargetSelect'
|
||||
|
||||
const DEFAULT_REMOTE_PORT = '6768'
|
||||
|
||||
type RuntimeSshAccessControlProps = {
|
||||
api: ManagedOrcadPreloadApi
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
targets: SshTarget[]
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
/** Reaches an already-paired server through an SSH tunnel instead of its network address. */
|
||||
export function RuntimeSshAccessControl({
|
||||
api,
|
||||
environment,
|
||||
targets,
|
||||
onChanged
|
||||
}: RuntimeSshAccessControlProps): React.JSX.Element {
|
||||
const [targetId, setTargetId] = useState('')
|
||||
const [remotePort, setRemotePort] = useState(DEFAULT_REMOTE_PORT)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const linked = environment.sshAccess
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const requestId = createBrowserUuid()
|
||||
await (linked
|
||||
? api.unlinkSshAccess({ selector: environment.id, requestId })
|
||||
: api.linkSshAccess({
|
||||
selector: environment.id,
|
||||
requestId,
|
||||
sshTargetId: targetId,
|
||||
remotePort: Number(remotePort)
|
||||
}))
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (linked) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<div className="min-w-0 flex-1 truncate text-sm">
|
||||
{translate('auto.components.settings.managedServers.access.linked', '{{name}} via SSH', {
|
||||
name: environment.name
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.access.unlink', 'Stop using SSH')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const port = Number(remotePort)
|
||||
const valid = targetId !== '' && Number.isInteger(port) && port >= 1 && port <= 65_535
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2 px-4 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label>{environment.name}</Label>
|
||||
<SshTargetSelect
|
||||
targets={targets}
|
||||
value={targetId}
|
||||
onChange={setTargetId}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.managedServers.access.target',
|
||||
'Reach through SSH host'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 space-y-1">
|
||||
<Label>
|
||||
{translate('auto.components.settings.managedServers.access.port', 'Server port')}
|
||||
</Label>
|
||||
<Input
|
||||
value={remotePort}
|
||||
inputMode="numeric"
|
||||
onChange={(event) => setRemotePort(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" disabled={busy || !valid} onClick={() => void submit()}>
|
||||
{translate('auto.components.settings.managedServers.access.link', 'Use SSH')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
terminateSshSessionsWithReconnect
|
||||
} from './ssh-session-termination'
|
||||
import { SshTargetCard } from './SshTargetCard'
|
||||
import { SshTargetManagedServerAction } from './SshTargetManagedServerAction'
|
||||
import { SshTargetsEmptyState } from './SshTargetsEmptyState'
|
||||
import { SshTargetDestructiveActions } from './SshTargetDestructiveActions'
|
||||
import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm'
|
||||
import { getEditingTargetForSshTarget } from './ssh-target-draft'
|
||||
@@ -375,33 +377,30 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
|
||||
<>
|
||||
{/* Target list */}
|
||||
{targets.length === 0 ? (
|
||||
<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">
|
||||
{translate(
|
||||
'auto.components.settings.SshPane.c0f1c80166',
|
||||
'No SSH targets configured.'
|
||||
)}
|
||||
</div>
|
||||
<SshTargetsEmptyState />
|
||||
) : (
|
||||
<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) =>
|
||||
requestRemoveTarget({ id, label: target.label }, requestRemove)
|
||||
}
|
||||
/>
|
||||
<div key={target.id} className="space-y-1">
|
||||
<SshTargetCard
|
||||
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) =>
|
||||
requestRemoveTarget({ id, label: target.label }, requestRemove)
|
||||
}
|
||||
/>
|
||||
<SshTargetManagedServerAction target={target} onMoved={loadTargets} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ArrowRightLeft } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '../ui/button'
|
||||
import { MoveToManagedServerDialog } from './MoveToManagedServerDialog'
|
||||
import { canMoveHostToManagedServer } from './managed-server-hosts'
|
||||
|
||||
type SshTargetManagedServerActionProps = {
|
||||
target: SshTarget
|
||||
onMoved: () => unknown
|
||||
}
|
||||
|
||||
/** "Move to managed server" under an SSH host, behind the Managed servers experiment. */
|
||||
export function SshTargetManagedServerAction({
|
||||
target,
|
||||
onMoved
|
||||
}: SshTargetManagedServerActionProps): React.JSX.Element | null {
|
||||
const enabled = useAppStore((s) => s.settings?.experimentalManagedServers === true)
|
||||
const state = useAppStore((s) => s.sshConnectionStates.get(target.id))
|
||||
const api = window.api.runtimeEnvironments.managedOrcad
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!enabled || !api || !canMoveHostToManagedServer(state)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end px-1">
|
||||
<Button type="button" size="xs" variant="ghost" onClick={() => setOpen(true)}>
|
||||
<ArrowRightLeft />
|
||||
{translate(
|
||||
'auto.components.settings.managedServers.move.action',
|
||||
'Move to managed server'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<MoveToManagedServerDialog
|
||||
api={api}
|
||||
target={open ? target : null}
|
||||
onClose={() => setOpen(false)}
|
||||
onFinished={() => void onMoved()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
|
||||
type SshTargetSelectProps = {
|
||||
targets: SshTarget[]
|
||||
value: string
|
||||
onChange: (targetId: string) => void
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
export function SshTargetSelect({
|
||||
targets,
|
||||
value,
|
||||
onChange,
|
||||
placeholder
|
||||
}: SshTargetSelectProps): React.JSX.Element {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targets.map((target) => (
|
||||
<SelectItem key={target.id} value={target.id}>
|
||||
{target.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function SshTargetsEmptyState(): React.JSX.Element {
|
||||
return (
|
||||
<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">
|
||||
{translate('auto.components.settings.SshPane.c0f1c80166', 'No SSH targets configured.')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { translateSearchKeyword } from './settings-search-keywords'
|
||||
import { getNewWorktreeCardStyleSearchEntry } from './new-worktree-card-style-search-entry'
|
||||
import { getNativeChatExperimentalSearchEntry } from './native-chat-experimental-search-entry'
|
||||
import { getEphemeralVmsSearchEntry } from './ephemeral-vms-search'
|
||||
import { getManagedServersSearchEntry } from './managed-servers-search'
|
||||
|
||||
export const getExperimentalPaneSearchEntries = createLocalizedCatalog(
|
||||
(): SettingsSearchEntry[] => [
|
||||
@@ -180,7 +181,8 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog(
|
||||
]
|
||||
},
|
||||
getNewWorktreeCardStyleSearchEntry(),
|
||||
getEphemeralVmsSearchEntry()
|
||||
getEphemeralVmsSearchEntry(),
|
||||
getManagedServersSearchEntry()
|
||||
]
|
||||
)
|
||||
|
||||
@@ -224,6 +226,9 @@ export function getExperimentalSearchEntry() {
|
||||
),
|
||||
ephemeralVms: findEntry(
|
||||
translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM')
|
||||
),
|
||||
managedServers: findEntry(
|
||||
translate('auto.components.settings.managedServers.search.title', 'Managed servers')
|
||||
)
|
||||
} as const
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ORCAD_MIGRATION_DEPENDENCY_KINDS } from '../../../../shared/orcad-migration-preflight'
|
||||
import { conversionBlockerLabel } from './managed-server-copy'
|
||||
import { dependencyKindLabel } from './managed-server-dependency-kinds'
|
||||
|
||||
describe('managed server blocker copy', () => {
|
||||
it('names dependent state in plain words, never the internal kind id', () => {
|
||||
expect(
|
||||
conversionBlockerLabel({
|
||||
code: 'orcad_migration_dependent_state',
|
||||
category: 'client-owned-state',
|
||||
dependencies: [{ kind: 'workspace-session', count: 1 }]
|
||||
})
|
||||
).toBe('State that cannot move yet: open tabs and layout (1).')
|
||||
expect(
|
||||
conversionBlockerLabel({
|
||||
code: 'orcad_migration_dependency_unverifiable',
|
||||
category: 'live-or-unverifiable',
|
||||
sources: ['automation', 'ui-routing']
|
||||
})
|
||||
).toContain('automations, sidebar and filter settings')
|
||||
})
|
||||
|
||||
it('has a label for every dependency kind', () => {
|
||||
for (const kind of ORCAD_MIGRATION_DEPENDENCY_KINDS) {
|
||||
expect(dependencyKindLabel(kind)).not.toBe(kind)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
/** User-facing words for managed-server states; every string goes through the catalog. */
|
||||
import type {
|
||||
OrcadManagedConversionPreview,
|
||||
OrcadManagedPendingMigrationRow,
|
||||
OrcadManagedRuntimeStatus
|
||||
} from '../../../../shared/orcad-managed-runtime'
|
||||
import type { OrcadMigrationBlocker } from '../../../../shared/orcad-migration-preflight'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { dependencyKindLabel } from './managed-server-dependency-kinds'
|
||||
|
||||
export function migrationPhaseLabel(phase: OrcadManagedPendingMigrationRow['phase']): string {
|
||||
switch (phase) {
|
||||
case 'source-fenced':
|
||||
return translate('auto.components.settings.managedServers.phase.fenced', 'Host locked')
|
||||
case 'destination-staged':
|
||||
return translate('auto.components.settings.managedServers.phase.staged', 'Copy staged')
|
||||
case 'destination-committed':
|
||||
return translate('auto.components.settings.managedServers.phase.committed', 'Copy committed')
|
||||
case 'source-retired':
|
||||
return translate('auto.components.settings.managedServers.phase.retired', 'Finished')
|
||||
}
|
||||
}
|
||||
|
||||
export function recoveryLabel(
|
||||
recovery: NonNullable<OrcadManagedRuntimeStatus['recovery']>
|
||||
): string {
|
||||
switch (recovery.operation) {
|
||||
case 'activate':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.recovery.activate',
|
||||
'An update was interrupted'
|
||||
)
|
||||
case 'rollback':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.recovery.rollback',
|
||||
'A rollback was interrupted'
|
||||
)
|
||||
case 'decommission':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.recovery.decommission',
|
||||
'A stop was interrupted'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` counts are unknown, never zero: the server did not answer. */
|
||||
export function terminalCensusLabel(terminals: OrcadManagedRuntimeStatus['terminals']): string {
|
||||
if (terminals.liveSessions === null) {
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.terminals.unknown',
|
||||
'Running terminals: unknown'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.terminals.count',
|
||||
'Running terminals: {{count}}',
|
||||
{ count: terminals.liveSessions }
|
||||
)
|
||||
}
|
||||
|
||||
export function conversionBlockerLabel(blocker: OrcadMigrationBlocker): string {
|
||||
switch (blocker.code) {
|
||||
case 'orcad_migration_target_not_found':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.blocker.notFound',
|
||||
'The SSH host is gone.'
|
||||
)
|
||||
case 'orcad_migration_target_owned':
|
||||
case 'orcad_migration_owner_unrecorded':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.blocker.owned',
|
||||
'This SSH host already belongs to a managed server.'
|
||||
)
|
||||
case 'orcad_migration_direct_ssh_repositories':
|
||||
case 'orcad_migration_direct_ssh_folder_workspaces':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.blocker.catalog',
|
||||
'Projects on this host cannot move yet.'
|
||||
)
|
||||
case 'orcad_migration_direct_ssh_terminal_leases':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.blocker.terminals',
|
||||
'Terminals on this host are still running ({{count}}). Close them first.',
|
||||
{ count: blocker.terminalLeases.length }
|
||||
)
|
||||
case 'orcad_migration_saved_port_forwards':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.blocker.portForwards',
|
||||
'Saved port forwards stay with the SSH host.'
|
||||
)
|
||||
case 'orcad_migration_dependent_state':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.blocker.dependents',
|
||||
'State that cannot move yet: {{kinds}}.',
|
||||
{
|
||||
kinds: blocker.dependencies
|
||||
.map((dependency) => `${dependencyKindLabel(dependency.kind)} (${dependency.count})`)
|
||||
.join(', ')
|
||||
}
|
||||
)
|
||||
case 'orcad_migration_dependency_unverifiable':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.blocker.unverifiable',
|
||||
'Orca could not read its saved {{sources}}, so it cannot tell what would move.',
|
||||
{ sources: blocker.sources.map(dependencyKindLabel).join(', ') }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function conversionMovesSummary(moves: OrcadManagedConversionPreview['moves']): string[] {
|
||||
const lines = [
|
||||
translate(
|
||||
'auto.components.settings.managedServers.moves.repositories',
|
||||
'Repositories: {{count}}',
|
||||
{
|
||||
count: moves.repositories
|
||||
}
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.managedServers.moves.folderWorkspaces',
|
||||
'Folder workspaces: {{count}}',
|
||||
{ count: moves.folderWorkspaces }
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.managedServers.moves.projectGroups',
|
||||
'Project groups: {{count}}',
|
||||
{
|
||||
count: moves.projectGroups
|
||||
}
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.managedServers.moves.automations',
|
||||
'Automations: {{count}}',
|
||||
{
|
||||
count: moves.automations
|
||||
}
|
||||
)
|
||||
]
|
||||
if (moves.workspaceSession) {
|
||||
lines.push(
|
||||
translate(
|
||||
'auto.components.settings.managedServers.moves.session',
|
||||
'Closed tabs and saved layouts'
|
||||
)
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Plain names for the kinds of saved state that can keep a host from moving. */
|
||||
import type { OrcadMigrationDependencyKind } from '../../../../shared/orcad-migration-preflight'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function dependencyKindLabel(kind: OrcadMigrationDependencyKind): string {
|
||||
switch (kind) {
|
||||
case 'saved-port-forward':
|
||||
return translate('auto.components.settings.managedServers.kind.portForward', 'port forwards')
|
||||
case 'terminal-lease':
|
||||
return translate('auto.components.settings.managedServers.kind.terminalLease', 'terminals')
|
||||
case 'terminal-recovery':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.terminalRecovery',
|
||||
'terminal recovery records'
|
||||
)
|
||||
case 'workspace-session':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.workspaceSession',
|
||||
'open tabs and layout'
|
||||
)
|
||||
case 'worktree-metadata':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.worktreeMetadata',
|
||||
'worktree details'
|
||||
)
|
||||
case 'worktree-lineage':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.worktreeLineage',
|
||||
'worktree history'
|
||||
)
|
||||
case 'workspace-lineage':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.workspaceLineage',
|
||||
'workspace history'
|
||||
)
|
||||
case 'sparse-preset':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.sparsePreset',
|
||||
'sparse checkout presets'
|
||||
)
|
||||
case 'retired-worktree-name':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.retiredWorktreeName',
|
||||
'retired worktree names'
|
||||
)
|
||||
case 'automation':
|
||||
return translate('auto.components.settings.managedServers.kind.automation', 'automations')
|
||||
case 'automation-run':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.automationRun',
|
||||
'automation runs'
|
||||
)
|
||||
case 'mobile-tab-selection':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.mobileTabSelection',
|
||||
'mobile tab selections'
|
||||
)
|
||||
case 'ui-routing':
|
||||
return translate(
|
||||
'auto.components.settings.managedServers.kind.uiRouting',
|
||||
'sidebar and filter settings'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
|
||||
/** "Move to managed server" needs a connected relay that has reported its platform (any of them). */
|
||||
export function canMoveHostToManagedServer(state: SshConnectionState | undefined): boolean {
|
||||
return state?.status === 'connected' && state.remotePlatform !== undefined
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { translateSearchKeyword } from './settings-search-keywords'
|
||||
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
|
||||
export const getManagedServersSearchEntry = createLocalizedCatalog((): SettingsSearchEntry => ({
|
||||
title: translate('auto.components.settings.managedServers.search.title', 'Managed servers'),
|
||||
description: translate(
|
||||
'auto.components.settings.managedServers.search.description',
|
||||
'Deploy and run an Orca server on your own SSH hosts, and move an SSH host’s projects onto one.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.experimental.search.0d24759f14',
|
||||
'experimental'
|
||||
),
|
||||
...translateSearchKeyword('auto.components.settings.managedServers.search.keywordSsh', 'ssh'),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.managedServers.search.keywordServer',
|
||||
'server'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.managedServers.search.keywordMigrate',
|
||||
'migrate'
|
||||
)
|
||||
],
|
||||
targetSectionId: 'managed-servers'
|
||||
}))
|
||||
+2
-5
@@ -2803,16 +2803,13 @@
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"failedAfter": "Failed after {{value0}}",
|
||||
"interruptedAfter": "Interrupted after {{value0}}",
|
||||
"responding": "Agent is responding",
|
||||
"toggleDetails": "Toggle turn details",
|
||||
"workedFor": "Worked for {{value0}}",
|
||||
"interruptedAfter": "Interrupted after {{value0}}",
|
||||
"failedAfter": "Failed after {{value0}}",
|
||||
"workingFor": "Working for {{value0}}"
|
||||
},
|
||||
"subagents": {
|
||||
"unnamed": "Subagent"
|
||||
},
|
||||
"tool": {
|
||||
"countN": "{{value0}} tool calls",
|
||||
"countOne": "1 tool call",
|
||||
|
||||
@@ -12254,6 +12254,110 @@
|
||||
},
|
||||
"UnsealedCredentialNotice": {
|
||||
"body": "{{credential}} is stored unencrypted — this system has no OS keyring Orca can use. Anyone who can read your disk or a backup of it can read the credential. Install and unlock gnome-keyring or kwallet, then save it again to seal it."
|
||||
},
|
||||
"managedServers": {
|
||||
"row": {
|
||||
"notRunning": "Not running",
|
||||
"refresh": "Refresh status",
|
||||
"migration": "Migration: {{phase}}",
|
||||
"deferred": "Update to {{version}} deferred: {{reason}}",
|
||||
"update": "Update",
|
||||
"rollback": "Roll back",
|
||||
"recover": "Recover",
|
||||
"cancelStop": "Cancel stop",
|
||||
"confirmStop": "Stop and remove server",
|
||||
"keep": "Keep running",
|
||||
"stop": "Stop…"
|
||||
},
|
||||
"title": "Managed servers",
|
||||
"description": "Orca installs and runs a server on an empty SSH host and keeps it up to date.",
|
||||
"deploy": {
|
||||
"host": "SSH host",
|
||||
"hostPlaceholder": "Choose an empty SSH host",
|
||||
"name": "Server name",
|
||||
"submit": "Deploy server"
|
||||
},
|
||||
"pending": {
|
||||
"resume": "Resume"
|
||||
},
|
||||
"access": {
|
||||
"title": "SSH access for paired servers",
|
||||
"linked": "{{name}} via SSH",
|
||||
"unlink": "Stop using SSH",
|
||||
"target": "Reach through SSH host",
|
||||
"port": "Server port",
|
||||
"link": "Use SSH"
|
||||
},
|
||||
"move": {
|
||||
"title": "Move to managed server",
|
||||
"description": "Orca installs a server on {{host}} and moves this host’s projects onto it. The host keeps everything until the server confirms it has a copy.",
|
||||
"moves": "What moves",
|
||||
"confirm": "I have closed every terminal on this host. Running terminals cannot move and stop the move.",
|
||||
"running": "Installing the server and copying projects. You can close Settings; the move resumes from where it stopped.",
|
||||
"converted": "Moved. {{name}} now serves these projects.",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Move host",
|
||||
"resume": "Resume",
|
||||
"close": "Close",
|
||||
"action": "Move to managed server"
|
||||
},
|
||||
"search": {
|
||||
"title": "Managed servers",
|
||||
"description": "Deploy and run an Orca server on your own SSH hosts, and move an SSH host’s projects onto one.",
|
||||
"keywordSsh": "ssh",
|
||||
"keywordServer": "server",
|
||||
"keywordMigrate": "migrate"
|
||||
},
|
||||
"phase": {
|
||||
"fenced": "Host locked",
|
||||
"staged": "Copy staged",
|
||||
"committed": "Copy committed",
|
||||
"retired": "Finished"
|
||||
},
|
||||
"recovery": {
|
||||
"activate": "An update was interrupted",
|
||||
"rollback": "A rollback was interrupted",
|
||||
"decommission": "A stop was interrupted"
|
||||
},
|
||||
"terminals": {
|
||||
"unknown": "Running terminals: unknown",
|
||||
"count": "Running terminals: {{count}}"
|
||||
},
|
||||
"blocker": {
|
||||
"notFound": "The SSH host is gone.",
|
||||
"owned": "This SSH host already belongs to a managed server.",
|
||||
"catalog": "Projects on this host cannot move yet.",
|
||||
"terminals": "Terminals on this host are still running ({{count}}). Close them first.",
|
||||
"portForwards": "Saved port forwards stay with the SSH host.",
|
||||
"dependents": "State that cannot move yet: {{kinds}}.",
|
||||
"unverifiable": "Orca could not read its saved {{sources}}, so it cannot tell what would move."
|
||||
},
|
||||
"moves": {
|
||||
"repositories": "Repositories: {{count}}",
|
||||
"folderWorkspaces": "Folder workspaces: {{count}}",
|
||||
"projectGroups": "Project groups: {{count}}",
|
||||
"automations": "Automations: {{count}}",
|
||||
"session": "Closed tabs and saved layouts"
|
||||
},
|
||||
"kind": {
|
||||
"portForward": "port forwards",
|
||||
"terminalLease": "terminals",
|
||||
"terminalRecovery": "terminal recovery records",
|
||||
"workspaceSession": "open tabs and layout",
|
||||
"worktreeMetadata": "worktree details",
|
||||
"worktreeLineage": "worktree history",
|
||||
"workspaceLineage": "workspace history",
|
||||
"sparsePreset": "sparse checkout presets",
|
||||
"retiredWorktreeName": "retired worktree names",
|
||||
"automation": "automations",
|
||||
"automationRun": "automation runs",
|
||||
"mobileTabSelection": "mobile tab selections",
|
||||
"uiRouting": "sidebar and filter settings"
|
||||
}
|
||||
},
|
||||
"managedServersExperimentalSetting": {
|
||||
"description": "Shows Managed servers under Remote Orca Servers, and Move to managed server on SSH hosts. macOS and Linux hosts only.",
|
||||
"toggleLabel": "Toggle Managed servers"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -252,6 +252,7 @@ export function buildDefaultSettings(args: {
|
||||
agentHibernationIdleMs: 30 * 60 * 1000,
|
||||
experimentalNewWorktreeCardStyle: false,
|
||||
experimentalEphemeralVms: false,
|
||||
experimentalManagedServers: false,
|
||||
compactWorktreeCards: false,
|
||||
// Why: local desktop stays the default until the user picks a saved runtime environment.
|
||||
activeRuntimeEnvironmentId: null,
|
||||
|
||||
@@ -497,6 +497,8 @@ export type GlobalSettings = {
|
||||
experimentalNewWorktreeCardStyle?: boolean
|
||||
/** Experimental: per-workspace on-demand environment recipes and setup surface. */
|
||||
experimentalEphemeralVms?: boolean
|
||||
/** Experimental: deploy managed Orca servers over SSH and move SSH hosts onto them. */
|
||||
experimentalManagedServers?: boolean
|
||||
/** Compact worktree cards: hide the metadata row when title and branch say the same thing. */
|
||||
compactWorktreeCards: boolean
|
||||
/** Legacy persisted key from the Experimental rollout; new writes use compactWorktreeCards. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PublicKnownRuntimeEnvironment } from './runtime-environments'
|
||||
import type { OrcadTerminalCensus } from './orcad-terminal-census'
|
||||
import type { OrcadMigrationBlocker } from './orcad-migration-preflight'
|
||||
|
||||
export const ORCAD_MANAGED_REMOTE_PORT = 6_768
|
||||
|
||||
@@ -118,3 +119,29 @@ export type OrcadManagedConversionResult =
|
||||
code: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** What converting an SSH host would move, and what stops it, before anything is touched. */
|
||||
export type OrcadManagedConversionPreview = {
|
||||
sshTargetId: string
|
||||
targetLabel: string | null
|
||||
moves: {
|
||||
repositories: number
|
||||
projectGroups: number
|
||||
folderWorkspaces: number
|
||||
automations: number
|
||||
workspaceSession: boolean
|
||||
}
|
||||
blockers: OrcadMigrationBlocker[]
|
||||
terminals:
|
||||
| { verdict: 'exited' }
|
||||
| { verdict: 'live' | 'unverifiable'; ptyIds: string[]; reason: string }
|
||||
}
|
||||
|
||||
export type OrcadManagedPendingMigrationRow = {
|
||||
migrationId: string
|
||||
environmentId: string
|
||||
name: string
|
||||
sshTargetId: string
|
||||
phase: 'source-fenced' | 'destination-staged' | 'destination-committed' | 'source-retired'
|
||||
startedAt: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user