github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/components/streaming-file.js (about) 1 import Component from '@ember/component'; 2 import { scheduleOnce, once } from '@ember/runloop'; 3 import { task } from 'ember-concurrency'; 4 import WindowResizable from 'nomad-ui/mixins/window-resizable'; 5 import { 6 classNames, 7 tagName, 8 attributeBindings, 9 } from '@ember-decorators/component'; 10 import classic from 'ember-classic-decorator'; 11 12 const A_KEY = 65; 13 14 @classic 15 @tagName('pre') 16 @classNames('cli-window') 17 @attributeBindings('data-test-log-cli') 18 export default class StreamingFile extends Component.extend(WindowResizable) { 19 'data-test-log-cli' = true; 20 21 mode = 'streaming'; // head, tail, streaming 22 isStreaming = true; 23 logger = null; 24 follow = true; 25 shouldFillHeight = true; 26 27 // Internal bookkeeping to avoid multiple scroll events on one frame 28 requestFrame = true; 29 30 didReceiveAttrs() { 31 super.didReceiveAttrs(); 32 if (!this.logger) { 33 return; 34 } 35 36 scheduleOnce('actions', this, this.performTask); 37 } 38 39 performTask() { 40 switch (this.mode) { 41 case 'head': 42 this.set('follow', false); 43 this.head.perform(); 44 break; 45 case 'tail': 46 this.set('follow', true); 47 this.tail.perform(); 48 break; 49 case 'streaming': 50 this.set('follow', true); 51 if (this.isStreaming) { 52 this.stream.perform(); 53 } else { 54 this.logger.stop(); 55 } 56 break; 57 } 58 } 59 60 scrollHandler() { 61 const cli = this.element; 62 63 // Scroll events can fire multiple times per frame, this eliminates 64 // redundant computation. 65 if (this.requestFrame) { 66 window.requestAnimationFrame(() => { 67 // If the scroll position is close enough to the bottom, autoscroll to the bottom 68 this.set( 69 'follow', 70 cli.scrollHeight - cli.scrollTop - cli.clientHeight < 20 71 ); 72 this.requestFrame = true; 73 }); 74 } 75 this.requestFrame = false; 76 } 77 78 keyDownHandler(e) { 79 // Rebind select-all shortcut to only select the text in the 80 // streaming file output. 81 if ((e.metaKey || e.ctrlKey) && e.keyCode === A_KEY) { 82 e.preventDefault(); 83 const selection = window.getSelection(); 84 selection.removeAllRanges(); 85 const range = document.createRange(); 86 range.selectNode(this.element); 87 selection.addRange(range); 88 } 89 } 90 91 didInsertElement() { 92 super.didInsertElement(...arguments); 93 if (this.shouldFillHeight) { 94 this.fillAvailableHeight(); 95 } 96 97 this.set('_scrollHandler', this.scrollHandler.bind(this)); 98 this.element.addEventListener('scroll', this._scrollHandler); 99 100 this.set('_keyDownHandler', this.keyDownHandler.bind(this)); 101 document.addEventListener('keydown', this._keyDownHandler); 102 } 103 104 willDestroyElement() { 105 super.willDestroyElement(...arguments); 106 this.element.removeEventListener('scroll', this._scrollHandler); 107 document.removeEventListener('keydown', this._keyDownHandler); 108 } 109 110 windowResizeHandler() { 111 if (this.shouldFillHeight) { 112 once(this, this.fillAvailableHeight); 113 } 114 } 115 116 fillAvailableHeight() { 117 // This math is arbitrary and far from bulletproof, but the UX 118 // of having the log window fill available height is worth the hack. 119 const margins = 30; // Account for padding and margin on either side of the CLI 120 const cliWindow = this.element; 121 cliWindow.style.height = `${ 122 window.innerHeight - cliWindow.offsetTop - margins 123 }px`; 124 } 125 126 @task(function* () { 127 yield this.get('logger.gotoHead').perform(); 128 scheduleOnce('afterRender', this, this.scrollToTop); 129 }) 130 head; 131 132 scrollToTop() { 133 this.element.scrollTop = 0; 134 } 135 136 @task(function* () { 137 yield this.get('logger.gotoTail').perform(); 138 }) 139 tail; 140 141 synchronizeScrollPosition() { 142 if (this.follow) { 143 this.element.scrollTop = this.element.scrollHeight; 144 } 145 } 146 147 @task(function* () { 148 // Follow the log if the scroll position is near the bottom of the cli window 149 this.logger.on('tick', this, 'scheduleScrollSynchronization'); 150 151 yield this.logger.startStreaming(); 152 this.logger.off('tick', this, 'scheduleScrollSynchronization'); 153 }) 154 stream; 155 156 scheduleScrollSynchronization() { 157 scheduleOnce('afterRender', this, this.synchronizeScrollPosition); 158 } 159 160 willDestroy() { 161 super.willDestroy(...arguments); 162 this.logger.stop(); 163 } 164 }