portainer/portainer

View on GitHub
app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx

Summary

Maintainability
A
3 hrs
Test Coverage
import { Terminal } from 'xterm';
import { fit } from 'xterm/lib/addons/fit/fit';
import { useCallback, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { RotateCw, X, Terminal as TerminalIcon } from 'lucide-react';

import { baseHref } from '@/portainer/helpers/pathHelper';
import {
  terminalClose,
  terminalResize,
} from '@/portainer/services/terminal-window';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';

import { Icon } from '@@/Icon';
import { Button } from '@@/buttons';

import styles from './KubectlShell.module.css';

interface ShellState {
  socket: WebSocket | null;
  minimized: boolean;
}

interface Props {
  environmentId: EnvironmentId;
  onClose(): void;
}

export function KubeCtlShell({ environmentId, onClose }: Props) {
  const [terminal] = useState(new Terminal());

  const [shell, setShell] = useState<ShellState>({
    socket: null,
    minimized: false,
  });

  const { socket } = shell;

  const terminalElem = useRef(null);

  const handleClose = useCallback(() => {
    terminalClose(); // only css trick
    socket?.close();
    terminal.dispose();
    onClose();
  }, [onClose, terminal, socket]);

  const openTerminal = useCallback(() => {
    if (!terminalElem.current) {
      return;
    }

    terminal.open(terminalElem.current);
    terminal.setOption('cursorBlink', true);
    terminal.focus();
    fit(terminal);
    terminal.writeln('#Run kubectl commands inside here');
    terminal.writeln('#e.g. kubectl get all');
    terminal.writeln('');
  }, [terminal]);

  // refresh socket listeners on socket updates
  useEffect(() => {
    if (!socket) {
      return () => {};
    }
    function onOpen() {
      openTerminal();
    }
    function onMessage(e: MessageEvent) {
      const encoded = new TextEncoder().encode(e.data);
      terminal.writeUtf8(encoded);
    }
    function onClose() {
      handleClose();
    }
    function onError(e: Event) {
      handleClose();
      if (socket?.readyState !== WebSocket.CLOSED) {
        notifyError(
          'Failure',
          e as unknown as Error,
          'Websocket connection error'
        );
      }
    }

    socket.addEventListener('open', onOpen);
    socket.addEventListener('message', onMessage);
    socket.addEventListener('close', onClose);
    socket.addEventListener('error', onError);

    return () => {
      socket.removeEventListener('open', onOpen);
      socket.removeEventListener('message', onMessage);
      socket.removeEventListener('close', onClose);
      socket.removeEventListener('error', onError);
    };
  }, [handleClose, openTerminal, socket, terminal]);

  // on component load/destroy
  useEffect(() => {
    const socket = new WebSocket(buildUrl(environmentId));
    setShell((shell) => ({ ...shell, socket }));

    terminal.onData((data) => socket.send(data));
    terminal.onKey(({ domEvent }) => {
      if (domEvent.ctrlKey && domEvent.code === 'KeyD') {
        close();
      }
    });

    window.addEventListener('resize', () => terminalResize());

    function close() {
      socket.close();
      terminal.dispose();
      window.removeEventListener('resize', terminalResize);
    }

    return close;
  }, [environmentId, terminal]);

  return (
    <div className={clsx(styles.root, { [styles.minimized]: shell.minimized })}>
      <div className={styles.header}>
        <div className={clsx(styles.title, 'vertical-center')}>
          <Icon icon={TerminalIcon} />
          kubectl shell
        </div>
        <div className={clsx(styles.actions, 'space-x-8')}>
          <Button
            color="link"
            onClick={clearScreen}
            data-cy="k8sShell-refreshButton"
          >
            <Icon icon={RotateCw} size="md" />
          </Button>
          <Button
            color="link"
            onClick={toggleMinimize}
            data-cy={shell.minimized ? 'k8sShell-restore' : 'k8sShell-minimise'}
          >
            <Icon
              icon={shell.minimized ? 'maximize-2' : 'minimize-2'}
              size="md"
              data-cy={
                shell.minimized ? 'k8sShell-restore' : 'k8sShell-minimise'
              }
            />
          </Button>
          <Button
            color="link"
            onClick={handleClose}
            data-cy="k8sShell-closeButton"
          >
            <Icon icon={X} size="md" />
          </Button>
        </div>
      </div>

      <div className={styles.terminalContainer} ref={terminalElem}>
        <div className={styles.loadingMessage}>Loading Terminal...</div>
      </div>
    </div>
  );

  function clearScreen() {
    terminal.clear();
  }

  function toggleMinimize() {
    if (shell.minimized) {
      terminalResize();
      setShell((shell) => ({ ...shell, minimized: false }));
    } else {
      terminalClose();
      setShell((shell) => ({ ...shell, minimized: true }));
    }
  }

  function buildUrl(environmentId: EnvironmentId) {
    const params = {
      endpointId: environmentId,
    };

    const wsProtocol =
      window.location.protocol === 'https:' ? 'wss://' : 'ws://';
    const path = `${baseHref()}api/websocket/kubernetes-shell`;
    const base = path.startsWith('http')
      ? path.replace(/^https?:\/\//i, '')
      : window.location.host + path;

    const queryParams = Object.entries(params)
      .map(([k, v]) => `${k}=${v}`)
      .join('&');
    return `${wsProtocol}${base}?${queryParams}`;
  }
}