-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #6048 from hashicorp/f-ui/alloc-fs-files
UI: Alloc FS: File Viewer
- Loading branch information
Showing
36 changed files
with
1,289 additions
and
247 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import Component from '@ember/component'; | ||
import { computed } from '@ember/object'; | ||
import { isEmpty } from '@ember/utils'; | ||
|
||
export default Component.extend({ | ||
tagName: 'nav', | ||
classNames: ['breadcrumb'], | ||
|
||
'data-test-fs-breadcrumbs': true, | ||
|
||
task: null, | ||
path: null, | ||
|
||
breadcrumbs: computed('path', function() { | ||
const breadcrumbs = this.path | ||
.split('/') | ||
.reject(isEmpty) | ||
.reduce((breadcrumbs, pathSegment, index) => { | ||
let breadcrumbPath; | ||
|
||
if (index > 0) { | ||
const lastBreadcrumb = breadcrumbs[index - 1]; | ||
breadcrumbPath = `${lastBreadcrumb.path}/${pathSegment}`; | ||
} else { | ||
breadcrumbPath = pathSegment; | ||
} | ||
|
||
breadcrumbs.push({ | ||
name: pathSegment, | ||
path: breadcrumbPath, | ||
}); | ||
|
||
return breadcrumbs; | ||
}, []); | ||
|
||
if (breadcrumbs.length) { | ||
breadcrumbs[breadcrumbs.length - 1].isLast = true; | ||
} | ||
|
||
return breadcrumbs; | ||
}), | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import Component from '@ember/component'; | ||
import { computed } from '@ember/object'; | ||
|
||
export default Component.extend({ | ||
tagName: 'figure', | ||
classNames: 'image-file', | ||
'data-test-image-file': true, | ||
|
||
src: null, | ||
alt: null, | ||
size: null, | ||
|
||
// Set by updateImageMeta | ||
width: 0, | ||
height: 0, | ||
|
||
fileName: computed('src', function() { | ||
if (!this.src) return; | ||
return this.src.includes('/') ? this.src.match(/^.*\/(.*)$/)[1] : this.src; | ||
}), | ||
|
||
updateImageMeta(event) { | ||
const img = event.target; | ||
this.setProperties({ | ||
width: img.naturalWidth, | ||
height: img.naturalHeight, | ||
}); | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import Component from '@ember/component'; | ||
import { run } from '@ember/runloop'; | ||
import { task } from 'ember-concurrency'; | ||
import WindowResizable from 'nomad-ui/mixins/window-resizable'; | ||
|
||
export default Component.extend(WindowResizable, { | ||
tagName: 'pre', | ||
classNames: ['cli-window'], | ||
'data-test-log-cli': true, | ||
|
||
mode: 'streaming', // head, tail, streaming | ||
isStreaming: true, | ||
logger: null, | ||
|
||
didReceiveAttrs() { | ||
if (!this.logger) { | ||
return; | ||
} | ||
|
||
run.scheduleOnce('actions', () => { | ||
switch (this.mode) { | ||
case 'head': | ||
this.head.perform(); | ||
break; | ||
case 'tail': | ||
this.tail.perform(); | ||
break; | ||
case 'streaming': | ||
if (this.isStreaming) { | ||
this.stream.perform(); | ||
} else { | ||
this.logger.stop(); | ||
} | ||
break; | ||
} | ||
}); | ||
}, | ||
|
||
didInsertElement() { | ||
this.fillAvailableHeight(); | ||
}, | ||
|
||
windowResizeHandler() { | ||
run.once(this, this.fillAvailableHeight); | ||
}, | ||
|
||
fillAvailableHeight() { | ||
// This math is arbitrary and far from bulletproof, but the UX | ||
// of having the log window fill available height is worth the hack. | ||
const margins = 30; // Account for padding and margin on either side of the CLI | ||
const cliWindow = this.element; | ||
cliWindow.style.height = `${window.innerHeight - cliWindow.offsetTop - margins}px`; | ||
}, | ||
|
||
head: task(function*() { | ||
yield this.get('logger.gotoHead').perform(); | ||
run.scheduleOnce('afterRender', () => { | ||
this.element.scrollTop = 0; | ||
}); | ||
}), | ||
|
||
tail: task(function*() { | ||
yield this.get('logger.gotoTail').perform(); | ||
run.scheduleOnce('afterRender', () => { | ||
const cliWindow = this.element; | ||
cliWindow.scrollTop = cliWindow.scrollHeight; | ||
}); | ||
}), | ||
|
||
synchronizeScrollPosition(force = false) { | ||
const cliWindow = this.element; | ||
if (cliWindow.scrollHeight - cliWindow.scrollTop < 10 || force) { | ||
// If the window is approximately scrolled to the bottom, follow the log | ||
cliWindow.scrollTop = cliWindow.scrollHeight; | ||
} | ||
}, | ||
|
||
stream: task(function*() { | ||
// Force the scroll position to the bottom of the window when starting streaming | ||
this.logger.one('tick', () => { | ||
run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition(true)); | ||
}); | ||
|
||
// Follow the log if the scroll position is near the bottom of the cli window | ||
this.logger.on('tick', () => { | ||
run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition()); | ||
}); | ||
|
||
yield this.logger.startStreaming(); | ||
this.logger.off('tick'); | ||
}), | ||
|
||
willDestroy() { | ||
this.logger.stop(); | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
import { inject as service } from '@ember/service'; | ||
import Component from '@ember/component'; | ||
import { computed } from '@ember/object'; | ||
import { gt } from '@ember/object/computed'; | ||
import { equal } from '@ember/object/computed'; | ||
import RSVP from 'rsvp'; | ||
import Log from 'nomad-ui/utils/classes/log'; | ||
import timeout from 'nomad-ui/utils/timeout'; | ||
|
||
export default Component.extend({ | ||
token: service(), | ||
|
||
classNames: ['boxed-section', 'task-log'], | ||
|
||
'data-test-file-viewer': true, | ||
|
||
allocation: null, | ||
task: null, | ||
file: null, | ||
stat: null, // { Name, IsDir, Size, FileMode, ModTime, ContentType } | ||
|
||
// When true, request logs from the server agent | ||
useServer: false, | ||
|
||
// When true, logs cannot be fetched from either the client or the server | ||
noConnection: false, | ||
|
||
clientTimeout: 1000, | ||
serverTimeout: 5000, | ||
|
||
mode: 'head', | ||
|
||
fileComponent: computed('stat.ContentType', function() { | ||
const contentType = this.stat.ContentType || ''; | ||
|
||
if (contentType.startsWith('image/')) { | ||
return 'image'; | ||
} else if (contentType.startsWith('text/') || contentType.startsWith('application/json')) { | ||
return 'stream'; | ||
} else { | ||
return 'unknown'; | ||
} | ||
}), | ||
|
||
isLarge: gt('stat.Size', 50000), | ||
|
||
fileTypeIsUnknown: equal('fileComponent', 'unknown'), | ||
isStreamable: equal('fileComponent', 'stream'), | ||
isStreaming: false, | ||
|
||
catUrl: computed('allocation.id', 'task.name', 'file', function() { | ||
const encodedPath = encodeURIComponent(`${this.task.name}/${this.file}`); | ||
return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`; | ||
}), | ||
|
||
fetchMode: computed('isLarge', 'mode', function() { | ||
if (this.mode === 'streaming') { | ||
return 'stream'; | ||
} | ||
|
||
if (!this.isLarge) { | ||
return 'cat'; | ||
} else if (this.mode === 'head' || this.mode === 'tail') { | ||
return 'readat'; | ||
} | ||
}), | ||
|
||
fileUrl: computed( | ||
'allocation.id', | ||
'allocation.node.httpAddr', | ||
'fetchMode', | ||
'useServer', | ||
function() { | ||
const address = this.get('allocation.node.httpAddr'); | ||
const url = `/v1/client/fs/${this.fetchMode}/${this.allocation.id}`; | ||
return this.useServer ? url : `//${address}${url}`; | ||
} | ||
), | ||
|
||
fileParams: computed('task.name', 'file', 'mode', function() { | ||
// The Log class handles encoding query params | ||
const path = `${this.task.name}/${this.file}`; | ||
|
||
switch (this.mode) { | ||
case 'head': | ||
return { path, offset: 0, limit: 50000 }; | ||
case 'tail': | ||
return { path, offset: this.stat.Size - 50000, limit: 50000 }; | ||
case 'streaming': | ||
return { path, offset: 50000, origin: 'end' }; | ||
default: | ||
return { path }; | ||
} | ||
}), | ||
|
||
logger: computed('fileUrl', 'fileParams', 'mode', function() { | ||
// The cat and readat APIs are in plainText while the stream API is always encoded. | ||
const plainText = this.mode === 'head' || this.mode === 'tail'; | ||
|
||
// If the file request can't settle in one second, the client | ||
// must be unavailable and the server should be used instead | ||
const timing = this.useServer ? this.serverTimeout : this.clientTimeout; | ||
const logFetch = url => | ||
RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then( | ||
response => { | ||
if (!response || !response.ok) { | ||
this.nextErrorState(response); | ||
} | ||
return response; | ||
}, | ||
error => this.nextErrorState(error) | ||
); | ||
|
||
return Log.create({ | ||
logFetch, | ||
plainText, | ||
params: this.fileParams, | ||
url: this.fileUrl, | ||
}); | ||
}), | ||
|
||
nextErrorState(error) { | ||
if (this.useServer) { | ||
this.set('noConnection', true); | ||
} else { | ||
this.send('failoverToServer'); | ||
} | ||
throw error; | ||
}, | ||
|
||
actions: { | ||
toggleStream() { | ||
this.set('mode', 'streaming'); | ||
this.toggleProperty('isStreaming'); | ||
}, | ||
gotoHead() { | ||
this.set('mode', 'head'); | ||
this.set('isStreaming', false); | ||
}, | ||
gotoTail() { | ||
this.set('mode', 'tail'); | ||
this.set('isStreaming', false); | ||
}, | ||
failoverToServer() { | ||
this.set('useServer', true); | ||
}, | ||
}, | ||
}); |
Oops, something went wrong.