Skip to content

Commit

Permalink
feat: use tty for containers started with TTY/interactive mode
Browse files Browse the repository at this point in the history
note: if you start with interactive mode, if you start a container,
you are redirected to the tty tab of the container

if you click on the details of a container, you're redirected
to the tty tab if the container has been launched using tty/interactive
else you see the logs

In the details of a container, tty tab is only available if
container is started with tty/interactive mode else it's hidden.

fixes #970
Signed-off-by: Florent Benoit <fbenoit@redhat.com>
  • Loading branch information
benoitf committed Sep 14, 2023
1 parent fd5e26d commit a65297c
Show file tree
Hide file tree
Showing 8 changed files with 418 additions and 12 deletions.
2 changes: 1 addition & 1 deletion packages/renderer/src/lib/ContainerList.svelte
Expand Up @@ -326,7 +326,7 @@ onDestroy(() => {
});
function openDetailsContainer(container: ContainerInfoUI) {
router.goto(`/containers/${container.id}/logs`);
router.goto(`/containers/${container.id}/`);
}
function keydownChoice(e: KeyboardEvent) {
Expand Down
92 changes: 90 additions & 2 deletions packages/renderer/src/lib/container/ContainerDetails.spec.ts
Expand Up @@ -17,7 +17,7 @@
***********************************************************************/

import '@testing-library/jest-dom/vitest';
import { test, expect, vi, beforeAll } from 'vitest';
import { test, expect, vi, beforeAll, beforeEach } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';

import ContainerDetails from './ContainerDetails.svelte';
Expand All @@ -30,6 +30,8 @@ import { lastPage } from '/@/stores/breadcrumb';

const listContainersMock = vi.fn();

const getContainerInspectMock = vi.fn();

const myContainer: ContainerInfo = {
Id: 'myContainer',
Labels: {},
Expand All @@ -49,12 +51,93 @@ const myContainer: ContainerInfo = {

const deleteContainerMock = vi.fn();

vi.mock('xterm', () => {
return {
Terminal: vi.fn().mockReturnValue({ loadAddon: vi.fn(), open: vi.fn(), write: vi.fn(), clear: vi.fn() }),
};
});

beforeAll(() => {
(window as any).listContainers = listContainersMock;
(window as any).deleteContainer = deleteContainerMock;
(window as any).getContainerInspect = getContainerInspectMock;

(window as any).getConfigurationValue = vi.fn().mockReturnValue(12);

(window as any).logsContainer = vi.fn();
(window as any).matchMedia = vi.fn().mockReturnValue({
addListener: vi.fn(),
});
(window as any).ResizeObserver = vi.fn().mockReturnValue({ observe: vi.fn(), unobserve: vi.fn() });
});

beforeEach(() => {});

test('Expect logs when tty is not enabled', async () => {
router.goto('/');

containersInfos.set([myContainer]);

// spy router.goto
const routerGotoSpy = vi.spyOn(router, 'goto');

getContainerInspectMock.mockResolvedValue({
Config: {
Tty: false,
},
});

// render the component
render(ContainerDetails, { containerID: 'myContainer' });

// wait router.goto is called
while (routerGotoSpy.mock.calls.length === 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}

// grab current route and check we have been redirected to tty
const currentRoute = window.location;
expect(currentRoute.href).toBe('http://localhost:3000/logs');

expect(routerGotoSpy).toBeCalledWith('/logs');
});

test('Expect show tty if container has tty enabled', async () => {
router.goto('/');

containersInfos.set([myContainer]);

// spy router.goto
const routerGotoSpy = vi.spyOn(router, 'goto');

getContainerInspectMock.mockResolvedValue({
Config: {
Tty: true,
OpenStdin: true,
},
});

// render the component
render(ContainerDetails, { containerID: 'myContainer' });

// wait router.goto is called
while (routerGotoSpy.mock.calls.length === 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}

// grab current route and check we have been redirected to tty
const currentRoute = window.location;
expect(currentRoute.href).toBe('http://localhost:3000/tty');

expect(routerGotoSpy).toBeCalledWith('/tty');
});

test('Expect redirect to previous page if container is deleted', async () => {
router.goto('/');

getContainerInspectMock.mockResolvedValue({
Config: {},
});
const routerGotoSpy = vi.spyOn(router, 'goto');
listContainersMock.mockResolvedValue([myContainer]);
window.dispatchEvent(new CustomEvent('extensions-already-started'));
Expand All @@ -74,9 +157,14 @@ test('Expect redirect to previous page if container is deleted', async () => {
// render the component
render(ContainerDetails, { containerID: 'myContainer' });

// wait router.goto is called
while (routerGotoSpy.mock.calls.length === 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}

// grab current route
const currentRoute = window.location;
expect(currentRoute.href).toBe('http://localhost:3000/');
expect(currentRoute.href).toBe('http://localhost:3000/logs');

// click on delete container button
const deleteButton = screen.getByRole('button', { name: 'Delete Container' });
Expand Down
32 changes: 32 additions & 0 deletions packages/renderer/src/lib/container/ContainerDetails.svelte
Expand Up @@ -18,19 +18,45 @@ import ContainerStatistics from './ContainerStatistics.svelte';
import DetailsPage from '../ui/DetailsPage.svelte';
import Tab from '../ui/Tab.svelte';
import ErrorMessage from '../ui/ErrorMessage.svelte';
import ContainerDetailsTtyTerminal from './ContainerDetailsTtyTerminal.svelte';
import { router } from 'tinro';
export let containerID: string;
let container: ContainerInfoUI;
let detailsPage: DetailsPage;
let displayTty = false;
// update current route scheme
let currentRouterPath: string;
onMount(() => {
const containerUtils = new ContainerUtils();
router.subscribe(route => {
currentRouterPath = route.path;
});
// loading container info
return containersInfos.subscribe(containers => {
const matchingContainer = containers.find(c => c.Id === containerID);
if (matchingContainer) {
container = containerUtils.getContainerInfoUI(matchingContainer);
// look if tty is supported by this container
window.getContainerInspect(container.engineId, container.id).then(inspect => {
displayTty = (inspect.Config.Tty || false) && (inspect.Config.OpenStdin || false);
// if we comes with a / redirect to /logs or to /tty if tty is supported
if (currentRouterPath.endsWith('/')) {
if (displayTty) {
router.goto(`${currentRouterPath}tty`);
} else {
router.goto(`${currentRouterPath}logs`);
}
}
});
} else if (detailsPage) {
// the container has been deleted
detailsPage.close();
Expand Down Expand Up @@ -80,6 +106,9 @@ function errorCallback(errorMessage: string): void {
<Tab title="Kube" url="kube" />
{/if}
<Tab title="Terminal" url="terminal" />
{#if displayTty}
<Tab title="Tty" url="tty" />
{/if}
</svelte:fragment>
<svelte:fragment slot="content">
<Route path="/summary" breadcrumb="Summary" navigationHint="tab">
Expand All @@ -97,6 +126,9 @@ function errorCallback(errorMessage: string): void {
<Route path="/terminal" breadcrumb="Terminal" navigationHint="tab">
<ContainerDetailsTerminal container="{container}" />
</Route>
<Route path="/tty" breadcrumb="Tty" navigationHint="tab">
<ContainerDetailsTtyTerminal container="{container}" />
</Route>
</svelte:fragment>
</DetailsPage>
{/if}
@@ -0,0 +1,82 @@
/**********************************************************************
* Copyright (C) 2023 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import '@testing-library/jest-dom/vitest';
import { test, expect, vi, beforeAll } from 'vitest';
import { render, waitFor } from '@testing-library/svelte';
import type { ContainerInfoUI } from './ContainerInfoUI';
import ContainerDetailsTtyTerminal from './ContainerDetailsTtyTerminal.svelte';

const getConfigurationValueMock = vi.fn();
const attachContainerMock = vi.fn();

beforeAll(() => {
(window as any).getConfigurationValue = getConfigurationValueMock;
(window as any).attachContainer = attachContainerMock;
(window as any).attachContainerSend = vi.fn();

(window as any).matchMedia = vi.fn().mockReturnValue({
addListener: vi.fn(),
});
});

test('expect being able to attach terminal ', async () => {
const container: ContainerInfoUI = {
id: 'myContainer',
state: 'RUNNING',
engineId: 'podman',
} as unknown as ContainerInfoUI;

let onDataCallback: (data: Buffer) => void = () => {};

const sendCallbackId = 12345;
attachContainerMock.mockImplementation(
(
_engineId: string,
_containerId: string,
onData: (data: Buffer) => void,
_onError: (error: string) => void,
_onEnd: () => void,
) => {
onDataCallback = onData;
// return a callback id
return sendCallbackId;
},
);

// render the component with a terminal
const renderObject = render(ContainerDetailsTtyTerminal, { container, screenReaderMode: true });

// wait attachContainerMock is called
await waitFor(() => expect(attachContainerMock).toHaveBeenCalled());

// write some data on the terminal
onDataCallback(Buffer.from('hello\nworld'));

// wait 1s
await new Promise(resolve => setTimeout(resolve, 1000));

// search a div having aria-live="assertive" attribute
const terminalLinesLiveRegion = renderObject.container.querySelector('div[aria-live="assertive"]');

// check the content
expect(terminalLinesLiveRegion).toHaveTextContent('hello world');

// check we have called attachContainer
expect(attachContainerMock).toHaveBeenCalledTimes(1);
});
112 changes: 112 additions & 0 deletions packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.svelte
@@ -0,0 +1,112 @@
<script lang="ts">
import type { ContainerInfoUI } from './ContainerInfoUI';
import { TerminalSettings } from '../../../../main/src/plugin/terminal-settings';
import { router } from 'tinro';
import { onDestroy, onMount } from 'svelte';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { getPanelDetailColor } from '../color/color';
import EmptyScreen from '../ui/EmptyScreen.svelte';
import NoLogIcon from '../ui/NoLogIcon.svelte';
export let container: ContainerInfoUI;
export let screenReaderMode = false;
let terminalXtermDiv: HTMLDivElement;
let attachContainerTerminal: Terminal;
let currentRouterPath: string;
let closed = false;
// update current route scheme
router.subscribe(route => {
currentRouterPath = route.path;
});
// update terminal when receiving data
function receiveDataCallback(data: Buffer) {
attachContainerTerminal.write(data.toString());
}
function receiveEndCallback() {
closed = true;
}
// call exec command
async function attachToContainer() {
if (container.state !== 'RUNNING') {
return;
}
// attach to the container
const callbackId = await window.attachContainer(
container.engineId,
container.id,
receiveDataCallback,
() => {},
receiveEndCallback,
);
// pass data from xterm to container
attachContainerTerminal?.onData(data => {
window.attachContainerSend(callbackId, data);
});
}
// refresh
async function refreshTerminal() {
// missing element, return
if (!terminalXtermDiv) {
return;
}
// grab font size
const fontSize = await window.getConfigurationValue<number>(
TerminalSettings.SectionName + '.' + TerminalSettings.FontSize,
);
const lineHeight = await window.getConfigurationValue<number>(
TerminalSettings.SectionName + '.' + TerminalSettings.LineHeight,
);
attachContainerTerminal = new Terminal({
fontSize,
lineHeight,
screenReaderMode,
theme: {
background: getPanelDetailColor(),
},
});
const fitAddon = new FitAddon();
attachContainerTerminal.loadAddon(fitAddon);
attachContainerTerminal.open(terminalXtermDiv);
// call fit addon each time we resize the window
window.addEventListener('resize', () => {
if (currentRouterPath === `/containers/${container.id}/tty-terminal`) {
fitAddon.fit();
}
});
fitAddon.fit();
}
onMount(async () => {
await refreshTerminal();
await attachToContainer();
});
onDestroy(() => {});
</script>

<div class="h-full" bind:this="{terminalXtermDiv}" class:hidden="{container.state !== 'RUNNING'}"></div>

<EmptyScreen
hidden="{!closed && !(container.state === 'RUNNING')}"
icon="{NoLogIcon}"
title="No TTY"
message="Tty has stopped" />

<EmptyScreen
hidden="{container.state === 'RUNNING'}"
icon="{NoLogIcon}"
title="No TTY"
message="Container is not running" />

0 comments on commit a65297c

Please sign in to comment.