diff --git a/app/assets/css/app.css b/app/assets/css/app.css index aa4749814..b8bd2abb4 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -94,7 +94,8 @@ body, font-size: 16px; } -.form-horizontal .control-label.text-left { +.form-horizontal .control-label.text-left, +.form-row .control-label.text-left { text-align: left; font-size: 0.9em; } diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index c235645a7..9ccdbcc8a 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -174,7 +174,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo url: '/:pod/:container/console', views: { 'content@': { - component: 'kubernetesApplicationConsoleView', + component: 'kubernetesConsoleView', }, }, }; diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 3d5a8b8d8..59d9699ae 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -8,6 +8,7 @@ import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressData import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView'; import { DashboardView } from '@/react/kubernetes/DashboardView'; import { ServicesView } from '@/react/kubernetes/ServicesView'; +import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) @@ -29,4 +30,8 @@ export const viewsModule = angular .component( 'kubernetesDashboardView', r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), []) + ) + .component( + 'kubernetesConsoleView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ConsoleView))), []) ).name; diff --git a/app/kubernetes/views/applications/console/console.html b/app/kubernetes/views/applications/console/console.html deleted file mode 100644 index 83c928c5c..000000000 --- a/app/kubernetes/views/applications/console/console.html +++ /dev/null @@ -1,84 +0,0 @@ -<page-header - ng-if="ctrl.state.viewReady" - title="'Application console'" - breadcrumbs="[ - { label:'Namespaces', link:'kubernetes.resourcePools' }, - { - label:ctrl.application.ResourcePool, - link: 'kubernetes.resourcePools.resourcePool', - linkParams:{ id: ctrl.application.ResourcePool } - }, - { label:'Applications', link:'kubernetes.applications' }, - { - label:ctrl.application.Name, - link: 'kubernetes.applications.application', - linkParams:{ name: ctrl.application.Name, namespace: ctrl.application.ResourcePool } - }, - 'Pods', - ctrl.podName, - 'Containers', - ctrl.containerName, - 'Console' - ]" - reload="true" -> -</page-header> - -<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading> - -<div ng-if="ctrl.state.viewReady"> - <div class="row"> - <div class="col-sm-12"> - <rd-widget> - <rd-widget-body> - <form class="form-horizontal" autocomplete="off"> - <div class="col-sm-12 form-section-title"> Console </div> - <!-- Command --> - <div class="form-group"> - <label for="console_command" class="col-sm-3 col-lg-2 control-label text-left">Command</label> - <div class="col-sm-8 input-group"> - <span class="input-group-addon"> - <pr-icon icon="'terminal'" class="mr-1"></pr-icon> - </span> - <input - type="text" - class="form-control" - placeholder="/bin/bash" - ng-model="ctrl.state.command" - name="console_command" - uib-typeahead="command for command in ctrl.state.availableCommands | filter:$viewValue | limitTo:5" - typeahead-min-length="0" - auto-focus - /> - </div> - </div> - <!-- !command --> - <div class="form-group"> - <div class="col-sm-12"> - <button - type="button" - class="btn btn-primary btn-sm" - style="margin: 0" - ng-if="!ctrl.state.connected" - ng-disabled="!ctrl.state.command || ctrl.state.connected" - ng-click="ctrl.connectConsole()" - button-spinner="ctrl.state.actionInProgress" - > - <span ng-hide="ctrl.state.actionInProgress">Connect</span> - <span ng-show="ctrl.state.actionInProgress">Connection in progress...</span> - </button> - <button type="button" class="btn btn-primary btn-sm" style="margin: 0" ng-if="ctrl.state.connected" ng-click="ctrl.disconnect()"> Disconnect </button> - </div> - </div> - </form> - </rd-widget-body> - </rd-widget> - </div> - </div> - - <div class="row"> - <div class="col-sm-12"> - <div id="terminal-container" class="terminal-container"></div> - </div> - </div> -</div> diff --git a/app/kubernetes/views/applications/console/console.js b/app/kubernetes/views/applications/console/console.js deleted file mode 100644 index 780e4f0ac..000000000 --- a/app/kubernetes/views/applications/console/console.js +++ /dev/null @@ -1,9 +0,0 @@ -angular.module('portainer.kubernetes').component('kubernetesApplicationConsoleView', { - templateUrl: './console.html', - controller: 'KubernetesApplicationConsoleController', - controllerAs: 'ctrl', - bindings: { - $transition$: '<', - endpoint: '<', - }, -}); diff --git a/app/kubernetes/views/applications/console/consoleController.js b/app/kubernetes/views/applications/console/consoleController.js deleted file mode 100644 index f0912d106..000000000 --- a/app/kubernetes/views/applications/console/consoleController.js +++ /dev/null @@ -1,117 +0,0 @@ -import angular from 'angular'; -import { Terminal } from 'xterm'; -import { baseHref } from '@/portainer/helpers/pathHelper'; - -class KubernetesApplicationConsoleController { - /* @ngInject */ - constructor($async, $state, Notifications, KubernetesApplicationService, LocalStorage) { - this.$async = $async; - this.$state = $state; - this.Notifications = Notifications; - this.KubernetesApplicationService = KubernetesApplicationService; - this.LocalStorage = LocalStorage; - - this.onInit = this.onInit.bind(this); - } - - disconnect() { - this.state.socket.close(); - this.state.term.dispose(); - this.state.connected = false; - } - - configureSocketAndTerminal(socket, term) { - socket.onopen = function () { - const terminal_container = document.getElementById('terminal-container'); - term.open(terminal_container); - term.setOption('cursorBlink', true); - term.focus(); - }; - - term.on('data', function (data) { - socket.send(data); - }); - - socket.onmessage = function (msg) { - term.write(msg.data); - }; - - socket.onerror = function (err) { - this.disconnect(); - this.Notifications.error('Failure', err, 'Websocket connection error'); - }.bind(this); - - this.state.socket.onclose = function () { - this.disconnect(); - }.bind(this); - - this.state.connected = true; - } - - connectConsole() { - const params = { - token: this.LocalStorage.getJWT(), - endpointId: this.endpoint.Id, - namespace: this.application.ResourcePool, - podName: this.podName, - containerName: this.containerName, - command: this.state.command, - }; - - const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref(); - - let url = - base + - 'api/websocket/pod?' + - Object.keys(params) - .map((k) => k + '=' + params[k]) - .join('&'); - if (url.indexOf('https') > -1) { - url = url.replace('https://', 'wss://'); - } else { - url = url.replace('http://', 'ws://'); - } - - this.state.socket = new WebSocket(url); - this.state.term = new Terminal(); - - this.configureSocketAndTerminal(this.state.socket, this.state.term); - } - - async onInit() { - const availableCommands = ['/bin/bash', '/bin/sh']; - - this.state = { - actionInProgress: false, - availableCommands: availableCommands, - command: availableCommands[1], - connected: false, - socket: null, - term: null, - viewReady: false, - }; - - const podName = this.$transition$.params().pod; - const applicationName = this.$transition$.params().name; - const namespace = this.$transition$.params().namespace; - const containerName = this.$transition$.params().container; - - this.podName = podName; - this.containerName = containerName; - - try { - this.application = await this.KubernetesApplicationService.get(namespace, applicationName); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve application logs'); - } finally { - this.state.viewReady = true; - } - } - - $onInit() { - return this.$async(this.onInit); - } -} - -export default KubernetesApplicationConsoleController; -angular.module('portainer.kubernetes').controller('KubernetesApplicationConsoleController', KubernetesApplicationConsoleController); diff --git a/app/react/kubernetes/applications/ConsoleView/.keep b/app/react/kubernetes/applications/ConsoleView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx b/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx new file mode 100644 index 000000000..f31c7816f --- /dev/null +++ b/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx @@ -0,0 +1,197 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useCurrentStateAndParams } from '@uirouter/react'; +import { Terminal as TerminalIcon } from 'lucide-react'; +import { Terminal } from 'xterm'; + +import { useLocalStorage } from '@/react/hooks/useLocalStorage'; +import { baseHref } from '@/portainer/helpers/pathHelper'; +import { notifyError } from '@/portainer/services/notifications'; + +import { PageHeader } from '@@/PageHeader'; +import { Widget, WidgetBody } from '@@/Widget'; +import { Icon } from '@@/Icon'; +import { Button } from '@@/buttons'; + +interface StringDictionary { + [index: string]: string; +} + +export function ConsoleView() { + const { + params: { + endpointId: environmentId, + container, + name: appName, + namespace, + pod: podID, + }, + } = useCurrentStateAndParams(); + + const [jwtToken] = useLocalStorage('JWT', ''); + const [command, setCommand] = useState('/bin/sh'); + const [connectionStatus, setConnectionStatus] = useState('closed'); + const [terminal, setTerminal] = useState(null as Terminal | null); + const [socket, setSocket] = useState(null as WebSocket | null); + + const breadcrumbs = [ + { + label: 'Namespaces', + link: 'kubernetes.resourcePools', + }, + { + label: namespace, + link: 'kubernetes.resourcePools.resourcePool', + linkParams: { id: namespace }, + }, + { + label: 'Applications', + link: 'kubernetes.applications', + }, + { + label: appName, + link: 'kubernetes.applications.application', + linkParams: { name: appName, namespace }, + }, + 'Pods', + podID, + 'Containers', + container, + 'Console', + ]; + + const disconnectConsole = useCallback(() => { + socket?.close(); + terminal?.dispose(); + setTerminal(null); + setSocket(null); + setConnectionStatus('closed'); + }, [socket, terminal, setConnectionStatus]); + + useEffect(() => { + if (socket) { + socket.onopen = () => { + const terminalContainer = document.getElementById('terminal-container'); + if (terminalContainer) { + terminal?.open(terminalContainer); + terminal?.setOption('cursorBlink', true); + terminal?.focus(); + setConnectionStatus('open'); + } + }; + + socket.onmessage = (msg) => { + terminal?.write(msg.data); + }; + + socket.onerror = () => { + disconnectConsole(); + notifyError('Websocket connection error'); + }; + + socket.onclose = () => { + disconnectConsole(); + }; + } + }, [disconnectConsole, setConnectionStatus, socket, terminal]); + + useEffect(() => { + terminal?.on('data', (data) => { + socket?.send(data); + }); + }, [terminal, socket]); + + return ( + <> + <PageHeader + title="Application console" + breadcrumbs={breadcrumbs} + reload + /> + <div className="row"> + <div className="col-sm-12"> + <Widget> + <WidgetBody> + <div className="row"> + <div className="col-sm-12 form-section-title">Console</div> + </div> + <div className="form-row flex"> + <label + htmlFor="consoleCommand" + className="col-sm-3 col-lg-2 control-label m-0 p-0 text-left" + > + Command + </label> + <div className="col-sm-8 input-group p-0"> + <span className="input-group-addon"> + <Icon icon={TerminalIcon} className="mr-1" /> + </span> + <input + type="text" + className="form-control" + placeholder="/bin/bash" + value={command} + onChange={(e) => setCommand(e.target.value)} + id="consoleCommand" + auto-focus="true" + /> + </div> + </div> + <div className="row mt-4"> + <Button + className="btn btn-primary !ml-0" + onClick={ + connectionStatus === 'closed' + ? connectConsole + : disconnectConsole + } + disabled={connectionStatus === 'connecting'} + > + {connectionStatus === 'open' && 'Disconnect'} + {connectionStatus === 'connecting' && 'Connecting'} + {connectionStatus !== 'connecting' && + connectionStatus !== 'open' && + 'Connect'} + </Button> + </div> + </WidgetBody> + </Widget> + <div className="row"> + <div className="col-sm-12 p-0"> + <div id="terminal-container" className="terminal-container" /> + </div> + </div> + </div> + </div> + </> + ); + + function connectConsole() { + const params: StringDictionary = { + token: jwtToken, + endpointId: environmentId, + namespace, + podName: podID, + containerName: container, + command, + }; + + const queryParams = Object.keys(params) + .map((k) => `${k}=${params[k]}`) + .join('&'); + + let url = `${ + window.location.origin + }${baseHref()}api/websocket/pod?${queryParams}`; + if (url.indexOf('https') > -1) { + url = url.replace('https://', 'wss://'); + } else { + url = url.replace('http://', 'ws://'); + } + + setConnectionStatus('connecting'); + const term = new Terminal(); + setTerminal(term); + const socket = new WebSocket(url); + setSocket(socket); + } +} diff --git a/app/react/kubernetes/applications/ConsoleView/index.ts b/app/react/kubernetes/applications/ConsoleView/index.ts new file mode 100644 index 000000000..bceeb0c68 --- /dev/null +++ b/app/react/kubernetes/applications/ConsoleView/index.ts @@ -0,0 +1 @@ +export { ConsoleView } from './ConsoleView';