github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/components/fs/file.js (about) 1 import Ember from 'ember'; 2 import { inject as service } from '@ember/service'; 3 import Component from '@ember/component'; 4 import { action, computed } from '@ember/object'; 5 import { equal, gt } from '@ember/object/computed'; 6 import RSVP from 'rsvp'; 7 import Log from 'nomad-ui/utils/classes/log'; 8 import timeout from 'nomad-ui/utils/timeout'; 9 import { classNames, attributeBindings } from '@ember-decorators/component'; 10 import classic from 'ember-classic-decorator'; 11 12 @classic 13 @classNames('boxed-section', 'task-log') 14 @attributeBindings('data-test-file-viewer') 15 export default class File extends Component { 16 @service token; 17 @service system; 18 19 'data-test-file-viewer' = true; 20 21 allocation = null; 22 taskState = null; 23 file = null; 24 stat = null; // { Name, IsDir, Size, FileMode, ModTime, ContentType } 25 26 // When true, request logs from the server agent 27 useServer = false; 28 29 // When true, logs cannot be fetched from either the client or the server 30 noConnection = false; 31 32 clientTimeout = 1000; 33 serverTimeout = 5000; 34 35 mode = 'head'; 36 37 @computed('stat.ContentType') 38 get fileComponent() { 39 const contentType = this.stat.ContentType || ''; 40 41 if (contentType.startsWith('image/')) { 42 return 'image'; 43 } else if ( 44 contentType.startsWith('text/') || 45 contentType.startsWith('application/json') 46 ) { 47 return 'stream'; 48 } else { 49 return 'unknown'; 50 } 51 } 52 53 @gt('stat.Size', 50000) isLarge; 54 55 @equal('fileComponent', 'unknown') fileTypeIsUnknown; 56 @equal('fileComponent', 'stream') isStreamable; 57 isStreaming = false; 58 59 @computed('allocation.id', 'taskState.name', 'file') 60 get catUrlWithoutRegion() { 61 const taskUrlPrefix = this.taskState ? `${this.taskState.name}/` : ''; 62 const encodedPath = encodeURIComponent(`${taskUrlPrefix}${this.file}`); 63 return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`; 64 } 65 66 @computed('catUrlWithoutRegion', 'system.{activeRegion,shouldIncludeRegion}') 67 get catUrl() { 68 let apiPath = this.catUrlWithoutRegion; 69 if (this.system.shouldIncludeRegion) { 70 apiPath += `®ion=${this.system.activeRegion}`; 71 } 72 return apiPath; 73 } 74 75 @computed('isLarge', 'mode') 76 get fetchMode() { 77 if (this.mode === 'streaming') { 78 return 'stream'; 79 } 80 81 if (!this.isLarge) { 82 return 'cat'; 83 } else if (this.mode === 'head' || this.mode === 'tail') { 84 return 'readat'; 85 } 86 87 return undefined; 88 } 89 90 @computed('allocation.{id,node.httpAddr}', 'fetchMode', 'useServer') 91 get fileUrl() { 92 const address = this.get('allocation.node.httpAddr'); 93 const url = `/v1/client/fs/${this.fetchMode}/${this.allocation.id}`; 94 return this.useServer ? url : `//${address}${url}`; 95 } 96 97 @computed('file', 'mode', 'stat.Size', 'taskState.name') 98 get fileParams() { 99 // The Log class handles encoding query params 100 const taskUrlPrefix = this.taskState ? `${this.taskState.name}/` : ''; 101 const path = `${taskUrlPrefix}${this.file}`; 102 103 switch (this.mode) { 104 case 'head': 105 return { path, offset: 0, limit: 50000 }; 106 case 'tail': 107 return { path, offset: this.stat.Size - 50000, limit: 50000 }; 108 case 'streaming': 109 return { path, offset: 50000, origin: 'end' }; 110 default: 111 return { path }; 112 } 113 } 114 115 @computed( 116 'clientTimeout', 117 'fileParams', 118 'fileUrl', 119 'mode', 120 'serverTimeout', 121 'useServer' 122 ) 123 get logger() { 124 // The cat and readat APIs are in plainText while the stream API is always encoded. 125 const plainText = this.mode === 'head' || this.mode === 'tail'; 126 127 // If the file request can't settle in one second, the client 128 // must be unavailable and the server should be used instead 129 const timing = this.useServer ? this.serverTimeout : this.clientTimeout; 130 const logFetch = (url) => 131 RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then( 132 (response) => { 133 if (!response || !response.ok) { 134 this.nextErrorState(response); 135 } 136 return response; 137 }, 138 (error) => this.nextErrorState(error) 139 ); 140 141 return Log.create({ 142 logFetch, 143 plainText, 144 params: this.fileParams, 145 url: this.fileUrl, 146 }); 147 } 148 149 nextErrorState(error) { 150 if (this.useServer) { 151 this.set('noConnection', true); 152 } else { 153 this.send('failoverToServer'); 154 } 155 throw error; 156 } 157 158 @action 159 toggleStream() { 160 this.set('mode', 'streaming'); 161 this.toggleProperty('isStreaming'); 162 } 163 164 @action 165 gotoHead() { 166 this.set('mode', 'head'); 167 this.set('isStreaming', false); 168 } 169 170 @action 171 gotoTail() { 172 this.set('mode', 'tail'); 173 this.set('isStreaming', false); 174 } 175 176 @action 177 failoverToServer() { 178 this.set('useServer', true); 179 } 180 181 @action 182 async downloadFile() { 183 const timing = this.useServer ? this.serverTimeout : this.clientTimeout; 184 185 try { 186 const response = await RSVP.race([ 187 this.token.authorizedRequest(this.catUrlWithoutRegion), 188 timeout(timing), 189 ]); 190 191 if (!response || !response.ok) throw new Error('file download timeout'); 192 193 // Don't download in tests. Unfortunately, since the download is triggered 194 // by the download attribute of the ephemeral anchor element, there's no 195 // way to stub this in tests. 196 if (Ember.testing) return; 197 198 const blob = await response.blob(); 199 const url = window.URL.createObjectURL(blob); 200 const downloadAnchor = document.createElement('a'); 201 202 downloadAnchor.href = url; 203 downloadAnchor.target = '_blank'; 204 downloadAnchor.rel = 'noopener noreferrer'; 205 downloadAnchor.download = this.file; 206 207 // Appending the element to the DOM is required for Firefox support 208 document.body.appendChild(downloadAnchor); 209 downloadAnchor.click(); 210 downloadAnchor.remove(); 211 212 window.URL.revokeObjectURL(url); 213 } catch (err) { 214 this.nextErrorState(err); 215 } 216 } 217 }