mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
fix(ports): keep port forwarding controls for an SSH host that moved to its managed server (#29427)
A moved host's workspaces are owned by its managed server, so the Ports panel showed the server's port scan and dropped the forwarding list and Add button, though the SSH connection under the server's tunnel still carries and restores this client's forwards. Show that host's forwards and Add control above the server's scan. A forward listens on this machine, so its rows open in the system browser here.
This commit is contained in:
@@ -6,7 +6,11 @@ import { translate } from '@/i18n/i18n'
|
||||
import { LocalWorkspacePortsPanel } from './local-workspace-ports-panel'
|
||||
import { SshPortsPanel } from './ssh-ports-panel'
|
||||
import { HostScopedPortsPanel } from './host-scoped-ports-panel'
|
||||
import { getPortsPanelOwnerKey, portsPanelHostForOwnerKey } from './ports-panel-host'
|
||||
import {
|
||||
getPortsPanelOwnerKey,
|
||||
portsPanelForwardTargetId,
|
||||
portsPanelHostForOwnerKey
|
||||
} from './ports-panel-host'
|
||||
|
||||
export { getLocalWorkspacePortSections } from './local-workspace-port-sections'
|
||||
export {
|
||||
@@ -20,6 +24,9 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React
|
||||
const workspaceId = activeWorktree?.id ?? null
|
||||
const ownerKey = useAppStore((state) => getPortsPanelOwnerKey(state, workspaceId))
|
||||
const host = useMemo(() => portsPanelHostForOwnerKey(ownerKey), [ownerKey])
|
||||
const sshForwardTargetId = useAppStore((state) =>
|
||||
portsPanelForwardTargetId(host, state.runtimeEnvironments)
|
||||
)
|
||||
const scopedHost = useMemo(
|
||||
() =>
|
||||
host.kind === 'host-scoped' && workspaceId
|
||||
@@ -38,7 +45,13 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React
|
||||
case 'direct-ssh':
|
||||
return <SshPortsPanel key={host.connectionId} activeConnectionId={host.connectionId} />
|
||||
case 'endpoint':
|
||||
return <LocalWorkspacePortsPanel isVisible={isVisible} runtimeTarget={host.target} />
|
||||
return (
|
||||
<LocalWorkspacePortsPanel
|
||||
isVisible={isVisible}
|
||||
runtimeTarget={host.target}
|
||||
sshForwardTargetId={sshForwardTargetId}
|
||||
/>
|
||||
)
|
||||
case 'host-scoped':
|
||||
case 'unknown':
|
||||
return (
|
||||
|
||||
@@ -25,15 +25,19 @@ import {
|
||||
} from './local-workspace-port-sections'
|
||||
import { LocalPortSection } from './local-port-section'
|
||||
import { LocalPortDetailsDialog } from './local-port-details-dialog'
|
||||
import { SshForwardedPortsSection } from './ssh-forwarded-ports-section'
|
||||
|
||||
/** Right-sidebar Ports panel for a workspace that runs on an endpoint's own machine. */
|
||||
export function LocalWorkspacePortsPanel({
|
||||
isVisible,
|
||||
runtimeTarget
|
||||
runtimeTarget,
|
||||
sshForwardTargetId = null
|
||||
}: {
|
||||
isVisible: boolean
|
||||
/** The endpoint that owns the active workspace, even if the sidebar is focused elsewhere. */
|
||||
runtimeTarget: RuntimeClientTarget | null
|
||||
/** A managed server's SSH host, whose connection carries this client's forwards. */
|
||||
sshForwardTargetId?: string | null
|
||||
}): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
@@ -227,6 +231,8 @@ export function LocalWorkspacePortsPanel({
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{sshForwardTargetId && <SshForwardedPortsSection connectionId={sshForwardTargetId} />}
|
||||
|
||||
{displayScan?.unavailableReason && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground border-b border-border">
|
||||
{translate(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '@/lib/worktree-operation-route'
|
||||
import {
|
||||
getPortsPanelOwnerKey,
|
||||
portsPanelForwardTargetId,
|
||||
portsPanelHostForOwnerKey,
|
||||
portsPanelReachesSshHost,
|
||||
type PortsPanelHost
|
||||
@@ -187,3 +188,32 @@ describe('Ports tab visibility', () => {
|
||||
expect(portsPanelReachesSshHost(host, environments)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ports panel forwards', () => {
|
||||
const ports = { localPort: 41_000, remotePort: 6768 }
|
||||
const environments = [
|
||||
{ id: 'env-paired' },
|
||||
{ id: 'env-ssh', orcadDeployment: { sshTargetId: 'box', sshTargetGeneration: 1, ...ports } },
|
||||
{ id: 'env-wsl', orcadDeployment: { kind: 'wsl' as const, distro: 'Ubuntu', ...ports } }
|
||||
]
|
||||
const onServer = (environmentId: string): PortsPanelHost => ({
|
||||
kind: 'endpoint',
|
||||
target: { kind: 'environment', environmentId }
|
||||
})
|
||||
|
||||
// #29153: a moved host's workspace is owned by its server, yet forwards still ride its SSH host.
|
||||
it.each<[string, PortsPanelHost, string | null]>([
|
||||
['a workspace on a managed server reached over SSH', onServer('env-ssh'), 'box'],
|
||||
['a workspace on a managed WSL server', onServer('env-wsl'), null],
|
||||
['a workspace on a paired server', onServer('env-paired'), null],
|
||||
['a workspace on an unknown server', onServer('env-gone'), null],
|
||||
['a local workspace', { kind: 'endpoint', target: { kind: 'local' } }, null],
|
||||
[
|
||||
'a direct SSH workspace, which lists its own forwards',
|
||||
{ kind: 'direct-ssh', connectionId: 'box' },
|
||||
null
|
||||
]
|
||||
])('%s', (_name, host, targetId) => {
|
||||
expect(portsPanelForwardTargetId(host, environments)).toBe(targetId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,10 @@ import { resolveOwner } from '@/lib/resolve-owner'
|
||||
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
|
||||
import { hostRouteForAuthority, type HostRoute } from '@/runtime/runtime-client-target'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import {
|
||||
getOrcadSshDeployment,
|
||||
type OrcadDeploymentLink
|
||||
} from '../../../../shared/runtime-environments'
|
||||
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
authorityKey,
|
||||
@@ -77,3 +81,20 @@ export function portsPanelReachesSshHost(
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH host whose connection carries this client's forwards for a managed server's workspace.
|
||||
* Why: the server scans its own ports, but a forward listens on this machine, so it rides the SSH
|
||||
* connection under the server's tunnel, as it did before the host moved (#29153).
|
||||
*/
|
||||
export function portsPanelForwardTargetId(
|
||||
host: PortsPanelHost,
|
||||
environments: readonly { id: string; orcadDeployment?: OrcadDeploymentLink }[]
|
||||
): string | null {
|
||||
if (host.kind !== 'endpoint' || host.target.kind !== 'environment') {
|
||||
return null
|
||||
}
|
||||
const { environmentId } = host.target
|
||||
const environment = environments.find((candidate) => candidate.id === environmentId)
|
||||
return (environment && getOrcadSshDeployment(environment)?.sshTargetId) || null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PortForwardEntry } from '../../../../shared/ssh-types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '@/store'
|
||||
import { SshForwardedPortsSection } from './ssh-forwarded-ports-section'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
|
||||
const forward: PortForwardEntry = {
|
||||
id: 'fwd-1',
|
||||
connectionId: 'box',
|
||||
localPort: 3000,
|
||||
remoteHost: 'localhost',
|
||||
remotePort: 3000,
|
||||
label: 'web'
|
||||
}
|
||||
const remapped: PortForwardEntry = { ...forward, id: 'fwd-2', localPort: 13000, label: 'remapped' }
|
||||
|
||||
function renderSection(status: 'connected' | 'disconnected', forwards: PortForwardEntry[]) {
|
||||
useAppStore.setState({
|
||||
portForwardsByConnection: { box: forwards },
|
||||
sshConnectionStates: new Map([
|
||||
['box', { targetId: 'box', status, error: null, reconnectAttempt: 0 }]
|
||||
])
|
||||
})
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<SshForwardedPortsSection connectionId="box" />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
describe('SSH forwards for a managed server workspace', () => {
|
||||
it("lists the host's saved forwards", () => {
|
||||
renderSection('connected', [forward])
|
||||
expect(screen.getByText('web')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the forward dialog for the SSH host under the server', () => {
|
||||
renderSection('connected', [])
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add/ }))
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('cannot add a forward while the SSH host is down', () => {
|
||||
renderSection('disconnected', [])
|
||||
expect(screen.getByRole('button', { name: /Add/ }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it("opens a forward's local address on this machine, where it listens", () => {
|
||||
const openUrl = vi.fn(async () => undefined)
|
||||
Object.defineProperty(window, 'api', { value: { shell: { openUrl } }, configurable: true })
|
||||
renderSection('connected', [remapped])
|
||||
fireEvent.click(screen.getByTitle(/Open in Browser/))
|
||||
expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:13000')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronRight, Plus } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { PortForwardEntry } from '../../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SshForwardedPortRow } from './ssh-forwarded-port-row'
|
||||
import { SshPortForwardDialog, type PortForwardDialogState } from './ssh-port-forward-dialog'
|
||||
import { browserUrlForPortForwardEntry } from '@/lib/workspace-port-urls'
|
||||
|
||||
const NO_FORWARDS: readonly PortForwardEntry[] = []
|
||||
|
||||
/** This client's forwards through an SSH host whose ports another endpoint scans. */
|
||||
export function SshForwardedPortsSection({
|
||||
connectionId
|
||||
}: {
|
||||
connectionId: string
|
||||
}): React.JSX.Element {
|
||||
const forwards = useAppStore((s) => s.portForwardsByConnection[connectionId] ?? NO_FORWARDS)
|
||||
const connected = useAppStore(
|
||||
(s) => s.sshConnectionStates.get(connectionId)?.status === 'connected'
|
||||
)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [dialogState, setDialogState] = useState<PortForwardDialogState>({ mode: 'closed' })
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-2" data-ssh-forwarded-ports="">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn('text-muted-foreground transition-transform', !collapsed && 'rotate-90')}
|
||||
/>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{translate('auto.components.right.sidebar.PortsPanel.ddbe58d74e', 'Forwarded')}
|
||||
</span>
|
||||
{forwards.length > 0 && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground/60">{forwards.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!connected}
|
||||
onClick={() => setDialogState({ mode: 'add', defaults: { targetId: connectionId } })}
|
||||
>
|
||||
<Plus />
|
||||
{translate('auto.components.right.sidebar.PortsPanel.a103dae837', 'Add')}
|
||||
</Button>
|
||||
</div>
|
||||
{!collapsed &&
|
||||
(forwards.length > 0 ? (
|
||||
forwards.map((entry) => (
|
||||
<SshForwardedPortRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onEdit={() => setDialogState({ mode: 'edit', entry })}
|
||||
// Why the system browser: the forward listens on this machine, while the workspace's
|
||||
// Orca browser may be the server's or routed through the SSH host.
|
||||
onOpenInBrowser={() =>
|
||||
void window.api.shell.openUrl(browserUrlForPortForwardEntry(entry))
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="py-1 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.PortsPanel.04efd3dad4',
|
||||
'Forward a port to access remote services on your local machine.'
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
<SshPortForwardDialog
|
||||
state={dialogState}
|
||||
activeConnectionId={connectionId}
|
||||
onClose={() => setDialogState({ mode: 'closed' })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user