github.com/argoproj/argo-cd/v3@v3.2.1/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx (about) 1 import {Terminal} from 'xterm'; 2 import {FitAddon} from 'xterm-addon-fit'; 3 import * as models from '../../../shared/models'; 4 import * as React from 'react'; 5 import './pod-terminal-viewer.scss'; 6 import 'xterm/css/xterm.css'; 7 import {useCallback, useEffect} from 'react'; 8 import {debounceTime, takeUntil} from 'rxjs/operators'; 9 import {fromEvent, ReplaySubject, Subject} from 'rxjs'; 10 import {Context} from '../../../shared/context'; 11 import {Tooltip} from 'argo-ui/v2'; 12 import {ErrorNotification, NotificationType} from 'argo-ui'; 13 export interface PodTerminalViewerProps { 14 applicationName: string; 15 applicationNamespace: string; 16 projectName: string; 17 selectedNode: models.ResourceNode; 18 podState: models.State; 19 containerName: string; 20 onClickContainer?: (group: any, i: number, tab: string) => any; 21 } 22 export interface ShellFrame { 23 operation: string; 24 data?: string; 25 rows?: number; 26 cols?: number; 27 } 28 29 const TooltipWrapper = (props: {content: React.ReactNode | string; disabled?: boolean; inverted?: boolean} & React.PropsWithRef<any>) => { 30 return !props.disabled ? ( 31 <Tooltip content={props.content} inverted={props.inverted}> 32 {props.children} 33 </Tooltip> 34 ) : ( 35 props.children 36 ); 37 }; 38 39 export const PodTerminalViewer: React.FC<PodTerminalViewerProps> = ({ 40 selectedNode, 41 applicationName, 42 applicationNamespace, 43 projectName, 44 podState, 45 containerName, 46 onClickContainer 47 }) => { 48 const terminalRef = React.useRef(null); 49 const appContext = React.useContext(Context); // used to show toast 50 const fitAddon = new FitAddon(); 51 let terminal: Terminal; 52 let webSocket: WebSocket; 53 const keyEvent = new ReplaySubject<KeyboardEvent>(2); 54 let connSubject = new ReplaySubject<ShellFrame>(100); 55 let incommingMessage = new Subject<ShellFrame>(); 56 const unsubscribe = new Subject<void>(); 57 let connected = false; 58 59 function showErrorMsg(msg: string, err: any) { 60 appContext.notifications.show({ 61 content: <ErrorNotification title={msg} e={err} />, 62 type: NotificationType.Error 63 }); 64 } 65 66 const onTerminalSendString = (str: string) => { 67 if (connected) { 68 webSocket.send(JSON.stringify({operation: 'stdin', data: str, rows: terminal.rows, cols: terminal.cols})); 69 } 70 }; 71 72 const onTerminalResize = () => { 73 if (connected) { 74 webSocket.send( 75 JSON.stringify({ 76 operation: 'resize', 77 cols: terminal.cols, 78 rows: terminal.rows 79 }) 80 ); 81 } 82 }; 83 84 const onConnectionMessage = (e: MessageEvent) => { 85 const msg = JSON.parse(e.data); 86 if (!msg?.Code) { 87 connSubject.next(msg); 88 } else { 89 // Do reconnect due to refresh token event 90 onConnectionClose(); 91 setupConnection(); 92 } 93 }; 94 95 const onConnectionOpen = () => { 96 connected = true; 97 onTerminalResize(); // fit the screen first time 98 terminal.focus(); 99 }; 100 101 const onConnectionClose = () => { 102 if (!connected) return; 103 if (webSocket) webSocket.close(); 104 connected = false; 105 }; 106 107 const handleConnectionMessage = (frame: ShellFrame) => { 108 terminal.write(frame.data); 109 incommingMessage.next(frame); 110 }; 111 112 const disconnect = () => { 113 if (webSocket) { 114 webSocket.close(); 115 } 116 117 if (connSubject) { 118 connSubject.complete(); 119 connSubject = new ReplaySubject<ShellFrame>(100); 120 } 121 122 if (terminal) { 123 terminal.dispose(); 124 } 125 126 incommingMessage.complete(); 127 incommingMessage = new Subject<ShellFrame>(); 128 }; 129 130 function initTerminal(node: HTMLElement) { 131 if (connSubject) { 132 connSubject.complete(); 133 connSubject = new ReplaySubject<ShellFrame>(100); 134 } 135 136 if (terminal) { 137 terminal.dispose(); 138 } 139 140 terminal = new Terminal({ 141 convertEol: true, 142 fontFamily: 'Menlo, Monaco, Courier New, monospace', 143 bellStyle: 'sound', 144 fontSize: 14, 145 fontWeight: 400, 146 cursorBlink: true 147 }); 148 terminal.options = { 149 theme: { 150 background: '#333' 151 } 152 }; 153 terminal.loadAddon(fitAddon); 154 terminal.open(node); 155 fitAddon.fit(); 156 157 connSubject.pipe(takeUntil(unsubscribe)).subscribe(frame => { 158 handleConnectionMessage(frame); 159 }); 160 161 terminal.onResize(onTerminalResize); 162 terminal.onKey(key => { 163 keyEvent.next(key.domEvent); 164 }); 165 terminal.onData(onTerminalSendString); 166 } 167 168 function setupConnection() { 169 const {name = '', namespace = ''} = selectedNode || {}; 170 const url = `${location.host}${appContext.baseHref}`.replace(/\/$/, ''); 171 webSocket = new WebSocket( 172 `${ 173 location.protocol === 'https:' ? 'wss' : 'ws' 174 }://${url}/terminal?pod=${name}&container=${containerName}&appName=${applicationName}&appNamespace=${applicationNamespace}&projectName=${projectName}&namespace=${namespace}` 175 ); 176 webSocket.onopen = onConnectionOpen; 177 webSocket.onclose = onConnectionClose; 178 webSocket.onerror = e => { 179 showErrorMsg('Terminal Connection Error', e); 180 onConnectionClose(); 181 }; 182 webSocket.onmessage = onConnectionMessage; 183 } 184 185 const setTerminalRef = useCallback( 186 node => { 187 if (terminal && connected) { 188 disconnect(); 189 } 190 191 if (node) { 192 initTerminal(node); 193 setupConnection(); 194 } 195 196 // Save a reference to the node 197 terminalRef.current = node; 198 }, 199 [containerName] 200 ); 201 202 useEffect(() => { 203 const resizeHandler = fromEvent(window, 'resize') 204 .pipe(debounceTime(1000)) 205 .subscribe(() => { 206 if (fitAddon) { 207 fitAddon.fit(); 208 } 209 }); 210 return () => { 211 resizeHandler.unsubscribe(); // unsubscribe resize callback 212 unsubscribe.next(); 213 unsubscribe.complete(); 214 215 // clear connection and close terminal 216 if (webSocket) { 217 webSocket.close(); 218 } 219 220 if (connSubject) { 221 connSubject.complete(); 222 } 223 224 if (terminal) { 225 terminal.dispose(); 226 } 227 228 incommingMessage.complete(); 229 }; 230 }, [containerName]); 231 232 const containerGroups = [ 233 { 234 offset: 0, 235 title: 'CONTAINERS', 236 containers: podState.spec.containers || [] 237 }, 238 { 239 offset: (podState.spec.containers || []).length, 240 title: 'INIT CONTAINERS', 241 containers: podState.spec.initContainers || [] 242 } 243 ]; 244 245 const isContainerRunning = (container: any): boolean => { 246 const containerStatus = 247 podState.status?.containerStatuses?.find((status: {name: string}) => status.name === container.name) || 248 podState.status?.initContainerStatuses?.find((status: {name: string}) => status.name === container.name); 249 return containerStatus?.state?.running != null; 250 }; 251 252 return ( 253 <div className='row pod-terminal-viewer__container'> 254 <div className='columns small-3 medium-2'> 255 {containerGroups.map(group => ( 256 <div key={group.title} style={{marginBottom: '1em'}}> 257 {group.containers.length > 0 && <p>{group.title}</p>} 258 {group.containers.map((container: any, i: number) => { 259 const running = isContainerRunning(container); 260 return ( 261 <TooltipWrapper key={container.name} content={!running ? 'Container is not running' : ''} disabled={running}> 262 <div 263 className={`application-details__container pod-terminal-viewer__tab ${!running ? 'pod-terminal-viewer__tab--disabled' : ''}`} 264 onClick={() => { 265 if (!running) { 266 return; 267 } 268 if (container.name !== containerName) { 269 disconnect(); 270 onClickContainer(group, i, 'exec'); 271 } 272 }} 273 title={!running ? 'Container is not running' : container.name}> 274 {container.name === containerName && <i className='pod-terminal-viewer__icon fa fa-angle-right negative-space-arrow' />} 275 <span>{container.name}</span> 276 </div> 277 </TooltipWrapper> 278 ); 279 })} 280 </div> 281 ))} 282 </div> 283 <div className='columns small-9 medium-10'> 284 <div ref={setTerminalRef} className='pod-terminal-viewer' /> 285 </div> 286 </div> 287 ); 288 };