-
+
+
diff --git a/libs/domains/services/feature/src/lib/service-terminal/service-terminal.spec.tsx b/libs/domains/services/feature/src/lib/service-terminal/service-terminal.spec.tsx
index 2f27ff69ead..cababbf505f 100644
--- a/libs/domains/services/feature/src/lib/service-terminal/service-terminal.spec.tsx
+++ b/libs/domains/services/feature/src/lib/service-terminal/service-terminal.spec.tsx
@@ -1,6 +1,12 @@
+import { type QueryClient } from '@tanstack/react-query'
+import { act, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@qovery/shared/util-tests'
+import { useReactQueryWsSubscription } from '@qovery/state/util-queries'
import { ServiceTerminal, type ServiceTerminalProps } from './service-terminal'
+const mockUseRunningStatus = jest.fn()
+
jest.mock('color', () => ({
__esModule: true,
default: () => ({
@@ -13,7 +19,7 @@ jest.mock('@qovery/state/util-queries', () => ({
}))
jest.mock('../..', () => ({
- useRunningStatus: () => ({ data: { pods: [] }, isLoading: false }),
+ useRunningStatus: (...args: unknown[]) => mockUseRunningStatus(...args),
}))
const props: ServiceTerminalProps = {
@@ -23,9 +29,107 @@ const props: ServiceTerminalProps = {
environmentId: '0',
serviceId: '0',
}
+
+const useReactQueryWsSubscriptionMock = jest.mocked(useReactQueryWsSubscription)
+
+const getLatestWsSubscriptionConfig = (): Parameters
[0] => {
+ const latestCall = useReactQueryWsSubscriptionMock.mock.calls.at(-1)
+
+ if (!latestCall) {
+ throw new Error('Expected useReactQueryWsSubscription to be called at least once.')
+ }
+
+ return latestCall[0]
+}
+
describe('ServiceTerminal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseRunningStatus.mockReturnValue({
+ data: {
+ pods: [
+ { name: 'pod-1', containers: [{ name: 'container-1' }] },
+ { name: 'pod-2', containers: [{ name: 'container-2' }] },
+ ],
+ state: 'STOPPED',
+ },
+ isLoading: false,
+ })
+ })
+
it('should match snapshot', () => {
const { baseElement } = renderWithProviders()
expect(baseElement).toMatchSnapshot()
})
+
+ it('should show a retry empty state and disable subscription on terminal launch error', () => {
+ renderWithProviders()
+
+ act(() => {
+ getLatestWsSubscriptionConfig().onClose?.(
+ {} as QueryClient,
+ new CloseEvent('close', { code: 1000, reason: 'No pod exists for this application.' })
+ )
+ })
+
+ expect(screen.getByText('Unable to launch CLI')).toBeInTheDocument()
+ expect(screen.getByText("We could not launch the CLI for this service because it's stopped.")).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Relaunch' })).toBeInTheDocument()
+ expect(useReactQueryWsSubscriptionMock).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false }))
+ })
+
+ it('should show a generic unavailable message when service state is not stopped or in error', () => {
+ mockUseRunningStatus.mockReturnValue({
+ data: {
+ pods: [
+ { name: 'pod-1', containers: [{ name: 'container-1' }] },
+ { name: 'pod-2', containers: [{ name: 'container-2' }] },
+ ],
+ state: 'RUNNING',
+ },
+ isLoading: false,
+ })
+
+ renderWithProviders()
+
+ act(() => {
+ getLatestWsSubscriptionConfig().onClose?.(
+ {} as QueryClient,
+ new CloseEvent('close', { code: 1000, reason: 'No pod exists for this application.' })
+ )
+ })
+
+ expect(screen.getByText('The CLI is currently unavailable for this service.')).toBeInTheDocument()
+ })
+
+ it('should restart terminal launch flow when retrying from empty state', async () => {
+ const user = userEvent.setup()
+ renderWithProviders()
+
+ act(() => {
+ getLatestWsSubscriptionConfig().onClose?.(
+ {} as QueryClient,
+ new CloseEvent('close', { code: 1000, reason: 'No pod exists for this application.' })
+ )
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Relaunch' }))
+
+ await waitFor(() => {
+ expect(screen.queryByText('Unable to launch CLI')).not.toBeInTheDocument()
+ expect(useReactQueryWsSubscriptionMock).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: true }))
+ })
+ })
+
+ it('should show pod and container placeholders', async () => {
+ const { userEvent } = renderWithProviders()
+
+ expect(screen.getByPlaceholderText('Select a pod to connect to')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByPlaceholderText('Select a pod to connect to'))
+ const [firstPodOption] = screen.getAllByRole('option')
+ await userEvent.click(firstPodOption)
+
+ expect(screen.getByPlaceholderText('Select a container to connect to')).toBeInTheDocument()
+ })
})
diff --git a/libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx b/libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx
index a98f40aea31..23f05b525e8 100644
--- a/libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx
+++ b/libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx
@@ -3,14 +3,16 @@ import { AttachAddon } from '@xterm/addon-attach'
import { FitAddon } from '@xterm/addon-fit'
import { type ITerminalAddon } from '@xterm/xterm'
import Color from 'color'
-import { memo, useCallback, useEffect, useMemo, useState } from 'react'
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { XTerm } from 'react-xtermjs'
-import { LoaderSpinner, toast } from '@qovery/shared/ui'
+import { match } from 'ts-pattern'
+import { Button, EmptyState, ExternalLink, Icon, LoaderSpinner, toast } from '@qovery/shared/ui'
import { useTerminalReadiness } from '@qovery/shared/util-hooks'
import { QOVERY_WS } from '@qovery/shared/util-node-env'
import { useReactQueryWsSubscription } from '@qovery/state/util-queries'
import { useRunningStatus } from '../..'
import { InputSearch } from './input-search/input-search'
+import { TerminalShellActionsAddon } from './terminal-shell-banner-addon'
const MemoizedXTerm = memo(XTerm)
@@ -30,9 +32,12 @@ export function ServiceTerminal({
serviceId,
}: ServiceTerminalProps) {
const { data: runningStatuses } = useRunningStatus({ environmentId, serviceId })
+ const hasWrittenShellBannerRef = useRef(false)
const [addons, setAddons] = useState>([])
+ const [terminalLaunchError, setTerminalLaunchError] = useState(null)
const isTerminalLoading = addons.length < 2
+ const isTerminalSubscriptionEnabled = terminalLaunchError === null
const { attachWebSocket, detachWebSocket, isTerminalReady, resetTerminalReadiness } = useTerminalReadiness()
const showDelayedLoader = !isTerminalReady
const fitAddon = addons[0] as FitAddon | undefined
@@ -46,6 +51,19 @@ export function ServiceTerminal({
const foreground = getCssVariableHex('--neutral-12')
const selectionBackground = getCssVariableHex('--brand-3')
const selectionForeground = getCssVariableHex('--neutral-12')
+ const warningTextColor = getCssVariableHex('--warning-11')
+ const accent1TextColor = getCssVariableHex('--accent-11')
+ const positiveTextColor = getCssVariableHex('--positive-11')
+ const subtleTextColor = getCssVariableHex('--neutral-11')
+ const terminalBannerColors = useMemo(
+ () => ({
+ accent1: accent1TextColor,
+ positive: positiveTextColor,
+ subtle: subtleTextColor,
+ warning: warningTextColor,
+ }),
+ [accent1TextColor, positiveTextColor, subtleTextColor, warningTextColor]
+ )
const terminalOptions = useMemo(
() => ({
theme: {
@@ -63,29 +81,74 @@ export function ServiceTerminal({
const [selectedPod, setSelectedPod] = useState()
const [selectedContainer, setSelectedContainer] = useState()
+ const selectedOrDefaultPodName = selectedPod ?? runningStatuses?.pods[0]?.name
+ const selectedOrDefaultContainerName =
+ selectedContainer ?? runningStatuses?.pods.find((pod) => pod.name === selectedOrDefaultPodName)?.containers[0]?.name
+ const connectShellCommand = [
+ `qovery shell https://console.qovery.com/organization/${organizationId}/project/${projectId}/environment/${environmentId}/application/${serviceId}`,
+ selectedOrDefaultPodName ? `--pod=${selectedOrDefaultPodName}` : undefined,
+ selectedOrDefaultContainerName ? `--container=${selectedOrDefaultContainerName}` : undefined,
+ ]
+ .filter((commandPart): commandPart is string => Boolean(commandPart))
+ .join(' ')
+ const portForwardCommand = `qovery port-forward --organization ${organizationId} --project ${projectId} --environment ${environmentId} --service ${serviceId} --port `
+ const getShellCommand = useCallback(() => connectShellCommand, [connectShellCommand])
+ const getPortForwardCommand = useCallback(() => portForwardCommand, [portForwardCommand])
+
const onOpenHandler = useCallback(
(_: QueryClient, event: Event) => {
const websocket = event.target as WebSocket
const fitAddon = new FitAddon()
+ const shouldWriteShellBanner = !hasWrittenShellBannerRef.current
// As WS are open twice in dev mode / strict mode it doesn't happens in production
attachWebSocket(websocket)
- setAddons([fitAddon, new AttachAddon(websocket)])
+ setTerminalLaunchError(null)
+ hasWrittenShellBannerRef.current = true
+ setAddons([
+ fitAddon,
+ new AttachAddon(websocket),
+ new TerminalShellActionsAddon(
+ fitAddon,
+ terminalBannerColors,
+ getPortForwardCommand,
+ getShellCommand,
+ shouldWriteShellBanner
+ ),
+ ])
},
- [attachWebSocket, setAddons]
+ [attachWebSocket, getPortForwardCommand, getShellCommand, setAddons, terminalBannerColors]
)
const onCloseHandler = useCallback(
(_: QueryClient, event: CloseEvent) => {
detachWebSocket()
+ setAddons([])
if (event.code !== 1006 && event.reason) {
+ setTerminalLaunchError(event.reason)
toast('ERROR', 'Not available', event.reason)
}
},
[detachWebSocket]
)
+ const onRetryCliLaunch = useCallback(() => {
+ detachWebSocket()
+ hasWrittenShellBannerRef.current = false
+ setAddons([])
+ setTerminalLaunchError(null)
+ resetTerminalReadiness()
+ }, [detachWebSocket, resetTerminalReadiness])
+ const terminalUnavailableDescription = useMemo(
+ () =>
+ match(runningStatuses?.state)
+ .with('STOPPED', () => "We could not launch the CLI for this service because it's stopped.")
+ .with('ERROR', () => "We could not launch the CLI for this service because it's in error.")
+ .otherwise(() => 'The CLI is currently unavailable for this service.'),
+ [runningStatuses?.state]
+ )
+
// Necesssary to calculate the number of rows and columns (tty) for the terminal
// https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101
// 16 is the font height
@@ -107,9 +170,11 @@ export function ServiceTerminal({
},
onOpen: onOpenHandler,
onClose: onCloseHandler,
+ enabled: isTerminalSubscriptionEnabled,
})
useEffect(() => {
+ hasWrittenShellBannerRef.current = false
resetTerminalReadiness()
}, [resetTerminalReadiness, selectedContainer, selectedPod])
@@ -121,46 +186,81 @@ export function ServiceTerminal({
return (
-
-
+
+
{runningStatuses && runningStatuses.pods.length > 0 && (
-
pod.name)}
- placeholder="Search by pod"
- trimLabel
- />
+
+
pod.name)}
+ placeholder="Select a pod to connect to"
+ size="md"
+ trimLabel
+ />
+ {!selectedPod && (
+
+
+
+ )}
+
)}
{runningStatuses && selectedPod && (
- selectedPod === pod?.name)
- ?.containers.map((container) => container?.name) || []
- }
- placeholder="Search by container"
- />
+
+
selectedPod === pod?.name)
+ ?.containers.map((container) => container?.name) || []
+ }
+ placeholder="Select a container to connect to"
+ size="md"
+ />
+ {!selectedContainer && (
+
+
+
+ )}
+
)}
+
+
+ CLI docs
+
-
- {isTerminalLoading ? (
-
-
-
- ) : (
- <>
-
- {showDelayedLoader && (
-
-
-
- )}
- >
- )}
+
+
+ {terminalLaunchError ? (
+
+
+
+ ) : isTerminalLoading ? (
+
+
+
+ ) : (
+ <>
+
+ {showDelayedLoader && (
+
+
+
+ )}
+ >
+ )}
+
)
diff --git a/libs/domains/services/feature/src/lib/service-terminal/terminal-shell-banner-addon.ts b/libs/domains/services/feature/src/lib/service-terminal/terminal-shell-banner-addon.ts
new file mode 100644
index 00000000000..a2b60882adf
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-terminal/terminal-shell-banner-addon.ts
@@ -0,0 +1,305 @@
+import { FitAddon } from '@xterm/addon-fit'
+import { type IDisposable, type ITerminalAddon, type Terminal } from '@xterm/xterm'
+import Color from 'color'
+
+export interface TerminalBannerColors {
+ accent1: string
+ positive: string
+ subtle: string
+ warning: string
+}
+
+interface TerminalBannerSegment {
+ bold?: boolean
+ color?: string
+ text: string
+}
+
+export class TerminalShellActionsAddon implements ITerminalAddon {
+ private static readonly COPY_COMMAND_LABEL = '[copy command]'
+ private static readonly FIRST_COMMAND_TITLE = '1. Use $ qovery shell'
+ private static readonly SECOND_COMMAND_TITLE = '2. Use $ qovery port-forward'
+ private static readonly LOOKBACK_LINE_COUNT = 6
+
+ private linkProviderDisposable?: IDisposable
+ private resizeDisposable?: IDisposable
+ private bannerRenderTimeoutId?: ReturnType
+ private hasRenderedBanner = false
+ private static readonly ANSI_BOLD = '\u001b[1m'
+ private static readonly ANSI_RESET = '\u001b[0m'
+
+ constructor(
+ private readonly fitAddon: FitAddon,
+ private readonly colors: TerminalBannerColors,
+ private readonly getPortForwardCommand: () => string,
+ private readonly getShellCommand: () => string,
+ private readonly shouldWriteBanner: boolean
+ ) {}
+
+ private toAnsiColor(color: string): string {
+ const [r, g, b] = Color(color)
+ .rgb()
+ .array()
+ .map((value) => Math.round(value))
+
+ return `\u001b[38;2;${r};${g};${b}m`
+ }
+
+ private formatSegment(segment: TerminalBannerSegment): string {
+ const stylePrefix = `${segment.bold ? TerminalShellActionsAddon.ANSI_BOLD : ''}${segment.color ? this.toAnsiColor(segment.color) : ''}`
+ if (!stylePrefix) {
+ return segment.text
+ }
+
+ return `${stylePrefix}${segment.text}${TerminalShellActionsAddon.ANSI_RESET}`
+ }
+
+ private clampSegments(
+ segments: TerminalBannerSegment[],
+ maxWidth: number
+ ): {
+ clippedSegments: TerminalBannerSegment[]
+ trailingPadding: string
+ } {
+ const clippedSegments: TerminalBannerSegment[] = []
+ let remainingWidth = maxWidth
+
+ segments.forEach((segment) => {
+ if (remainingWidth <= 0) {
+ return
+ }
+
+ const clippedText = segment.text.slice(0, remainingWidth)
+ if (!clippedText) {
+ return
+ }
+
+ clippedSegments.push({
+ ...segment,
+ text: clippedText,
+ })
+ remainingWidth -= clippedText.length
+ })
+
+ return {
+ clippedSegments,
+ trailingPadding: ' '.repeat(Math.max(0, remainingWidth)),
+ }
+ }
+
+ private wrapSegments(segments: TerminalBannerSegment[], maxWidth: number): TerminalBannerSegment[][] {
+ if (!segments.some((segment) => segment.text.length > 0)) {
+ return [[{ text: '' }]]
+ }
+
+ const wrappedLines: TerminalBannerSegment[][] = []
+ let currentLine: TerminalBannerSegment[] = []
+ let remainingWidth = maxWidth
+
+ segments.forEach((segment) => {
+ let remainingText = segment.text
+
+ while (remainingText.length > 0) {
+ if (remainingWidth <= 0) {
+ wrappedLines.push(currentLine)
+ currentLine = []
+ remainingWidth = maxWidth
+ }
+
+ const chunkLength = Math.min(remainingWidth, remainingText.length)
+ const chunk = remainingText.slice(0, chunkLength)
+
+ currentLine.push({
+ ...segment,
+ text: chunk,
+ })
+
+ remainingText = remainingText.slice(chunkLength)
+ remainingWidth -= chunkLength
+ }
+ })
+
+ if (currentLine.length > 0) {
+ wrappedLines.push(currentLine)
+ }
+
+ return wrappedLines.length > 0 ? wrappedLines : [[{ text: '' }]]
+ }
+
+ private copyCommandToClipboard(command: string): void {
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ void navigator.clipboard.writeText(command).catch(() => undefined)
+ }
+ }
+
+ private resolveCopyCommandAction(terminal: Terminal, bufferLineNumber: number): (() => void) | undefined {
+ const contextLines = Array.from({ length: TerminalShellActionsAddon.LOOKBACK_LINE_COUNT }, (_, index) =>
+ terminal.buffer.active.getLine(bufferLineNumber - 2 - index)?.translateToString(false)
+ )
+ .filter((line): line is string => Boolean(line))
+ .join(' ')
+
+ if (contextLines.includes(TerminalShellActionsAddon.SECOND_COMMAND_TITLE)) {
+ return () => this.copyCommandToClipboard(this.getPortForwardCommand())
+ }
+
+ if (contextLines.includes(TerminalShellActionsAddon.FIRST_COMMAND_TITLE)) {
+ return () => this.copyCommandToClipboard(this.getShellCommand())
+ }
+
+ return undefined
+ }
+
+ private writeBanner(terminal: Terminal): void {
+ const horizontalPadding = 1
+ const maxContentWidth = Math.max(1, terminal.cols - 4 - horizontalPadding * 2)
+ const bannerTitle: TerminalBannerSegment = {
+ bold: true,
+ text: 'Connect from your local terminal via the Qovery CLI',
+ }
+
+ const firstCommandLine: TerminalBannerSegment[] = [
+ { bold: true, color: this.colors.warning, text: TerminalShellActionsAddon.FIRST_COMMAND_TITLE },
+ ]
+
+ const secondCommandLine: TerminalBannerSegment[] = [
+ { bold: true, color: this.colors.warning, text: TerminalShellActionsAddon.SECOND_COMMAND_TITLE },
+ ]
+
+ const bannerLines: TerminalBannerSegment[][] = [
+ firstCommandLine,
+ [{ color: this.colors.subtle, text: 'Open an interactive shell inside a running container' }],
+ [{ text: TerminalShellActionsAddon.COPY_COMMAND_LABEL }],
+ [{ text: '' }],
+ secondCommandLine,
+ [{ color: this.colors.subtle, text: 'Forward a pod port to localhost' }],
+ [{ text: TerminalShellActionsAddon.COPY_COMMAND_LABEL }],
+ ]
+
+ const maxBannerLineLength = Math.max(
+ bannerTitle.text.length,
+ ...bannerLines.map((segments) => segments.reduce((length, segment) => length + segment.text.length, 0))
+ )
+ const additionalContentWidth = 8
+ const bannerContentWidth = Math.min(Math.max(20, maxBannerLineLength + additionalContentWidth), maxContentWidth)
+ const bannerInnerWidth = bannerContentWidth + horizontalPadding * 2
+ const lineContentWidth = bannerInnerWidth + 2
+ const formatBannerLine = (segments: TerminalBannerSegment[]) => {
+ const { clippedSegments, trailingPadding } = this.clampSegments(segments, bannerContentWidth)
+ const styledText = clippedSegments.map((segment) => this.formatSegment(segment)).join('') + trailingPadding
+
+ return `│ ${' '.repeat(horizontalPadding)}${styledText}${' '.repeat(horizontalPadding)} │`
+ }
+
+ const leftTitleLineWidth = 1
+ const minimumRightTitleLineWidth = 1
+ const maxVisibleTitleLength = Math.max(0, lineContentWidth - leftTitleLineWidth - minimumRightTitleLineWidth - 2)
+ const visibleTitle = bannerTitle.text.slice(0, maxVisibleTitleLength)
+ const rightTitleLineWidth = Math.max(
+ minimumRightTitleLineWidth,
+ lineContentWidth - leftTitleLineWidth - visibleTitle.length - 2
+ )
+ const styledTitle = this.formatSegment({
+ ...bannerTitle,
+ text: visibleTitle,
+ })
+
+ terminal.writeln(`┌${'─'.repeat(leftTitleLineWidth)} ${styledTitle} ${'─'.repeat(rightTitleLineWidth)}┐`)
+ terminal.writeln(formatBannerLine([{ text: '' }]))
+ bannerLines.forEach((line) => {
+ this.wrapSegments(line, bannerContentWidth).forEach((wrappedLine) => {
+ terminal.writeln(formatBannerLine(wrappedLine))
+ })
+ })
+ terminal.writeln(formatBannerLine([{ text: '' }]))
+ terminal.writeln(`└${'─'.repeat(lineContentWidth)}┘`)
+ terminal.writeln('')
+ }
+
+ activate(terminal: Terminal): void {
+ if (this.shouldWriteBanner) {
+ const renderWhenFitIsStable = () => {
+ if (this.bannerRenderTimeoutId) {
+ clearTimeout(this.bannerRenderTimeoutId)
+ }
+
+ this.bannerRenderTimeoutId = setTimeout(() => {
+ if (this.hasRenderedBanner) {
+ return
+ }
+
+ this.fitAddon.fit()
+ this.writeBanner(terminal)
+ this.hasRenderedBanner = true
+ this.resizeDisposable?.dispose()
+ this.resizeDisposable = undefined
+ }, 120)
+ }
+
+ this.resizeDisposable = terminal.onResize(() => {
+ renderWhenFitIsStable()
+ })
+ renderWhenFitIsStable()
+ }
+
+ this.linkProviderDisposable = terminal.registerLinkProvider({
+ provideLinks: (bufferLineNumber, callback) => {
+ const line = terminal.buffer.active.getLine(bufferLineNumber - 1)
+
+ if (!line) {
+ callback(undefined)
+ return
+ }
+
+ const lineText = line.translateToString(false)
+ const linkRanges = []
+ let fromIndex = 0
+
+ while (fromIndex < lineText.length) {
+ const labelIndex = lineText.indexOf(TerminalShellActionsAddon.COPY_COMMAND_LABEL, fromIndex)
+ if (labelIndex === -1) {
+ break
+ }
+
+ const action = this.resolveCopyCommandAction(terminal, bufferLineNumber)
+ if (action) {
+ linkRanges.push({
+ activate: () => action(),
+ decorations: {
+ pointerCursor: true,
+ underline: false,
+ },
+ range: {
+ end: {
+ x: labelIndex + TerminalShellActionsAddon.COPY_COMMAND_LABEL.length + 1,
+ y: bufferLineNumber,
+ },
+ start: {
+ x: labelIndex + 1,
+ y: bufferLineNumber,
+ },
+ },
+ text: TerminalShellActionsAddon.COPY_COMMAND_LABEL,
+ })
+ }
+
+ fromIndex = labelIndex + TerminalShellActionsAddon.COPY_COMMAND_LABEL.length
+ }
+
+ callback(linkRanges.length > 0 ? linkRanges : undefined)
+ },
+ })
+ }
+
+ dispose(): void {
+ if (this.bannerRenderTimeoutId) {
+ clearTimeout(this.bannerRenderTimeoutId)
+ this.bannerRenderTimeoutId = undefined
+ }
+
+ this.linkProviderDisposable?.dispose()
+ this.resizeDisposable?.dispose()
+ this.linkProviderDisposable = undefined
+ this.resizeDisposable = undefined
+ }
+}