diff --git a/ui/app/adapters/task-state.js b/ui/app/adapters/task-state.js index e4a08e40124c..f9f98ca6271e 100644 --- a/ui/app/adapters/task-state.js +++ b/ui/app/adapters/task-state.js @@ -6,13 +6,15 @@ export default ApplicationAdapter.extend({ ls(model, path) { return this.token - .authorizedRequest(`/v1/client/fs/ls/${model.allocation.id}?path=${path}`) + .authorizedRequest(`/v1/client/fs/ls/${model.allocation.id}?path=${encodeURIComponent(path)}`) .then(handleFSResponse); }, stat(model, path) { return this.token - .authorizedRequest(`/v1/client/fs/stat/${model.allocation.id}?path=${path}`) + .authorizedRequest( + `/v1/client/fs/stat/${model.allocation.id}?path=${encodeURIComponent(path)}` + ) .then(handleFSResponse); }, }); diff --git a/ui/app/components/fs-breadcrumbs.js b/ui/app/components/fs-breadcrumbs.js new file mode 100644 index 000000000000..f613a0c4933c --- /dev/null +++ b/ui/app/components/fs-breadcrumbs.js @@ -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; + }), +}); diff --git a/ui/app/components/fs-directory-entry.js b/ui/app/components/fs-directory-entry.js index 73a527421540..f50bb84560d3 100644 --- a/ui/app/components/fs-directory-entry.js +++ b/ui/app/components/fs-directory-entry.js @@ -7,7 +7,7 @@ export default Component.extend({ pathToEntry: computed('path', 'entry.Name', function() { const pathWithNoLeadingSlash = this.get('path').replace(/^\//, ''); - const name = this.get('entry.Name'); + const name = encodeURIComponent(this.get('entry.Name')); if (isEmpty(pathWithNoLeadingSlash)) { return name; diff --git a/ui/app/components/image-file.js b/ui/app/components/image-file.js new file mode 100644 index 000000000000..fe28201ba027 --- /dev/null +++ b/ui/app/components/image-file.js @@ -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, + }); + }, +}); diff --git a/ui/app/components/streaming-file.js b/ui/app/components/streaming-file.js new file mode 100644 index 000000000000..9a85d41a19f4 --- /dev/null +++ b/ui/app/components/streaming-file.js @@ -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(); + }, +}); diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js new file mode 100644 index 000000000000..03f4e52c4e09 --- /dev/null +++ b/ui/app/components/task-file.js @@ -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); + }, + }, +}); diff --git a/ui/app/components/task-log.js b/ui/app/components/task-log.js index c40e4a28409b..b5cda3eed3ac 100644 --- a/ui/app/components/task-log.js +++ b/ui/app/components/task-log.js @@ -1,14 +1,11 @@ import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { computed } from '@ember/object'; -import { run } from '@ember/runloop'; import RSVP from 'rsvp'; -import { task } from 'ember-concurrency'; import { logger } from 'nomad-ui/utils/classes/log'; -import WindowResizable from 'nomad-ui/mixins/window-resizable'; import timeout from 'nomad-ui/utils/timeout'; -export default Component.extend(WindowResizable, { +export default Component.extend({ token: service(), classNames: ['boxed-section', 'task-log'], @@ -25,26 +22,8 @@ export default Component.extend(WindowResizable, { clientTimeout: 1000, serverTimeout: 5000, - didReceiveAttrs() { - if (this.allocation && this.task) { - this.send('toggleStream'); - } - }, - - 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 cliWindow = this.$('.cli-window'); - cliWindow.height(window.innerHeight - cliWindow.offset().top - 25); - }, + isStreaming: true, + streamMode: 'streaming', mode: 'stdout', @@ -75,56 +54,28 @@ export default Component.extend(WindowResizable, { this.set('noConnection', true); } else { this.send('failoverToServer'); - this.stream.perform(); } throw error; } ); }), - head: task(function*() { - yield this.get('logger.gotoHead').perform(); - run.scheduleOnce('afterRender', () => { - this.$('.cli-window').scrollTop(0); - }); - }), - - tail: task(function*() { - yield this.get('logger.gotoTail').perform(); - run.scheduleOnce('afterRender', () => { - const cliWindow = this.$('.cli-window'); - cliWindow.scrollTop(cliWindow[0].scrollHeight); - }); - }), - - stream: task(function*() { - this.logger.on('tick', () => { - run.scheduleOnce('afterRender', () => { - const cliWindow = this.$('.cli-window'); - cliWindow.scrollTop(cliWindow[0].scrollHeight); - }); - }); - - yield this.logger.startStreaming(); - this.logger.off('tick'); - }), - - willDestroy() { - this.logger.stop(); - }, - actions: { setMode(mode) { this.logger.stop(); this.set('mode', mode); - this.stream.perform(); }, toggleStream() { - if (this.get('logger.isStreaming')) { - this.logger.stop(); - } else { - this.stream.perform(); - } + this.set('streamMode', 'streaming'); + this.toggleProperty('isStreaming'); + }, + gotoHead() { + this.set('streamMode', 'head'); + this.set('isStreaming', false); + }, + gotoTail() { + this.set('streamMode', 'tail'); + this.set('isStreaming', false); }, failoverToServer() { this.set('useServer', true); diff --git a/ui/app/controllers/allocations/allocation/task/fs.js b/ui/app/controllers/allocations/allocation/task/fs.js index d4b0f27d0ff5..bfece091338a 100644 --- a/ui/app/controllers/allocations/allocation/task/fs.js +++ b/ui/app/controllers/allocations/allocation/task/fs.js @@ -1,7 +1,6 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; import { filterBy } from '@ember/object/computed'; -import { isEmpty } from '@ember/utils'; export default Controller.extend({ queryParams: { @@ -16,6 +15,7 @@ export default Controller.extend({ task: null, directoryEntries: null, isFile: null, + stat: null, directories: filterBy('directoryEntries', 'IsDir'), files: filterBy('directoryEntries', 'IsDir', false), @@ -51,33 +51,4 @@ export default Controller.extend({ } } ), - - 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; - }), }); diff --git a/ui/app/routes/allocations/allocation/task/fs.js b/ui/app/routes/allocations/allocation/task/fs.js index f592e77a0df1..cc0b6213ae25 100644 --- a/ui/app/routes/allocations/allocation/task/fs.js +++ b/ui/app/routes/allocations/allocation/task/fs.js @@ -9,9 +9,8 @@ export default Route.extend({ const pathWithTaskName = `${task.name}${decodedPath.startsWith('/') ? '' : '/'}${decodedPath}`; - return task - .stat(pathWithTaskName) - .then(statJson => { + return RSVP.all([task.stat(pathWithTaskName), task.get('allocation.node')]) + .then(([statJson]) => { if (statJson.IsDir) { return RSVP.hash({ path: decodedPath, @@ -24,14 +23,15 @@ export default Route.extend({ path: decodedPath, task, isFile: true, + stat: statJson, }; } }) .catch(notifyError(this)); }, - setupController(controller, { path, task, directoryEntries, isFile } = {}) { + setupController(controller, { path, task, directoryEntries, isFile, stat } = {}) { this._super(...arguments); - controller.setProperties({ path, task, directoryEntries, isFile }); + controller.setProperties({ path, task, directoryEntries, isFile, stat }); }, }); diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 276bfa13f9c2..6941c2443232 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -11,6 +11,7 @@ @import './components/fs-explorer'; @import './components/gutter'; @import './components/gutter-toggle'; +@import './components/image-file.scss'; @import './components/inline-definitions'; @import './components/job-diff'; @import './components/loading-spinner'; diff --git a/ui/app/styles/components/empty-message.scss b/ui/app/styles/components/empty-message.scss index 3f00021ebdc9..944a92011dac 100644 --- a/ui/app/styles/components/empty-message.scss +++ b/ui/app/styles/components/empty-message.scss @@ -18,4 +18,8 @@ color: $grey; } } + + &.is-hollow { + background: transparent; + } } diff --git a/ui/app/styles/components/image-file.scss b/ui/app/styles/components/image-file.scss new file mode 100644 index 000000000000..a9b6d59201f5 --- /dev/null +++ b/ui/app/styles/components/image-file.scss @@ -0,0 +1,20 @@ +.image-file { + width: 100%; + height: 100%; + background: $white; + text-align: center; + color: $text; + + .image-file-image { + margin: auto; + } + + .image-file-caption { + margin-top: 0.5em; + } + + .image-file-caption-primary { + display: block; + color: $grey; + } +} diff --git a/ui/app/styles/components/page-layout.scss b/ui/app/styles/components/page-layout.scss index b654dfb16457..fa69c11f1da4 100644 --- a/ui/app/styles/components/page-layout.scss +++ b/ui/app/styles/components/page-layout.scss @@ -36,6 +36,7 @@ &.is-right { margin-left: $gutter-width; + width: calc(100% - #{$gutter-width}); } @media #{$mq-hidden-gutter} { @@ -51,6 +52,7 @@ &.is-right { margin-left: 0; + width: 100%; } } } diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index bad307ee8c2e..41a90d585e18 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -30,7 +30,7 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2); &.is-compact { padding: 0.25em 0.75em; - margin: -0.25em -0.25em -0.25em 0; + margin: -0.25em 0; &.pull-right { margin-right: -1em; diff --git a/ui/app/templates/allocations/allocation/task/fs.hbs b/ui/app/templates/allocations/allocation/task/fs.hbs index be5edc4267ac..41ea6bae566b 100644 --- a/ui/app/templates/allocations/allocation/task/fs.hbs +++ b/ui/app/templates/allocations/allocation/task/fs.hbs @@ -1,32 +1,16 @@ {{title pathWithLeadingSlash " - Task " task.name " filesystem"}} {{task-subnav task=task}} -
+
{{#if task.isRunning}} -
-
- -
- - {{#if isFile}} -
-
placeholder file viewer
+ {{#if isFile}} + {{#task-file allocation=task.allocation task=task file=path stat=stat class="fs-explorer"}} + {{fs-breadcrumbs task=task path=path}} + {{/task-file}} + {{else}} +
+
+ {{fs-breadcrumbs task=task path=path}}
- {{else}} {{#list-table source=sortedDirectoryEntries sortProperty=sortProperty @@ -52,8 +36,8 @@ {{/if}} {{/list-table}} - {{/if}} -
+
+ {{/if}} {{else}}

Task is not Running

diff --git a/ui/app/templates/components/fs-breadcrumbs.hbs b/ui/app/templates/components/fs-breadcrumbs.hbs new file mode 100644 index 000000000000..97aba6398a5f --- /dev/null +++ b/ui/app/templates/components/fs-breadcrumbs.hbs @@ -0,0 +1,14 @@ +
    +
  • + {{#link-to "allocations.allocation.task.fs-root" task.allocation task activeClass="is-active"}} + {{task.name}} + {{/link-to}} +
  • + {{#each breadcrumbs as |breadcrumb|}} +
  • + {{#link-to "allocations.allocation.task.fs" task.allocation task breadcrumb.path activeClass="is-active"}} + {{breadcrumb.name}} + {{/link-to}} +
  • + {{/each}} +
\ No newline at end of file diff --git a/ui/app/templates/components/image-file.hbs b/ui/app/templates/components/image-file.hbs new file mode 100644 index 000000000000..9228740d4871 --- /dev/null +++ b/ui/app/templates/components/image-file.hbs @@ -0,0 +1,11 @@ + + {{or + +
+ + {{fileName}} + {{#if (and width height)}} + ({{width}}px × {{height}}px{{#if size}}, {{format-bytes size}}{{/if}}) + {{/if}} + +
\ No newline at end of file diff --git a/ui/app/templates/components/streaming-file.hbs b/ui/app/templates/components/streaming-file.hbs new file mode 100644 index 000000000000..fa97a815e2ef --- /dev/null +++ b/ui/app/templates/components/streaming-file.hbs @@ -0,0 +1 @@ +{{logger.output}} \ No newline at end of file diff --git a/ui/app/templates/components/task-file.hbs b/ui/app/templates/components/task-file.hbs new file mode 100644 index 000000000000..bed5c27620f9 --- /dev/null +++ b/ui/app/templates/components/task-file.hbs @@ -0,0 +1,39 @@ +{{#if noConnection}} +
+

Cannot fetch file

+

The files for this task are inaccessible. Check the condition of the client the allocation is on.

+
+{{/if}} +
+ {{yield}} + + + {{#if (not fileTypeIsUnknown)}} + View Raw File + {{/if}} + {{#if (and isLarge isStreamable)}} + + + {{/if}} + {{#if isStreamable}} + + {{/if}} + +
+
+ {{#if (eq fileComponent "stream")}} + {{streaming-file logger=logger mode=mode isStreaming=isStreaming}} + {{else if (eq fileComponent "image")}} + {{image-file src=catUrl alt=stat.Name size=stat.Size}} + {{else}} +
+

Unsupported File Type

+

The Nomad UI could not render this file, but you can still view the file directly.

+

+ View Raw File +

+
+ {{/if}} +
diff --git a/ui/app/templates/components/task-log.hbs b/ui/app/templates/components/task-log.hbs index 6c3991a359c2..89a363747aed 100644 --- a/ui/app/templates/components/task-log.hbs +++ b/ui/app/templates/components/task-log.hbs @@ -10,13 +10,13 @@ - - + +
-
{{logger.output}}
+ {{streaming-file logger=logger mode=streamMode isStreaming=isStreaming}}
diff --git a/ui/app/utils/classes/log.js b/ui/app/utils/classes/log.js index 82f96ebc3a99..597dfdc880ae 100644 --- a/ui/app/utils/classes/log.js +++ b/ui/app/utils/classes/log.js @@ -8,6 +8,7 @@ import queryString from 'query-string'; import { task } from 'ember-concurrency'; import StreamLogger from 'nomad-ui/utils/classes/stream-logger'; import PollLogger from 'nomad-ui/utils/classes/poll-logger'; +import { decode } from 'nomad-ui/utils/stream-frames'; import Anser from 'anser'; const MAX_OUTPUT_LENGTH = 50000; @@ -20,6 +21,7 @@ const Log = EmberObject.extend(Evented, { url: '', params: computed(() => ({})), + plainText: false, logFetch() { assert('Log objects need a logFetch method, which should have an interface like window.fetch'); }, @@ -40,6 +42,7 @@ const Log = EmberObject.extend(Evented, { // the logPointer is pointed at head or tail output: computed('logPointer', 'head', 'tail', function() { let logs = this.logPointer === 'head' ? this.head : this.tail; + logs = logs.replace(//g, '>'); let colouredLogs = Anser.ansiToHtml(logs); return htmlSafe(colouredLogs); }), @@ -72,16 +75,19 @@ const Log = EmberObject.extend(Evented, { gotoHead: task(function*() { const logFetch = this.logFetch; const queryParams = queryString.stringify( - assign(this.params, { - plain: true, - origin: 'start', - offset: 0, - }) + assign( + { + origin: 'start', + offset: 0, + }, + this.params + ) ); const url = `${this.url}?${queryParams}`; this.stop(); - let text = yield logFetch(url).then(res => res.text(), fetchFailure(url)); + const response = yield logFetch(url).then(res => res.text(), fetchFailure(url)); + let text = this.plainText ? response : decode(response).message; if (text && text.length > MAX_OUTPUT_LENGTH) { text = text.substr(0, MAX_OUTPUT_LENGTH); @@ -94,16 +100,19 @@ const Log = EmberObject.extend(Evented, { gotoTail: task(function*() { const logFetch = this.logFetch; const queryParams = queryString.stringify( - assign(this.params, { - plain: true, - origin: 'end', - offset: MAX_OUTPUT_LENGTH, - }) + assign( + { + origin: 'end', + offset: MAX_OUTPUT_LENGTH, + }, + this.params + ) ); const url = `${this.url}?${queryParams}`; this.stop(); - let text = yield logFetch(url).then(res => res.text(), fetchFailure(url)); + const response = yield logFetch(url).then(res => res.text(), fetchFailure(url)); + let text = this.plainText ? response : decode(response).message; this.set('tail', text); this.set('logPointer', 'tail'); diff --git a/ui/app/utils/classes/poll-logger.js b/ui/app/utils/classes/poll-logger.js index 2c18e3d14634..ce42fb8dc96f 100644 --- a/ui/app/utils/classes/poll-logger.js +++ b/ui/app/utils/classes/poll-logger.js @@ -1,5 +1,6 @@ import EmberObject from '@ember/object'; import { task, timeout } from 'ember-concurrency'; +import { decode } from 'nomad-ui/utils/stream-frames'; import AbstractLogger from './abstract-logger'; import { fetchFailure } from './log'; @@ -7,9 +8,7 @@ export default EmberObject.extend(AbstractLogger, { interval: 1000, start() { - return this.poll - .linked() - .perform(); + return this.poll.linked().perform(); }, stop() { @@ -29,15 +28,10 @@ export default EmberObject.extend(AbstractLogger, { let text = yield response.text(); if (text) { - const lines = text.replace(/\}\{/g, '}\n{').split('\n'); - const frames = lines - .map(line => JSON.parse(line)) - .filter(frame => frame.Data != null && frame.Offset != null); - - if (frames.length) { - frames.forEach(frame => (frame.Data = window.atob(frame.Data))); - this.set('endOffset', frames[frames.length - 1].Offset); - this.write(frames.mapBy('Data').join('')); + const { offset, message } = decode(text); + if (message) { + this.set('endOffset', offset); + this.write(message); } } diff --git a/ui/app/utils/classes/stream-logger.js b/ui/app/utils/classes/stream-logger.js index aad60a4f904b..986d230a219c 100644 --- a/ui/app/utils/classes/stream-logger.js +++ b/ui/app/utils/classes/stream-logger.js @@ -1,6 +1,7 @@ import EmberObject, { computed } from '@ember/object'; import { task } from 'ember-concurrency'; import TextDecoder from 'nomad-ui/utils/classes/text-decoder'; +import { decode } from 'nomad-ui/utils/stream-frames'; import AbstractLogger from './abstract-logger'; import { fetchFailure } from './log'; @@ -60,13 +61,10 @@ export default EmberObject.extend(AbstractLogger, { // Assuming the logs endpoint never returns nested JSON (it shouldn't), at this // point chunk is a series of valid JSON objects with no delimiter. - const lines = chunk.replace(/\}\{/g, '}\n{').split('\n'); - const frames = lines.map(line => JSON.parse(line)).filter(frame => frame.Data); - - if (frames.length) { - frames.forEach(frame => (frame.Data = window.atob(frame.Data))); - this.set('endOffset', frames[frames.length - 1].Offset); - this.write(frames.mapBy('Data').join('')); + const { offset, message } = decode(chunk); + if (message) { + this.set('endOffset', offset); + this.write(message); } } }); diff --git a/ui/app/utils/stream-frames.js b/ui/app/utils/stream-frames.js new file mode 100644 index 000000000000..bd4253406553 --- /dev/null +++ b/ui/app/utils/stream-frames.js @@ -0,0 +1,26 @@ +/** + * + * @param {string} chunk + * Chunk is an undelimited string of valid JSON objects as returned by a streaming endpoint. + * Each JSON object in a chunk contains two properties: + * Offset {number} The index from the beginning of the stream at which this JSON object starts + * Data {string} A base64 encoded string representing the contents of the stream this JSON + * object represents. + */ +export function decode(chunk) { + const lines = chunk + .replace(/\}\{/g, '}\n{') + .split('\n') + .without(''); + const frames = lines.map(line => JSON.parse(line)).filter(frame => frame.Data); + + if (frames.length) { + frames.forEach(frame => (frame.Data = window.atob(frame.Data))); + return { + offset: frames[frames.length - 1].Offset, + message: frames.mapBy('Data').join(''), + }; + } + + return {}; +} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 777124d7b972..9098e85fb981 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -5,13 +5,21 @@ import { logFrames, logEncode } from './data/logs'; import { generateDiff } from './factories/job-version'; import { generateTaskGroupFailures } from './factories/evaluation'; import { copy } from 'ember-copy'; -import moment from 'moment'; export function findLeader(schema) { const agent = schema.agents.first(); return `${agent.address}:${agent.tags.port}`; } +export function filesForPath(allocFiles, filterPath) { + return allocFiles.where( + file => + (!filterPath || file.path.startsWith(filterPath)) && + file.path.length > filterPath.length && + !file.path.substr(filterPath.length + 1).includes('/') + ); +} + export default function() { this.timing = 0; // delay for each request, automatically set to 0 during testing @@ -305,63 +313,68 @@ export default function() { return logEncode(logFrames, logFrames.length - 1); }; - const clientAllocationFSLsHandler = function(schema, { queryParams }) { - if (queryParams.path.endsWith('empty-directory')) { - return []; - } else if (queryParams.path.endsWith('directory')) { - return [ - { - Name: 'another', - IsDir: true, - ModTime: moment().format(), - }, - ]; - } else if (queryParams.path.endsWith('another')) { - return [ - { - Name: 'something.txt', - IsDir: false, - ModTime: moment().format(), - }, - ]; - } else { - return [ - { - Name: '🤩.txt', - IsDir: false, - Size: 1919, - ModTime: moment() - .subtract(2, 'day') - .format(), - }, - { - Name: '🙌🏿.txt', - IsDir: false, - ModTime: moment() - .subtract(2, 'minute') - .format(), - }, - { - Name: 'directory', - IsDir: true, - Size: 3682561, - ModTime: moment() - .subtract(1, 'year') - .format(), - }, - { - Name: 'empty-directory', - IsDir: true, - ModTime: moment().format(), - }, - ]; + const clientAllocationFSLsHandler = function({ allocFiles }, { queryParams }) { + // Ignore the task name at the beginning of the path + const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1); + const files = filesForPath(allocFiles, filterPath); + return this.serialize(files); + }; + + const clientAllocationFSStatHandler = function({ allocFiles }, { queryParams }) { + // Ignore the task name at the beginning of the path + const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1); + + // Root path + if (!filterPath) { + return this.serialize({ + IsDir: true, + ModTime: new Date(), + }); } + + // Either a file or a nested directory + const file = allocFiles.where({ path: filterPath }).models[0]; + return this.serialize(file); }; - const clientAllocationFSStatHandler = function(schema, { queryParams }) { - return { - IsDir: !queryParams.path.endsWith('.txt'), - }; + const clientAllocationCatHandler = function({ allocFiles }, { queryParams }) { + const [file, err] = fileOrError(allocFiles, queryParams.path); + + if (err) return err; + return file.body; + }; + + const clientAllocationStreamHandler = function({ allocFiles }, { queryParams }) { + const [file, err] = fileOrError(allocFiles, queryParams.path); + + if (err) return err; + + // Pretender, and therefore Mirage, doesn't support streaming responses. + return file.body; + }; + + const clientAllocationReadAtHandler = function({ allocFiles }, { queryParams }) { + const [file, err] = fileOrError(allocFiles, queryParams.path); + + if (err) return err; + return file.body.substr(queryParams.offset || 0, queryParams.limit); + }; + + const fileOrError = function(allocFiles, path, message = 'Operation not allowed on a directory') { + // Ignore the task name at the beginning of the path + const filterPath = path.substr(path.indexOf('/') + 1); + + // Root path + if (!filterPath) { + return [null, new Response(400, {}, message)]; + } + + const file = allocFiles.where({ path: filterPath }).models[0]; + if (file.isDir) { + return [null, new Response(400, {}, message)]; + } + + return [file, null]; }; // Client requests are available on the server and the client @@ -374,6 +387,9 @@ export default function() { this.get('/client/fs/ls/:allocation_id', clientAllocationFSLsHandler); this.get('/client/fs/stat/:allocation_id', clientAllocationFSStatHandler); + this.get('/client/fs/cat/:allocation_id', clientAllocationCatHandler); + this.get('/client/fs/stream/:allocation_id', clientAllocationStreamHandler); + this.get('/client/fs/readat/:allocation_id', clientAllocationReadAtHandler); this.get('/client/stats', function({ clientStats }, { queryParams }) { const seed = Math.random(); @@ -397,6 +413,9 @@ export default function() { this.get(`http://${host}/v1/client/fs/ls/:allocation_id`, clientAllocationFSLsHandler); this.get(`http://${host}/v1/client/stat/ls/:allocation_id`, clientAllocationFSStatHandler); + this.get(`http://${host}/v1/client/fs/cat/:allocation_id`, clientAllocationCatHandler); + this.get(`http://${host}/v1/client/fs/stream/:allocation_id`, clientAllocationStreamHandler); + this.get(`http://${host}/v1/client/fs/readat/:allocation_id`, clientAllocationReadAtHandler); this.get(`http://${host}/v1/client/stats`, function({ clientStats }) { return this.serialize(clientStats.find(host)); diff --git a/ui/mirage/factories/alloc-file.js b/ui/mirage/factories/alloc-file.js new file mode 100644 index 000000000000..db9f4a9261f2 --- /dev/null +++ b/ui/mirage/factories/alloc-file.js @@ -0,0 +1,113 @@ +import { Factory, faker, trait } from 'ember-cli-mirage'; +import { pickOne } from '../utils'; + +const REF_TIME = new Date(); +const TROUBLESOME_CHARACTERS = '🏆 💃 🤩 🙌🏿 🖨 ? ; %'.split(' '); +const makeWord = () => Math.round(Math.random() * 10000000 + 50000).toString(36); +const makeSentence = (count = 10) => + new Array(count) + .fill(null) + .map(makeWord) + .join(' '); + +const fileTypeMapping = { + svg: 'image/svg', + txt: 'text/plain', + json: 'application/json', + app: 'application/octet-stream', + exe: 'application/octet-stream', +}; + +const fileBodyMapping = { + svg: () => ` + + + + + + + + + `, + txt: () => + new Array(3000) + .fill(null) + .map((_, i) => { + const date = new Date(2019, 6, 23); + date.setSeconds(i * 5); + return `${date.toISOString()} ${makeSentence(Math.round(Math.random() * 5 + 7))}`; + }) + .join('\n'), + json: () => + JSON.stringify({ + key: 'value', + array: [1, 'two', [3]], + deep: { + ly: { + nest: 'ed', + }, + }, + }), +}; + +export default Factory.extend({ + id: i => i, + + isDir: faker.random.boolean, + + // Depth is used to recursively create nested directories. + depth: 0, + parent: null, + + fileType() { + if (this.isDir) return 'dir'; + return pickOne(['svg', 'txt', 'json', 'app', 'exe']); + }, + + contentType() { + return fileTypeMapping[this.fileType] || null; + }, + + path() { + if (this.parent) { + return `${this.parent.path}/${this.name}`; + } + + return this.name; + }, + + name() { + return `${faker.hacker.noun().dasherize()}-${pickOne(TROUBLESOME_CHARACTERS)}${ + this.isDir ? '' : `.${this.fileType}` + }`; + }, + + body() { + const strategy = fileBodyMapping[this.fileType]; + return strategy ? strategy() : ''; + }, + + size() { + return this.body.length; + }, + + modTime: () => faker.date.past(2 / 365, REF_TIME), + + dir: trait({ + isDir: true, + afterCreate(allocFile, server) { + // create files for the directory + if (allocFile.depth > 0) { + server.create('allocFile', 'dir', { parent: allocFile, depth: allocFile.depth - 1 }); + } + + server.createList('allocFile', faker.random.number({ min: 1, max: 3 }), 'file', { + parent: allocFile, + }); + }, + }), + + file: trait({ + isDir: false, + }), +}); diff --git a/ui/mirage/models/alloc-file.js b/ui/mirage/models/alloc-file.js new file mode 100644 index 000000000000..23677eb033e4 --- /dev/null +++ b/ui/mirage/models/alloc-file.js @@ -0,0 +1,5 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + parent: belongsTo('alloc-file'), +}); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index e8aa6a242681..407c917bf292 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -40,6 +40,8 @@ function smallCluster(server) { server.createList('agent', 3); server.createList('node', 5); server.createList('job', 5); + server.createList('allocFile', 5); + server.create('allocFile', 'dir', { depth: 2 }); } function mediumCluster(server) { @@ -141,6 +143,8 @@ function allocationFileExplorer(server) { jobId: job.id, taskGroup: taskGroup.name, }); + server.createList('allocFile', 5); + server.create('allocFile', 'dir', { depth: 2 }); } // Behaviors diff --git a/ui/tests/acceptance/task-fs-test.js b/ui/tests/acceptance/task-fs-test.js index 1fea33b2f8ef..671b8027bec4 100644 --- a/ui/tests/acceptance/task-fs-test.js +++ b/ui/tests/acceptance/task-fs-test.js @@ -2,15 +2,33 @@ import { currentURL, visit } from '@ember/test-helpers'; import { Promise } from 'rsvp'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; +import moment from 'moment'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import Response from 'ember-cli-mirage/response'; -import moment from 'moment'; + +import { formatBytes } from 'nomad-ui/helpers/format-bytes'; +import { filesForPath } from 'nomad-ui/mirage/config'; import FS from 'nomad-ui/tests/pages/allocations/task/fs'; let allocation; let task; +let files; + +const fileSort = (prop, files) => { + let dir = []; + let file = []; + files.forEach(f => { + if (f.isDir) { + dir.push(f); + } else { + file.push(f); + } + }); + + return dir.sortBy(prop).concat(file.sortBy(prop)); +}; module('Acceptance | task fs', function(hooks) { setupApplicationTest(hooks); @@ -25,6 +43,24 @@ module('Acceptance | task fs', function(hooks) { task = server.schema.taskStates.where({ allocationId: allocation.id }).models[0]; task.name = 'task-name'; task.save(); + + // Reset files + files = []; + + // Nested files + files.push(server.create('allocFile', { isDir: true, name: 'directory' })); + files.push(server.create('allocFile', { isDir: true, name: 'another', parent: files[0] })); + files.push( + server.create('allocFile', 'file', { + name: 'something.txt', + fileType: 'txt', + parent: files[1], + }) + ); + + files.push(server.create('allocFile', { isDir: true, name: 'empty-directory' })); + files.push(server.create('allocFile', 'file', { fileType: 'txt' })); + files.push(server.create('allocFile', 'file', { fileType: 'txt' })); }); test('visiting /allocations/:allocation_id/:task_name/fs', async function(assert) { @@ -77,6 +113,8 @@ module('Acceptance | task fs', function(hooks) { test('navigating allocation filesystem', async function(assert) { await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); + const sortedFiles = fileSort('name', filesForPath(this.server.schema.allocFiles, '').models); + assert.ok(FS.fileViewer.isHidden); assert.equal(FS.directoryEntries.length, 4); @@ -88,18 +126,20 @@ module('Acceptance | task fs', function(hooks) { assert.equal(FS.breadcrumbs[0].text, 'task-name'); FS.directoryEntries[0].as(directory => { - assert.equal(directory.name, 'directory', 'directories should come first'); + const fileRecord = sortedFiles[0]; + assert.equal(directory.name, fileRecord.name, 'directories should come first'); assert.ok(directory.isDirectory); assert.equal(directory.size, '', 'directory sizes are hidden'); - assert.equal(directory.lastModified, 'a year ago'); + assert.equal(directory.lastModified, moment(fileRecord.modTime).fromNow()); assert.notOk(directory.path.includes('//'), 'paths shouldn’t have redundant separators'); }); FS.directoryEntries[2].as(file => { - assert.equal(file.name, '🤩.txt'); + const fileRecord = sortedFiles[2]; + assert.equal(file.name, fileRecord.name); assert.ok(file.isFile); - assert.equal(file.size, '1 KiB'); - assert.equal(file.lastModified, '2 days ago'); + assert.equal(file.size, formatBytes([fileRecord.size])); + assert.equal(file.lastModified, moment(fileRecord.modTime).fromNow()); }); await FS.directoryEntries[0].visit(); @@ -107,11 +147,11 @@ module('Acceptance | task fs', function(hooks) { assert.equal(FS.directoryEntries.length, 1); assert.equal(FS.breadcrumbs.length, 2); - assert.equal(FS.breadcrumbsText, 'task-name directory'); + assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name}`); assert.notOk(FS.breadcrumbs[0].isActive); - assert.equal(FS.breadcrumbs[1].text, 'directory'); + assert.equal(FS.breadcrumbs[1].text, files[0].name); assert.ok(FS.breadcrumbs[1].isActive); await FS.directoryEntries[0].visit(); @@ -123,8 +163,8 @@ module('Acceptance | task fs', function(hooks) { ); assert.equal(FS.breadcrumbs.length, 3); - assert.equal(FS.breadcrumbsText, 'task-name directory another'); - assert.equal(FS.breadcrumbs[2].text, 'another'); + assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name} ${files[1].name}`); + assert.equal(FS.breadcrumbs[2].text, files[1].name); assert.notOk( FS.breadcrumbs[0].path.includes('//'), @@ -136,7 +176,7 @@ module('Acceptance | task fs', function(hooks) { ); await FS.breadcrumbs[1].visit(); - assert.equal(FS.breadcrumbsText, 'task-name directory'); + assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name}`); assert.equal(FS.breadcrumbs.length, 2); }); @@ -266,12 +306,39 @@ module('Acceptance | task fs', function(hooks) { }); test('viewing a file', async function(assert) { + const node = server.db.nodes.find(allocation.nodeId); + + server.get(`http://${node.httpAddr}/v1/client/fs/readat/:allocation_id`, function() { + return new Response(500); + }); + await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); - await FS.directoryEntries[2].visit(); - assert.equal(FS.breadcrumbsText, 'task-name 🤩.txt'); + const sortedFiles = fileSort('name', filesForPath(this.server.schema.allocFiles, '').models); + const fileRecord = sortedFiles.find(f => !f.isDir); + const fileIndex = sortedFiles.indexOf(fileRecord); + + await FS.directoryEntries[fileIndex].visit(); + + assert.equal(FS.breadcrumbsText, `${task.name} ${fileRecord.name}`); assert.ok(FS.fileViewer.isPresent); + + const requests = this.server.pretender.handledRequests; + const secondAttempt = requests.pop(); + const firstAttempt = requests.pop(); + + assert.equal( + firstAttempt.url.split('?')[0], + `//${node.httpAddr}/v1/client/fs/readat/${allocation.id}`, + 'Client is hit first' + ); + assert.equal(firstAttempt.status, 500, 'Client request fails'); + assert.equal( + secondAttempt.url.split('?')[0], + `/v1/client/fs/readat/${allocation.id}`, + 'Server is hit second' + ); }); test('viewing an empty directory', async function(assert) { @@ -304,7 +371,7 @@ module('Acceptance | task fs', function(hooks) { return new Response(500, {}, 'no such file or directory'); }); - await FS.visitPath({ id: allocation.id, name: task.name, path: '/what-is-this' }); + await FS.visitPath({ id: allocation.id, name: task.name, path: files[0].name }); assert.equal(FS.error.title, 'Not Found', '500 is interpreted as 404'); await visit('/'); @@ -313,7 +380,7 @@ module('Acceptance | task fs', function(hooks) { return new Response(999); }); - await FS.visitPath({ id: allocation.id, name: task.name, path: '/what-is-this' }); + await FS.visitPath({ id: allocation.id, name: task.name, path: files[0].name }); assert.equal(FS.error.title, 'Error', 'other statuses are passed through'); }); }); diff --git a/ui/tests/integration/image-file-test.js b/ui/tests/integration/image-file-test.js new file mode 100644 index 000000000000..7557cc954e7a --- /dev/null +++ b/ui/tests/integration/image-file-test.js @@ -0,0 +1,106 @@ +import { find, settled } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import RSVP from 'rsvp'; +import { formatBytes } from 'nomad-ui/helpers/format-bytes'; + +module('Integration | Component | image file', function(hooks) { + setupRenderingTest(hooks); + + const commonTemplate = hbs` + {{image-file src=src alt=alt size=size}} + `; + + const commonProperties = { + src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + alt: 'This is the alt text', + size: 123456, + }; + + test('component displays the image', async function(assert) { + this.setProperties(commonProperties); + + await this.render(commonTemplate); + + assert.ok(find('img'), 'Image is in the DOM'); + assert.equal( + find('img').getAttribute('src'), + commonProperties.src, + `src is ${commonProperties.src}` + ); + }); + + test('the image is wrapped in an anchor that links directly to the image', async function(assert) { + this.setProperties(commonProperties); + + await this.render(commonTemplate); + + assert.ok(find('a'), 'Anchor'); + assert.ok(find('a > img'), 'Image in anchor'); + assert.equal( + find('a').getAttribute('href'), + commonProperties.src, + `href is ${commonProperties.src}` + ); + assert.equal(find('a').getAttribute('target'), '_blank', 'Anchor opens to a new tab'); + assert.equal( + find('a').getAttribute('rel'), + 'noopener noreferrer', + 'Anchor rel correctly bars openers and referrers' + ); + }); + + test('component updates image meta when the image loads', async function(assert) { + const { spy, wrapper, notifier } = notifyingSpy(); + + this.setProperties(commonProperties); + this.set('spy', wrapper); + + this.render(hbs` + {{image-file src=src alt=alt size=size updateImageMeta=spy}} + `); + + await notifier; + assert.ok(spy.calledOnce); + }); + + test('component shows the width, height, and size of the image', async function(assert) { + this.setProperties(commonProperties); + + await this.render(commonTemplate); + await settled(); + + const statsEl = find('[data-test-file-stats]'); + assert.ok( + /\d+px \u00d7 \d+px/.test(statsEl.textContent), + 'Width and height are formatted correctly' + ); + assert.ok( + statsEl.textContent.trim().endsWith(formatBytes([commonProperties.size]) + ')'), + 'Human-formatted size is included' + ); + }); +}); + +function notifyingSpy() { + // The notifier must resolve when the spy wrapper is called + let dispatch; + const notifier = new RSVP.Promise(resolve => { + dispatch = resolve; + }); + + const spy = sinon.spy(); + + // The spy wrapper must call the spy, passing all arguments through, and it must + // call dispatch in order to resolve the promise. + const wrapper = (...args) => { + spy(...args); + dispatch(); + }; + + // All three pieces are required to wire up a component, pause test execution, and + // write assertions. + return { spy, wrapper, notifier }; +} diff --git a/ui/tests/integration/streaming-file-test.js b/ui/tests/integration/streaming-file-test.js new file mode 100644 index 000000000000..f281ffed6dba --- /dev/null +++ b/ui/tests/integration/streaming-file-test.js @@ -0,0 +1,111 @@ +import { run } from '@ember/runloop'; +import { find, settled } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import Pretender from 'pretender'; +import { logEncode } from '../../mirage/data/logs'; +import fetch from 'nomad-ui/utils/fetch'; +import Log from 'nomad-ui/utils/classes/log'; + +const { assign } = Object; + +const stringifyValues = obj => + Object.keys(obj).reduce((newObj, key) => { + newObj[key] = obj[key].toString(); + return newObj; + }, {}); + +const makeLogger = (url, params) => + Log.create({ + url, + params, + plainText: true, + logFetch: url => fetch(url).then(res => res), + }); + +module('Integration | Component | streaming file', function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + this.server = new Pretender(function() { + this.get('/file/endpoint', () => [200, {}, 'Hello World']); + this.get('/file/stream', () => [200, {}, logEncode(['Hello World'], 0)]); + }); + }); + + hooks.afterEach(function() { + this.server.shutdown(); + }); + + const commonTemplate = hbs` + {{streaming-file logger=logger mode=mode isStreaming=isStreaming}} + `; + + test('when mode is `head`, the logger signals head', async function(assert) { + const url = '/file/endpoint'; + const params = { path: 'hello/world.txt', offset: 0, limit: 50000 }; + this.setProperties({ + logger: makeLogger(url, params), + mode: 'head', + isStreaming: false, + }); + + await this.render(commonTemplate); + await settled(); + + const request = this.server.handledRequests[0]; + assert.equal(this.server.handledRequests.length, 1, 'One request made'); + assert.equal(request.url.split('?')[0], url, `URL is ${url}`); + assert.deepEqual( + request.queryParams, + stringifyValues(assign({ origin: 'start' }, params)), + 'Query params are correct' + ); + assert.equal(find('[data-test-output]').textContent, 'Hello World'); + }); + + test('when mode is `tail`, the logger signals tail', async function(assert) { + const url = '/file/endpoint'; + const params = { path: 'hello/world.txt', limit: 50000 }; + this.setProperties({ + logger: makeLogger(url, params), + mode: 'tail', + isStreaming: false, + }); + + await this.render(commonTemplate); + await settled(); + + const request = this.server.handledRequests[0]; + assert.equal(this.server.handledRequests.length, 1, 'One request made'); + assert.equal(request.url.split('?')[0], url, `URL is ${url}`); + assert.deepEqual( + request.queryParams, + stringifyValues(assign({ origin: 'end', offset: 50000 }, params)), + 'Query params are correct' + ); + assert.equal(find('[data-test-output]').textContent, 'Hello World'); + }); + + test('when mode is `streaming` and `isStreaming` is true, streaming starts', async function(assert) { + const url = '/file/stream'; + const params = { path: 'hello/world.txt', limit: 50000 }; + this.setProperties({ + logger: makeLogger(url, params), + mode: 'streaming', + isStreaming: true, + }); + + assert.ok(true); + + run.later(run, run.cancelTimers, 500); + + await this.render(commonTemplate); + await settled(); + + const request = this.server.handledRequests[0]; + assert.equal(request.url.split('?')[0], url, `URL is ${url}`); + assert.equal(find('[data-test-output]').textContent, 'Hello World'); + }); +}); diff --git a/ui/tests/integration/task-file-test.js b/ui/tests/integration/task-file-test.js new file mode 100644 index 000000000000..05f944579dcb --- /dev/null +++ b/ui/tests/integration/task-file-test.js @@ -0,0 +1,228 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, settled } from '@ember/test-helpers'; +import { find } from 'ember-native-dom-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import Pretender from 'pretender'; +import { logEncode } from '../../mirage/data/logs'; + +const { assign } = Object; +const HOST = '1.1.1.1:1111'; + +module('Integration | Component | task file', function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + this.server = new Pretender(function() { + this.get('/v1/regions', () => [200, {}, JSON.stringify(['default'])]); + this.get('/v1/client/fs/stream/:alloc_id', () => [200, {}, logEncode(['Hello World'], 0)]); + this.get('/v1/client/fs/cat/:alloc_id', () => [200, {}, 'Hello World']); + this.get('/v1/client/fs/readat/:alloc_id', () => [200, {}, 'Hello World']); + }); + }); + + hooks.afterEach(function() { + this.server.shutdown(); + }); + + const commonTemplate = hbs` + {{task-file allocation=allocation task=task file=file stat=stat}} + `; + + const fileStat = (type, size = 0) => ({ + stat: { + Size: size, + ContentType: type, + }, + }); + const makeProps = (props = {}) => + assign( + {}, + { + allocation: { + id: 'alloc-1', + node: { + httpAddr: HOST, + }, + }, + task: { + name: 'task-name', + }, + file: 'path/to/file', + stat: { + Size: 12345, + ContentType: 'text/plain', + }, + }, + props + ); + + test('When a file is text-based, the file mode is streaming', async function(assert) { + const props = makeProps(fileStat('text/plain', 500)); + this.setProperties(props); + + await render(commonTemplate); + + assert.ok( + find('[data-test-file-box] [data-test-log-cli]'), + 'The streaming file component was rendered' + ); + assert.notOk( + find('[data-test-file-box] [data-test-image-file]'), + 'The image file component was not rendered' + ); + }); + + test('When a file is an image, the file mode is image', async function(assert) { + const props = makeProps(fileStat('image/png', 1234)); + this.setProperties(props); + + await render(commonTemplate); + + assert.ok( + find('[data-test-file-box] [data-test-image-file]'), + 'The image file component was rendered' + ); + assert.notOk( + find('[data-test-file-box] [data-test-log-cli]'), + 'The streaming file component was not rendered' + ); + }); + + test('When the file is neither text-based or an image, the unsupported file type empty state is shown', async function(assert) { + const props = makeProps(fileStat('wat/ohno', 1234)); + this.setProperties(props); + + await render(commonTemplate); + + assert.notOk( + find('[data-test-file-box] [data-test-image-file]'), + 'The image file component was not rendered' + ); + assert.notOk( + find('[data-test-file-box] [data-test-log-cli]'), + 'The streaming file component was not rendered' + ); + assert.ok(find('[data-test-unsupported-type]'), 'Unsupported file type message is shown'); + }); + + test('The unsupported file type empty state includes a link to the raw file', async function(assert) { + const props = makeProps(fileStat('wat/ohno', 1234)); + this.setProperties(props); + + await render(commonTemplate); + + assert.ok( + find('[data-test-unsupported-type] [data-test-log-action="raw"]'), + 'Unsupported file type message includes a link to the raw file' + ); + + assert.notOk( + find('[data-test-header] [data-test-log-action="raw"]'), + 'Raw link is no longer in the header' + ); + }); + + test('The view raw button goes to the correct API url', async function(assert) { + const props = makeProps(fileStat('image/png', 1234)); + this.setProperties(props); + + await render(commonTemplate); + + const rawLink = find('[data-test-log-action="raw"]'); + assert.equal( + rawLink.getAttribute('href'), + `/v1/client/fs/cat/${props.allocation.id}?path=${encodeURIComponent( + `${props.task.name}/${props.file}` + )}`, + 'Raw link href is correct' + ); + + assert.equal(rawLink.getAttribute('target'), '_blank', 'Raw link opens in a new tab'); + assert.equal( + rawLink.getAttribute('rel'), + 'noopener noreferrer', + 'Raw link rel correctly bars openers and referrers' + ); + }); + + test('The head and tail buttons are not shown when the file is small', async function(assert) { + const props = makeProps(fileStat('application/json', 5000)); + this.setProperties(props); + + await render(commonTemplate); + + assert.notOk(find('[data-test-log-action="head"]'), 'No head button'); + assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button'); + + this.set('stat.Size', 100000); + + await settled(); + + assert.ok(find('[data-test-log-action="head"]'), 'Head button is shown'); + assert.ok(find('[data-test-log-action="tail"]'), 'Tail button is shown'); + }); + + test('The head and tail buttons are not shown for an image file', async function(assert) { + const props = makeProps(fileStat('image/svg', 5000)); + this.setProperties(props); + + await render(commonTemplate); + + assert.notOk(find('[data-test-log-action="head"]'), 'No head button'); + assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button'); + + this.set('stat.Size', 100000); + + await settled(); + + assert.notOk(find('[data-test-log-action="head"]'), 'Still no head button'); + assert.notOk(find('[data-test-log-action="tail"]'), 'Still no tail button'); + }); + + test('Yielded content goes in the top-left header area', async function(assert) { + const props = makeProps(fileStat('image/svg', 5000)); + this.setProperties(props); + + await render(hbs` + {{#task-file allocation=allocation task=task file=file stat=stat}} +
Yielded content
+ {{/task-file}} + `); + + assert.ok( + find('[data-test-header] [data-test-yield-spy]'), + 'Yielded content shows up in the header' + ); + }); + + test('The body is full-bleed and dark when the file is streaming', async function(assert) { + const props = makeProps(fileStat('application/json', 5000)); + this.setProperties(props); + + await render(commonTemplate); + + const classes = Array.from(find('[data-test-file-box]').classList); + assert.ok(classes.includes('is-dark'), 'Body is dark'); + assert.ok(classes.includes('is-full-bleed'), 'Body is full-bleed'); + }); + + test('The body has padding and a light background when the file is not streaming', async function(assert) { + const props = makeProps(fileStat('image/jpeg', 5000)); + this.setProperties(props); + + await render(commonTemplate); + + let classes = Array.from(find('[data-test-file-box]').classList); + assert.notOk(classes.includes('is-dark'), 'Body is not dark'); + assert.notOk(classes.includes('is-full-bleed'), 'Body is not full-bleed'); + + this.set('stat.ContentType', 'something/unknown'); + + await settled(); + + classes = Array.from(find('[data-test-file-box]').classList); + assert.notOk(classes.includes('is-dark'), 'Body is still not dark'); + assert.notOk(classes.includes('is-full-bleed'), 'Body is still not full-bleed'); + }); +}); diff --git a/ui/tests/integration/task-log-test.js b/ui/tests/integration/task-log-test.js index 4f2474cae87f..67f66786eb73 100644 --- a/ui/tests/integration/task-log-test.js +++ b/ui/tests/integration/task-log-test.js @@ -21,24 +21,23 @@ const commonProps = { serverTimeout: allowedConnectionTime, }; -const logHead = ['HEAD']; -const logTail = ['TAIL']; +const logHead = [logEncode(['HEAD'], 0)]; +const logTail = [logEncode(['TAIL'], 0)]; const streamFrames = ['one\n', 'two\n', 'three\n', 'four\n', 'five\n']; let streamPointer = 0; +let logMode = null; module('Integration | Component | task log', function(hooks) { setupRenderingTest(hooks); hooks.beforeEach(function() { const handler = ({ queryParams }) => { - const { origin, offset, plain, follow } = queryParams; - let frames; let data; - if (origin === 'start' && offset === '0' && plain && !follow) { + if (logMode === 'head') { frames = logHead; - } else if (origin === 'end' && plain && !follow) { + } else if (logMode === 'tail') { frames = logTail; } else { frames = streamFrames; @@ -64,6 +63,7 @@ module('Integration | Component | task log', function(hooks) { hooks.afterEach(function() { this.server.shutdown(); streamPointer = 0; + logMode = null; }); test('Basic appearance', async function(assert) { @@ -107,6 +107,7 @@ module('Integration | Component | task log', function(hooks) { }); test('Clicking Head loads the log head', async function(assert) { + logMode = 'head'; run.later(run, run.cancelTimers, commonProps.interval); this.setProperties(commonProps); @@ -117,7 +118,7 @@ module('Integration | Component | task log', function(hooks) { await settled(); assert.ok( this.server.handledRequests.find( - ({ queryParams: qp }) => qp.origin === 'start' && qp.plain === 'true' && qp.offset === '0' + ({ queryParams: qp }) => qp.origin === 'start' && qp.offset === '0' ), 'Log head request was made' ); @@ -125,6 +126,7 @@ module('Integration | Component | task log', function(hooks) { }); test('Clicking Tail loads the log tail', async function(assert) { + logMode = 'tail'; run.later(run, run.cancelTimers, commonProps.interval); this.setProperties(commonProps); @@ -134,9 +136,7 @@ module('Integration | Component | task log', function(hooks) { await settled(); assert.ok( - this.server.handledRequests.find( - ({ queryParams: qp }) => qp.origin === 'end' && qp.plain === 'true' - ), + this.server.handledRequests.find(({ queryParams: qp }) => qp.origin === 'end'), 'Log tail request was made' ); assert.equal(find('[data-test-log-cli]').textContent, logTail[0], 'Tail of the log is shown'); diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js index 908fbf33b98d..9faa709b05c6 100644 --- a/ui/tests/unit/adapters/node-test.js +++ b/ui/tests/unit/adapters/node-test.js @@ -20,7 +20,6 @@ module('Unit | Adapter | Node', function(hooks) { this.server.create('allocation', { id: 'node-1-2', nodeId: 'node-1' }); this.server.create('allocation', { id: 'node-2-1', nodeId: 'node-2' }); this.server.create('allocation', { id: 'node-2-2', nodeId: 'node-2' }); - this.server.logging = true; }); hooks.afterEach(function() { diff --git a/ui/tests/unit/utils/log-test.js b/ui/tests/unit/utils/log-test.js index b3be1e18b9f6..e69b8c105806 100644 --- a/ui/tests/unit/utils/log-test.js +++ b/ui/tests/unit/utils/log-test.js @@ -76,7 +76,7 @@ module('Unit | Util | Log', function(hooks) { test('gotoHead builds the correct URL', async function(assert) { const mocks = makeMocks(''); - const expectedUrl = `${mocks.url}?a=param&another=one&offset=0&origin=start&plain=true`; + const expectedUrl = `${mocks.url}?a=param&another=one&offset=0&origin=start`; const log = Log.create(mocks); run(() => { @@ -89,10 +89,11 @@ module('Unit | Util | Log', function(hooks) { const longLog = Array(50001) .fill('a') .join(''); + const encodedLongLog = `{"Offset":0,"Data":"${window.btoa(longLog)}"}`; const truncationMessage = '\n\n---------- TRUNCATED: Click "tail" to view the bottom of the log ----------'; - const mocks = makeMocks(longLog); + const mocks = makeMocks(encodedLongLog); const log = Log.create(mocks); run(() => { @@ -100,7 +101,13 @@ module('Unit | Util | Log', function(hooks) { }); await settled(); - assert.ok(log.get('output').toString().endsWith(truncationMessage), 'Truncation message is shown'); + assert.ok( + log + .get('output') + .toString() + .endsWith(truncationMessage), + 'Truncation message is shown' + ); assert.equal( log.get('output').toString().length, 50000 + truncationMessage.length, @@ -110,7 +117,7 @@ module('Unit | Util | Log', function(hooks) { test('gotoTail builds the correct URL', async function(assert) { const mocks = makeMocks(''); - const expectedUrl = `${mocks.url}?a=param&another=one&offset=50000&origin=end&plain=true`; + const expectedUrl = `${mocks.url}?a=param&another=one&offset=50000&origin=end`; const log = Log.create(mocks); run(() => { diff --git a/ui/tests/unit/utils/stream-frames-test.js b/ui/tests/unit/utils/stream-frames-test.js new file mode 100644 index 000000000000..0a185eb5bbc5 --- /dev/null +++ b/ui/tests/unit/utils/stream-frames-test.js @@ -0,0 +1,41 @@ +import { module, test } from 'qunit'; +import { decode } from 'nomad-ui/utils/stream-frames'; + +module('Unit | Util | stream-frames', function() { + const { btoa } = window; + const decodeTestCases = [ + { + name: 'Single frame', + in: `{"Offset":100,"Data":"${btoa('Hello World')}"}`, + out: { + offset: 100, + message: 'Hello World', + }, + }, + { + name: 'Multiple frames', + // prettier-ignore + in: `{"Offset":1,"Data":"${btoa('One fish,')}"}{"Offset":2,"Data":"${btoa( ' Two fish.')}"}{"Offset":3,"Data":"${btoa(' Red fish, ')}"}{"Offset":4,"Data":"${btoa('Blue fish.')}"}`, + out: { + offset: 4, + message: 'One fish, Two fish. Red fish, Blue fish.', + }, + }, + { + name: 'Empty frames', + in: '{}{}{}', + out: {}, + }, + { + name: 'Empty string', + in: '', + out: {}, + }, + ]; + + decodeTestCases.forEach(testCase => { + test(`decode: ${testCase.name}`, function(assert) { + assert.deepEqual(decode(testCase.in), testCase.out); + }); + }); +});