From a21ae527832288f44d8b752c34000627a99e0e26 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 26 Jun 2019 14:51:23 -0700 Subject: [PATCH 01/38] Move common stream frame decoding to a util --- ui/app/utils/stream-frames.js | 26 ++++++++++++++ ui/tests/unit/utils/stream-frames-test.js | 41 +++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 ui/app/utils/stream-frames.js create mode 100644 ui/tests/unit/utils/stream-frames-test.js 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/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); + }); + }); +}); From 07f1c89cc58917282877f8cf7b557b7037ce7529 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 26 Jun 2019 14:52:41 -0700 Subject: [PATCH 02/38] Use the stream decode util and never opt to use the plain query param --- ui/app/utils/classes/log.js | 9 +++++---- ui/app/utils/classes/poll-logger.js | 18 ++++++------------ ui/app/utils/classes/stream-logger.js | 12 +++++------- ui/tests/integration/task-log-test.js | 20 ++++++++++---------- ui/tests/unit/utils/log-test.js | 15 +++++++++++---- 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/ui/app/utils/classes/log.js b/ui/app/utils/classes/log.js index 82f96ebc3a99..f3379ffdf6ae 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; @@ -73,7 +74,6 @@ const Log = EmberObject.extend(Evented, { const logFetch = this.logFetch; const queryParams = queryString.stringify( assign(this.params, { - plain: true, origin: 'start', offset: 0, }) @@ -81,7 +81,8 @@ const Log = EmberObject.extend(Evented, { 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 = decode(response).message; if (text && text.length > MAX_OUTPUT_LENGTH) { text = text.substr(0, MAX_OUTPUT_LENGTH); @@ -95,7 +96,6 @@ const Log = EmberObject.extend(Evented, { const logFetch = this.logFetch; const queryParams = queryString.stringify( assign(this.params, { - plain: true, origin: 'end', offset: MAX_OUTPUT_LENGTH, }) @@ -103,7 +103,8 @@ const Log = EmberObject.extend(Evented, { 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 = 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/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/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(() => { From 871fe105a2f8d87a81e11da0d29a0603261a9167 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 26 Jun 2019 14:53:01 -0700 Subject: [PATCH 03/38] Tweak log window math --- ui/app/components/task-log.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/app/components/task-log.js b/ui/app/components/task-log.js index c40e4a28409b..cd70cd6bb17b 100644 --- a/ui/app/components/task-log.js +++ b/ui/app/components/task-log.js @@ -42,8 +42,9 @@ export default Component.extend(WindowResizable, { 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 + 30; // Account for padding and margin on either side of the CLI const cliWindow = this.$('.cli-window'); - cliWindow.height(window.innerHeight - cliWindow.offset().top - 25); + cliWindow.height(window.innerHeight - cliWindow.offset().top - margins); }, mode: 'stdout', From a993a305cdb1c3318a38cb26a7919f5a0405b689 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 27 Jun 2019 09:21:00 -0700 Subject: [PATCH 04/38] New task-file component Based on the task-log component. Commonalities will be refactored. --- ui/app/components/task-file.js | 137 ++++++++++++++++++++++ ui/app/templates/components/task-file.hbs | 24 ++++ 2 files changed, 161 insertions(+) create mode 100644 ui/app/components/task-file.js create mode 100644 ui/app/templates/components/task-file.hbs diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js new file mode 100644 index 000000000000..98557be2251e --- /dev/null +++ b/ui/app/components/task-file.js @@ -0,0 +1,137 @@ +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, { + token: service(), + + classNames: ['boxed-section', 'task-log'], + + allocation: null, + task: null, + file: null, + + // 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, + + 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 margins = 30 + 30; // Account for padding and margin on either side of the CLI + const cliWindow = this.$('.cli-window'); + cliWindow.height(window.innerHeight - cliWindow.offset().top - margins); + }, + + fileUrl: computed('task.name', 'allocation.id', 'file', function() { + return `/v1/client/fs/cat/${this.allocation.id}?path=${this.task.name}/${this.file}`; + }), + + logUrl: computed('allocation.id', 'allocation.node.httpAddr', 'useServer', function() { + const address = this.get('allocation.node.httpAddr'); + const allocation = this.get('allocation.id'); + + const url = `/v1/client/fs/logs/${allocation}`; + return this.useServer ? url : `//${address}${url}`; + }), + + logParams: computed('task', 'mode', function() { + return { + task: this.task, + type: this.mode, + }; + }), + + logger: logger('logUrl', 'logParams', function logFetch() { + // If the log 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; + return url => + RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then( + response => response, + error => { + if (this.useServer) { + 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(); + } + }, + failoverToServer() { + this.set('useServer', true); + }, + }, +}); diff --git a/ui/app/templates/components/task-file.hbs b/ui/app/templates/components/task-file.hbs new file mode 100644 index 000000000000..fc43956e05df --- /dev/null +++ b/ui/app/templates/components/task-file.hbs @@ -0,0 +1,24 @@ +{{#if noConnection}} +
+

Cannot fetch file

+

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

+
+{{/if}} +
+ + View Raw File + {{#if isLarge}} + + + {{/if}} + {{#if isStreaming}} + + {{/if}} + +
+
+ {{!-- switch file component here --}} +
{{logger.output}}
+
From caf48ee3e6e599c1f2b2fe0a879d4a58e1abfd1b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 27 Jun 2019 12:10:34 -0700 Subject: [PATCH 05/38] Extract a streaming-file component from the task-log component The new streaming-file component takes an arbitrary logger component along with some mode flags and handles things like polling, DOM updates, and scroll position. --- ui/app/components/streaming-file.js | 95 +++++++++++++++++++ ui/app/components/task-log.js | 73 +++----------- .../templates/components/streaming-file.hbs | 1 + ui/app/templates/components/task-log.hbs | 6 +- 4 files changed, 111 insertions(+), 64 deletions(-) create mode 100644 ui/app/components/streaming-file.js create mode 100644 ui/app/templates/components/streaming-file.hbs diff --git a/ui/app/components/streaming-file.js b/ui/app/components/streaming-file.js new file mode 100644 index 000000000000..bbbc16126d1d --- /dev/null +++ b/ui/app/components/streaming-file.js @@ -0,0 +1,95 @@ +import Component from '@ember/component'; +import { run } from '@ember/runloop'; +import { task } from 'ember-concurrency'; + +export default Component.extend({ + 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*() { + // Follow the log if the scroll position is near the bottom of the cli window + this.logger.on('tick', () => { + run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition()); + }); + + // Force the scroll position to the bottom of the window when starting streaming + this.logger.one('tick', () => { + run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition(true)); + }); + + yield this.logger.startStreaming(); + this.logger.off('tick'); + }), + + willDestroy() { + this.logger.stop(); + }, +}); diff --git a/ui/app/components/task-log.js b/ui/app/components/task-log.js index cd70cd6bb17b..17ed8f5fcaec 100644 --- a/ui/app/components/task-log.js +++ b/ui/app/components/task-log.js @@ -1,9 +1,7 @@ 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'; @@ -25,27 +23,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 margins = 30 + 30; // Account for padding and margin on either side of the CLI - const cliWindow = this.$('.cli-window'); - cliWindow.height(window.innerHeight - cliWindow.offset().top - margins); - }, + isStreaming: true, + streamMode: 'streaming', mode: 'stdout', @@ -76,56 +55,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/templates/components/streaming-file.hbs b/ui/app/templates/components/streaming-file.hbs new file mode 100644 index 000000000000..690dd591528e --- /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-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}}
From 45c8f37157cdef3a7a5420a3e2c1dc6927062bf5 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 2 Jul 2019 07:52:47 -0700 Subject: [PATCH 06/38] image-file component for showing an image and image metadata --- ui/app/components/image-file.js | 28 ++++++++++++++++++++++ ui/app/templates/components/image-file.hbs | 12 ++++++++++ 2 files changed, 40 insertions(+) create mode 100644 ui/app/components/image-file.js create mode 100644 ui/app/templates/components/image-file.hbs diff --git a/ui/app/components/image-file.js b/ui/app/components/image-file.js new file mode 100644 index 000000000000..2c84ff893870 --- /dev/null +++ b/ui/app/components/image-file.js @@ -0,0 +1,28 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default Component.extend({ + tagName: 'figure', + classNames: 'image-file', + + 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/templates/components/image-file.hbs b/ui/app/templates/components/image-file.hbs new file mode 100644 index 000000000000..dc16220548f1 --- /dev/null +++ b/ui/app/templates/components/image-file.hbs @@ -0,0 +1,12 @@ + + {{or + +
+ + {{fileName}} + {{#if (and width height)}} + ({{width}}px × {{height}}{{#if size}}px, {{format-bytes size}}{{/if}}) + {{/if}} + + {{src}} +
\ No newline at end of file From 8966bc0830b3461583b0d3b014705547a5f45a9e Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 2 Jul 2019 12:51:33 -0700 Subject: [PATCH 07/38] Styles for the image-file component --- ui/app/styles/components.scss | 1 + ui/app/styles/components/image-file.scss | 26 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 ui/app/styles/components/image-file.scss 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/image-file.scss b/ui/app/styles/components/image-file.scss new file mode 100644 index 000000000000..02b99efeb206 --- /dev/null +++ b/ui/app/styles/components/image-file.scss @@ -0,0 +1,26 @@ +.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; + } + + .image-file-caption-secondary { + display: block; + color: $grey-light; + font-style: italic; + } +} From 0698e119c82a617b89434949cd41d987c48171d2 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 2 Jul 2019 12:59:15 -0700 Subject: [PATCH 08/38] Address WindowResizable refactor --- ui/app/components/streaming-file.js | 3 ++- ui/app/components/task-file.js | 5 +---- ui/app/components/task-log.js | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/ui/app/components/streaming-file.js b/ui/app/components/streaming-file.js index bbbc16126d1d..7b498ccf1f8d 100644 --- a/ui/app/components/streaming-file.js +++ b/ui/app/components/streaming-file.js @@ -1,8 +1,9 @@ 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({ +export default Component.extend(WindowResizable, { tagName: 'pre', classNames: ['cli-window'], 'data-test-log-cli': true, diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index 98557be2251e..a959438dd5b2 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.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'], diff --git a/ui/app/components/task-log.js b/ui/app/components/task-log.js index 17ed8f5fcaec..b5cda3eed3ac 100644 --- a/ui/app/components/task-log.js +++ b/ui/app/components/task-log.js @@ -3,10 +3,9 @@ import Component from '@ember/component'; import { computed } from '@ember/object'; import RSVP from 'rsvp'; 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'], From 6ec7fafaa5538f9b0bbe41cfb380520a9048928a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 2 Jul 2019 12:59:57 -0700 Subject: [PATCH 09/38] Markup for the image-file component --- ui/app/templates/components/image-file.hbs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ui/app/templates/components/image-file.hbs b/ui/app/templates/components/image-file.hbs index dc16220548f1..bc11e407cf5c 100644 --- a/ui/app/templates/components/image-file.hbs +++ b/ui/app/templates/components/image-file.hbs @@ -1,12 +1,11 @@ - + {{or -
- - {{fileName}} +
+ + {{fileName}} {{#if (and width height)}} ({{width}}px × {{height}}{{#if size}}px, {{format-bytes size}}{{/if}}) {{/if}} - {{src}}
\ No newline at end of file From 8403cbaa53fb6ee5e4fa17cbc92ff99845b626b1 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 2 Jul 2019 13:01:07 -0700 Subject: [PATCH 10/38] Refactored and image support of the task-file component --- ui/app/components/task-file.js | 140 +++++++++++----------- ui/app/templates/components/task-file.hbs | 21 ++-- 2 files changed, 82 insertions(+), 79 deletions(-) diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index a959438dd5b2..e11a70e8f23d 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -13,6 +13,7 @@ export default Component.extend({ allocation: null, task: null, file: null, + stat: null, // { Name, IsDir, Size, FileMode, ModTime, ContentType } // When true, request logs from the server agent useServer: false, @@ -23,49 +24,78 @@ export default Component.extend({ clientTimeout: 1000, serverTimeout: 5000, - didReceiveAttrs() { - if (this.allocation && this.task) { - // this.send('toggleStream'); + mode: 'head', + + fileComponent: computed('stat', function() { + // TODO: Switch to this.stat.ContentType + // TODO: Determine binary/unsupported non-text files to set to "cannot view" component + const matches = this.stat.Name.match(/^.+?\.(.+)$/); + const ext = matches ? matches[1] : ''; + + switch (ext) { + case 'jpg': + case 'jpeg': + case 'gif': + case 'png': + return 'image'; + default: + return 'stream'; } - }, + }), - didInsertElement() { - this.fillAvailableHeight(); - }, + isLarge: computed('stat', function() { + return this.stat.Size > 50000; + }), - windowResizeHandler() { - run.once(this, this.fillAvailableHeight); - }, + isStreamable: computed('stat', function() { + return false; + return this.stat.ContentType.startsWith('text/'); + }), - 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 + 30; // Account for padding and margin on either side of the CLI - const cliWindow = this.$('.cli-window'); - cliWindow.height(window.innerHeight - cliWindow.offset().top - margins); - }, + isStreaming: false, - fileUrl: computed('task.name', 'allocation.id', 'file', function() { + catUrl: computed('allocation.id', 'task.name', 'file', function() { return `/v1/client/fs/cat/${this.allocation.id}?path=${this.task.name}/${this.file}`; }), - logUrl: computed('allocation.id', 'allocation.node.httpAddr', 'useServer', function() { - const address = this.get('allocation.node.httpAddr'); - const allocation = this.get('allocation.id'); + fetchMode: computed('isLarge', 'mode', function() { + if (!this.isLarge) { + return 'cat'; + } else if (this.mode === 'head') { + return 'readat'; + } - const url = `/v1/client/fs/logs/${allocation}`; - return this.useServer ? url : `//${address}${url}`; + return 'stream'; }), - logParams: computed('task', 'mode', function() { - return { - task: this.task, - type: this.mode, - }; + 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() { + const path = `${this.task.name}/${this.file}`; + + switch (this.mode) { + case 'head': + return { path, offset: 0, limit: 50000 }; + case 'tail': + case 'stream': + return { path, offset: 50000, origin: 'end' }; + default: + return { path }; + } }), - logger: logger('logUrl', 'logParams', function logFetch() { - // If the log request can't settle in one second, the client + logger: logger('fileUrl', 'fileParams', function logFetch() { + // 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; return url => @@ -83,49 +113,17 @@ export default Component.extend({ ); }), - 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.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/templates/components/task-file.hbs b/ui/app/templates/components/task-file.hbs index fc43956e05df..1846e34ce735 100644 --- a/ui/app/templates/components/task-file.hbs +++ b/ui/app/templates/components/task-file.hbs @@ -6,19 +6,24 @@ {{/if}}
- View Raw File - {{#if isLarge}} - - + View Raw File + {{#if (and isLarge isStreamable)}} + + {{/if}} - {{#if isStreaming}} + {{#if isStreamable}} {{/if}}
-
- {{!-- switch file component here --}} -
{{logger.output}}
+
+ {{#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}} +

No component

+ {{/if}}
From a60960690156b303405bcba1f535bd2e23a6ac34 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 2 Jul 2019 15:36:38 -0700 Subject: [PATCH 11/38] Add a plainText mode --- ui/app/utils/classes/log.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/app/utils/classes/log.js b/ui/app/utils/classes/log.js index f3379ffdf6ae..9c8ed228ad82 100644 --- a/ui/app/utils/classes/log.js +++ b/ui/app/utils/classes/log.js @@ -21,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'); }, @@ -82,7 +83,7 @@ const Log = EmberObject.extend(Evented, { this.stop(); const response = yield logFetch(url).then(res => res.text(), fetchFailure(url)); - let text = decode(response).message; + let text = this.plainText ? response : decode(response).message; if (text && text.length > MAX_OUTPUT_LENGTH) { text = text.substr(0, MAX_OUTPUT_LENGTH); @@ -104,7 +105,7 @@ const Log = EmberObject.extend(Evented, { this.stop(); const response = yield logFetch(url).then(res => res.text(), fetchFailure(url)); - let text = decode(response).message; + let text = this.plainText ? response : decode(response).message; this.set('tail', text); this.set('logPointer', 'tail'); From c335aced9b2582d5fed101d389a20eb233ccc4d4 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 2 Jul 2019 15:37:17 -0700 Subject: [PATCH 12/38] Custom Log instance to deal with API quirks --- ui/app/components/task-file.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index e11a70e8f23d..dfc40c549c90 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -2,7 +2,7 @@ import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { computed } from '@ember/object'; import RSVP from 'rsvp'; -import { logger } from 'nomad-ui/utils/classes/log'; +import Log from 'nomad-ui/utils/classes/log'; import timeout from 'nomad-ui/utils/timeout'; export default Component.extend({ @@ -48,7 +48,7 @@ export default Component.extend({ }), isStreamable: computed('stat', function() { - return false; + return true; return this.stat.ContentType.startsWith('text/'); }), @@ -61,7 +61,7 @@ export default Component.extend({ fetchMode: computed('isLarge', 'mode', function() { if (!this.isLarge) { return 'cat'; - } else if (this.mode === 'head') { + } else if (this.mode === 'head' || this.mode === 'tail') { return 'readat'; } @@ -87,18 +87,22 @@ export default Component.extend({ case 'head': return { path, offset: 0, limit: 50000 }; case 'tail': - case 'stream': + return { path, offset: this.stat.Size - 50000, limit: 50000 }; + case 'streaming': return { path, offset: 50000, origin: 'end' }; default: return { path }; } }), - logger: logger('fileUrl', 'fileParams', function logFetch() { + 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; - return url => + const logFetch = url => RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then( response => response, error => { @@ -111,10 +115,18 @@ export default Component.extend({ throw error; } ); + + return Log.create({ + logFetch, + plainText, + params: this.fileParams, + url: this.fileUrl, + }); }), actions: { toggleStream() { + this.set('mode', 'streaming'); this.toggleProperty('isStreaming'); }, gotoHead() { From ccbe31f0d6f32f81551d0ac436bf08facaf41974 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Sat, 20 Jul 2019 11:46:54 -0700 Subject: [PATCH 13/38] Always escape < and > to avoid inadvertently rendering html --- ui/app/utils/classes/log.js | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/app/utils/classes/log.js b/ui/app/utils/classes/log.js index 9c8ed228ad82..ab865737d945 100644 --- a/ui/app/utils/classes/log.js +++ b/ui/app/utils/classes/log.js @@ -42,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); }), From fea37310688c215e9f6c9ca7cebfe361ec8e0e4d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Sat, 20 Jul 2019 12:00:20 -0700 Subject: [PATCH 14/38] Integrate the task-file component with the fs explorer pages --- ui/app/components/streaming-file.js | 1 + ui/app/components/task-file.js | 34 +++++------ .../allocations/allocation/task/fs.js | 1 + .../routes/allocations/allocation/task/fs.js | 5 +- .../allocations/allocation/task/fs.hbs | 60 ++++++++++++------- ui/app/templates/components/task-file.hbs | 1 + 6 files changed, 57 insertions(+), 45 deletions(-) diff --git a/ui/app/components/streaming-file.js b/ui/app/components/streaming-file.js index 7b498ccf1f8d..8e7167b31edd 100644 --- a/ui/app/components/streaming-file.js +++ b/ui/app/components/streaming-file.js @@ -1,4 +1,5 @@ import Component from '@ember/component'; +import { computed } from '@ember/object'; import { run } from '@ember/runloop'; import { task } from 'ember-concurrency'; import WindowResizable from 'nomad-ui/mixins/window-resizable'; diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index dfc40c549c90..a735515982a6 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -1,6 +1,7 @@ import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { computed } from '@ember/object'; +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'; @@ -27,19 +28,14 @@ export default Component.extend({ mode: 'head', fileComponent: computed('stat', function() { - // TODO: Switch to this.stat.ContentType - // TODO: Determine binary/unsupported non-text files to set to "cannot view" component - const matches = this.stat.Name.match(/^.+?\.(.+)$/); - const ext = matches ? matches[1] : ''; - - switch (ext) { - case 'jpg': - case 'jpeg': - case 'gif': - case 'png': - return 'image'; - default: - return 'stream'; + 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'; } }), @@ -47,11 +43,7 @@ export default Component.extend({ return this.stat.Size > 50000; }), - isStreamable: computed('stat', function() { - return true; - return this.stat.ContentType.startsWith('text/'); - }), - + isStreamable: equal('fileComponent', 'stream'), isStreaming: false, catUrl: computed('allocation.id', 'task.name', 'file', function() { @@ -59,13 +51,15 @@ export default Component.extend({ }), 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'; } - - return 'stream'; }), fileUrl: computed( diff --git a/ui/app/controllers/allocations/allocation/task/fs.js b/ui/app/controllers/allocations/allocation/task/fs.js index b8084fd63423..9ad6df2c72db 100644 --- a/ui/app/controllers/allocations/allocation/task/fs.js +++ b/ui/app/controllers/allocations/allocation/task/fs.js @@ -16,6 +16,7 @@ export default Controller.extend({ task: null, directoryEntries: null, isFile: null, + stat: null, directories: filterBy('directoryEntries', 'IsDir'), files: filterBy('directoryEntries', 'IsDir', false), diff --git a/ui/app/routes/allocations/allocation/task/fs.js b/ui/app/routes/allocations/allocation/task/fs.js index f592e77a0df1..12ee70fc4da8 100644 --- a/ui/app/routes/allocations/allocation/task/fs.js +++ b/ui/app/routes/allocations/allocation/task/fs.js @@ -24,14 +24,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/templates/allocations/allocation/task/fs.hbs b/ui/app/templates/allocations/allocation/task/fs.hbs index 9854d73417d8..6dda0a01d3d0 100644 --- a/ui/app/templates/allocations/allocation/task/fs.hbs +++ b/ui/app/templates/allocations/allocation/task/fs.hbs @@ -1,31 +1,45 @@ {{task-subnav task=task}} -
+
{{#if task.isRunning}} -
-
-
- - {{#if isFile}} -
-
placeholder file viewer
+ {{#each breadcrumbs as |breadcrumb|}} +
  • + {{#link-to "allocations.allocation.task.fs" task.allocation task breadcrumb.path activeClass="is-active"}} + {{breadcrumb.name}} + {{/link-to}} +
  • + {{/each}} + + + {{/task-file}} + {{else}} +
    +
    +
    - {{else}} {{#list-table source=sortedDirectoryEntries sortProperty=sortProperty diff --git a/ui/app/templates/components/task-file.hbs b/ui/app/templates/components/task-file.hbs index 1846e34ce735..be8621bfc535 100644 --- a/ui/app/templates/components/task-file.hbs +++ b/ui/app/templates/components/task-file.hbs @@ -5,6 +5,7 @@
    {{/if}}
    + {{yield}} View Raw File {{#if (and isLarge isStreamable)}} From bd9d73ba5d529efa3c9f294f9769a1d0c6b2cf4f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Sat, 20 Jul 2019 21:01:39 -0700 Subject: [PATCH 15/38] Fix a bug where tail calls for files weren't getting the correct params --- ui/app/utils/classes/log.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/ui/app/utils/classes/log.js b/ui/app/utils/classes/log.js index ab865737d945..597dfdc880ae 100644 --- a/ui/app/utils/classes/log.js +++ b/ui/app/utils/classes/log.js @@ -75,10 +75,13 @@ const Log = EmberObject.extend(Evented, { gotoHead: task(function*() { const logFetch = this.logFetch; const queryParams = queryString.stringify( - assign(this.params, { - origin: 'start', - offset: 0, - }) + assign( + { + origin: 'start', + offset: 0, + }, + this.params + ) ); const url = `${this.url}?${queryParams}`; @@ -97,10 +100,13 @@ const Log = EmberObject.extend(Evented, { gotoTail: task(function*() { const logFetch = this.logFetch; const queryParams = queryString.stringify( - assign(this.params, { - origin: 'end', - offset: MAX_OUTPUT_LENGTH, - }) + assign( + { + origin: 'end', + offset: MAX_OUTPUT_LENGTH, + }, + this.params + ) ); const url = `${this.url}?${queryParams}`; From c4dd61d1cd5d841c53106bd4044efd8c107034ba Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 24 Jul 2019 23:42:51 -0700 Subject: [PATCH 16/38] Mirage factory for file system fixtures --- ui/mirage/factories/alloc-file.js | 108 ++++++++++++++++++++++++++++++ ui/mirage/models/alloc-file.js | 5 ++ 2 files changed, 113 insertions(+) create mode 100644 ui/mirage/factories/alloc-file.js create mode 100644 ui/mirage/models/alloc-file.js diff --git a/ui/mirage/factories/alloc-file.js b/ui/mirage/factories/alloc-file.js new file mode 100644 index 000000000000..3329b6498b37 --- /dev/null +++ b/ui/mirage/factories/alloc-file.js @@ -0,0 +1,108 @@ +import { Factory, faker, trait } from 'ember-cli-mirage'; +import { pickOne } from '../utils'; + +const EMOJI = '🏆 💃 🤩 🙌🏿 🖨'.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; + }, + + name() { + const fileName = `${faker.hacker.noun().dasherize()}-${pickOne(EMOJI)}${ + this.isDir ? '' : `.${this.fileType}` + }`; + + if (this.parent) { + return `${this.parent.name}/${fileName}`; + } + + return fileName; + }, + + body() { + const strategy = fileBodyMapping[this.fileType]; + return strategy ? strategy() : ''; + }, + + size() { + return this.body.length; + }, + + 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'), +}); From 97c45096df5f434d75f315edf104c5b39731f3ac Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 25 Jul 2019 00:47:46 -0700 Subject: [PATCH 17/38] Use the alloc file factory for the fs stat and fs ls end points --- ui/mirage/config.js | 82 ++++++++++--------------------- ui/mirage/factories/alloc-file.js | 19 ++++--- 2 files changed, 39 insertions(+), 62 deletions(-) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 777124d7b972..daa5adfda71e 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -305,63 +305,35 @@ 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 = allocFiles.where( + file => + (!filterPath || file.path.startsWith(filterPath)) && + file.path.length > filterPath.length && + !file.path.substr(filterPath.length + 1).includes('/') + ); + + return this.serialize(files); }; - const clientAllocationFSStatHandler = function(schema, { queryParams }) { - return { - IsDir: !queryParams.path.endsWith('.txt'), - }; + 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); }; // Client requests are available on the server and the client diff --git a/ui/mirage/factories/alloc-file.js b/ui/mirage/factories/alloc-file.js index 3329b6498b37..b6232dced754 100644 --- a/ui/mirage/factories/alloc-file.js +++ b/ui/mirage/factories/alloc-file.js @@ -1,6 +1,7 @@ import { Factory, faker, trait } from 'ember-cli-mirage'; import { pickOne } from '../utils'; +const REF_TIME = new Date(); const EMOJI = '🏆 💃 🤩 🙌🏿 🖨'.split(' '); const makeWord = () => Math.round(Math.random() * 10000000 + 50000).toString(36); const makeSentence = (count = 10) => @@ -67,16 +68,18 @@ export default Factory.extend({ return fileTypeMapping[this.fileType] || null; }, - name() { - const fileName = `${faker.hacker.noun().dasherize()}-${pickOne(EMOJI)}${ - this.isDir ? '' : `.${this.fileType}` - }`; - + path() { if (this.parent) { - return `${this.parent.name}/${fileName}`; + return `${this.parent.name}/${this.name}`; } - return fileName; + return this.name; + }, + + name() { + return `${faker.hacker.noun().dasherize()}-${pickOne(EMOJI)}${ + this.isDir ? '' : `.${this.fileType}` + }`; }, body() { @@ -88,6 +91,8 @@ export default Factory.extend({ return this.body.length; }, + modTime: () => faker.date.past(2 / 365, REF_TIME), + dir: trait({ isDir: true, afterCreate(allocFile, server) { From 33f1da9bbdce789113f654ddf4d66cdc84a836fd Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 25 Jul 2019 01:12:44 -0700 Subject: [PATCH 18/38] cat, stream, and readat mocks for alloc fs --- ui/mirage/config.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index daa5adfda71e..22a140b89b56 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -336,6 +336,46 @@ export default function() { return this.serialize(file); }; + 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 this.put('/client/allocation/:id/restart', function() { return new Response(204, {}, ''); @@ -346,6 +386,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(); From 4b037bde7f1e6481a12805431de031040768c8ca Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 25 Jul 2019 10:52:59 -0700 Subject: [PATCH 19/38] Add hollow variation to empty-message --- ui/app/styles/components/empty-message.scss | 4 ++++ 1 file changed, 4 insertions(+) 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; + } } From 55349dcceee341a3aeaa4530a097659bd49b028b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 25 Jul 2019 10:53:45 -0700 Subject: [PATCH 20/38] Add unsupported file type state --- ui/app/components/task-file.js | 1 + ui/app/templates/components/task-file.hbs | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index a735515982a6..d8ca0b05f33e 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -43,6 +43,7 @@ export default Component.extend({ return this.stat.Size > 50000; }), + fileTypeIsUnknown: equal('fileComponent', 'unknown'), isStreamable: equal('fileComponent', 'stream'), isStreaming: false, diff --git a/ui/app/templates/components/task-file.hbs b/ui/app/templates/components/task-file.hbs index be8621bfc535..dbe4d0ddc573 100644 --- a/ui/app/templates/components/task-file.hbs +++ b/ui/app/templates/components/task-file.hbs @@ -7,7 +7,10 @@
    {{yield}} - View Raw File + + {{#if (not fileTypeIsUnknown)}} + View Raw File + {{/if}} {{#if (and isLarge isStreamable)}} @@ -25,6 +28,12 @@ {{else if (eq fileComponent "image")}} {{image-file src=catUrl alt=stat.Name size=stat.Size}} {{else}} -

    No component

    +
    +

    Unsupported File Type

    +

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

    +

    + View Raw File +

    +
    {{/if}}
    From 8f496e8b65d4e367953be916cd4e139135208962 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 25 Jul 2019 22:44:29 -0700 Subject: [PATCH 21/38] Refactor existing fs tests to use new mirage factories --- ui/app/components/streaming-file.js | 1 - ui/app/components/task-file.js | 4 ++- ui/mirage/config.js | 1 - ui/mirage/factories/alloc-file.js | 2 +- ui/tests/acceptance/task-fs-test.js | 52 +++++++++++++++++++++-------- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/ui/app/components/streaming-file.js b/ui/app/components/streaming-file.js index 8e7167b31edd..7b498ccf1f8d 100644 --- a/ui/app/components/streaming-file.js +++ b/ui/app/components/streaming-file.js @@ -1,5 +1,4 @@ import Component from '@ember/component'; -import { computed } from '@ember/object'; import { run } from '@ember/runloop'; import { task } from 'ember-concurrency'; import WindowResizable from 'nomad-ui/mixins/window-resizable'; diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index d8ca0b05f33e..8f5666b09136 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -11,6 +11,8 @@ export default Component.extend({ classNames: ['boxed-section', 'task-log'], + 'data-test-file-viewer': true, + allocation: null, task: null, file: null, @@ -28,7 +30,7 @@ export default Component.extend({ mode: 'head', fileComponent: computed('stat', function() { - const contentType = this.stat.ContentType; + const contentType = this.stat.ContentType || ''; if (contentType.startsWith('image/')) { return 'image'; diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 22a140b89b56..3a91c530decf 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -5,7 +5,6 @@ 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(); diff --git a/ui/mirage/factories/alloc-file.js b/ui/mirage/factories/alloc-file.js index b6232dced754..d8ef7c893597 100644 --- a/ui/mirage/factories/alloc-file.js +++ b/ui/mirage/factories/alloc-file.js @@ -70,7 +70,7 @@ export default Factory.extend({ path() { if (this.parent) { - return `${this.parent.name}/${this.name}`; + return `${this.parent.path}/${this.name}`; } return this.name; diff --git a/ui/tests/acceptance/task-fs-test.js b/ui/tests/acceptance/task-fs-test.js index 28d1d9ee2a1e..a7f8e40f08bc 100644 --- a/ui/tests/acceptance/task-fs-test.js +++ b/ui/tests/acceptance/task-fs-test.js @@ -2,15 +2,19 @@ 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 FS from 'nomad-ui/tests/pages/allocations/task/fs'; let allocation; let task; +let files; module('Acceptance | task fs', function(hooks) { setupApplicationTest(hooks); @@ -25,6 +29,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')); + files.push(server.create('allocFile', 'file')); }); test('visiting /allocations/:allocation_id/:task_name/fs', async function(assert) { @@ -78,18 +100,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 = files[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 = files[4]; + 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(); @@ -97,11 +121,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(); @@ -113,8 +137,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('//'), @@ -126,7 +150,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); }); @@ -259,7 +283,9 @@ module('Acceptance | task fs', function(hooks) { await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); await FS.directoryEntries[2].visit(); - assert.equal(FS.breadcrumbsText, 'task-name 🤩.txt'); + const fileRecord = files[4]; + + assert.equal(FS.breadcrumbsText, `${task.name} ${fileRecord.name}`); assert.ok(FS.fileViewer.isPresent); }); @@ -294,7 +320,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('/'); @@ -303,7 +329,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'); }); }); From 7294f38a3276550c3b89b49a93d59948f2f1c4ab Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 26 Jul 2019 00:38:36 -0700 Subject: [PATCH 22/38] Integration tests for the image-file component --- ui/app/templates/components/image-file.hbs | 8 +- ui/tests/integration/image-file-test.js | 111 +++++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 ui/tests/integration/image-file-test.js diff --git a/ui/app/templates/components/image-file.hbs b/ui/app/templates/components/image-file.hbs index bc11e407cf5c..a60d6352272f 100644 --- a/ui/app/templates/components/image-file.hbs +++ b/ui/app/templates/components/image-file.hbs @@ -1,11 +1,11 @@ - - {{or + + {{or
    - {{fileName}} + {{fileName}} {{#if (and width height)}} - ({{width}}px × {{height}}{{#if size}}px, {{format-bytes size}}{{/if}}) + ({{width}}px × {{height}}{{#if size}}px, {{format-bytes size}}{{/if}}) {{/if}}
    \ No newline at end of file diff --git a/ui/tests/integration/image-file-test.js b/ui/tests/integration/image-file-test.js new file mode 100644 index 000000000000..be615e9b82e9 --- /dev/null +++ b/ui/tests/integration/image-file-test.js @@ -0,0 +1,111 @@ +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 = { + // HACK! + // Without mocking the entire image element, this component will make a + // real request for a real image. It's unfortuante to couple this test to the + // favicon, but it's an unlikely asset to be removed, and it's better than + // attempting to mock this. + src: '/ui/favicon.png', + 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 }; +} From 6bd54f734364df35d38417cbb03b93174fed96fa Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 30 Jul 2019 17:20:44 -0700 Subject: [PATCH 23/38] Test coverage for streaming file component --- .../templates/components/streaming-file.hbs | 2 +- ui/tests/integration/streaming-file-test.js | 111 ++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 ui/tests/integration/streaming-file-test.js diff --git a/ui/app/templates/components/streaming-file.hbs b/ui/app/templates/components/streaming-file.hbs index 690dd591528e..fa97a815e2ef 100644 --- a/ui/app/templates/components/streaming-file.hbs +++ b/ui/app/templates/components/streaming-file.hbs @@ -1 +1 @@ -{{logger.output}} \ No newline at end of file +{{logger.output}} \ No newline at end of file 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'); + }); +}); From c4516158b0a3fb06fac723e4f1439acbf0d93781 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 30 Jul 2019 17:21:12 -0700 Subject: [PATCH 24/38] Test coverage for task-file component --- ui/app/components/image-file.js | 1 + ui/app/components/task-file.js | 5 +- ui/app/templates/components/task-file.hbs | 4 +- ui/tests/integration/task-file-test.js | 226 ++++++++++++++++++++++ 4 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 ui/tests/integration/task-file-test.js diff --git a/ui/app/components/image-file.js b/ui/app/components/image-file.js index 2c84ff893870..fe28201ba027 100644 --- a/ui/app/components/image-file.js +++ b/ui/app/components/image-file.js @@ -4,6 +4,7 @@ import { computed } from '@ember/object'; export default Component.extend({ tagName: 'figure', classNames: 'image-file', + 'data-test-image-file': true, src: null, alt: null, diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index 8f5666b09136..a37126aab71a 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -29,7 +29,7 @@ export default Component.extend({ mode: 'head', - fileComponent: computed('stat', function() { + fileComponent: computed('stat.ContentType', function() { const contentType = this.stat.ContentType || ''; if (contentType.startsWith('image/')) { @@ -41,7 +41,7 @@ export default Component.extend({ } }), - isLarge: computed('stat', function() { + isLarge: computed('stat.Size', function() { return this.stat.Size > 50000; }), @@ -107,7 +107,6 @@ export default Component.extend({ this.set('noConnection', true); } else { this.send('failoverToServer'); - this.stream.perform(); } throw error; } diff --git a/ui/app/templates/components/task-file.hbs b/ui/app/templates/components/task-file.hbs index dbe4d0ddc573..3d9e1f518334 100644 --- a/ui/app/templates/components/task-file.hbs +++ b/ui/app/templates/components/task-file.hbs @@ -4,7 +4,7 @@

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

    {{/if}} -
    +
    {{yield}} @@ -28,7 +28,7 @@ {{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 call view the file directly.

    diff --git a/ui/tests/integration/task-file-test.js b/ui/tests/integration/task-file-test.js new file mode 100644 index 000000000000..e23ef26fcdc6 --- /dev/null +++ b/ui/tests/integration/task-file-test.js @@ -0,0 +1,226 @@ +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=${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'); + }); +}); From 0471f28e9e1e557c989191f125d5e0f4b18f014c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 30 Jul 2019 17:21:42 -0700 Subject: [PATCH 25/38] Add file mocks to every mirage scenario --- ui/mirage/scenarios/default.js | 4 ++++ 1 file changed, 4 insertions(+) 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 From a465046d9cbc042ac942b663ebd085361d77b519 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 31 Jul 2019 01:39:59 -0700 Subject: [PATCH 26/38] fixup-integrate-file-component --- ui/app/templates/allocations/allocation/task/fs.hbs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/app/templates/allocations/allocation/task/fs.hbs b/ui/app/templates/allocations/allocation/task/fs.hbs index 6dda0a01d3d0..78f5f34fc806 100644 --- a/ui/app/templates/allocations/allocation/task/fs.hbs +++ b/ui/app/templates/allocations/allocation/task/fs.hbs @@ -65,8 +65,8 @@ {{/if}} {{/list-table}} - {{/if}} -
    +
    + {{/if}} {{else}}

    Task is not Running

    From be5c88fd4fcbd8252d47914d50a2bb130c145e7f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 31 Jul 2019 01:40:39 -0700 Subject: [PATCH 27/38] Remove errant server logging line --- ui/tests/unit/adapters/node-test.js | 1 - 1 file changed, 1 deletion(-) 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() { From e277cc53086f7e7246aaf1046def882d5193dc50 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 31 Jul 2019 01:41:00 -0700 Subject: [PATCH 28/38] Update factory-based fs tests to sort properly --- ui/mirage/config.js | 18 ++++++++++-------- ui/tests/acceptance/task-fs-test.js | 29 ++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 3a91c530decf..ff177ef0e602 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -11,6 +11,15 @@ export function findLeader(schema) { 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 @@ -307,14 +316,7 @@ export default function() { 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 = allocFiles.where( - file => - (!filterPath || file.path.startsWith(filterPath)) && - file.path.length > filterPath.length && - !file.path.substr(filterPath.length + 1).includes('/') - ); - + const files = filesForPath(allocFiles, filterPath); return this.serialize(files); }; diff --git a/ui/tests/acceptance/task-fs-test.js b/ui/tests/acceptance/task-fs-test.js index a7f8e40f08bc..6d41f191ff25 100644 --- a/ui/tests/acceptance/task-fs-test.js +++ b/ui/tests/acceptance/task-fs-test.js @@ -6,9 +6,9 @@ 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'; @@ -16,6 +16,20 @@ 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); setupMirage(hooks); @@ -89,6 +103,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); @@ -100,7 +116,7 @@ module('Acceptance | task fs', function(hooks) { assert.equal(FS.breadcrumbs[0].text, 'task-name'); FS.directoryEntries[0].as(directory => { - const fileRecord = files[0]; + 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'); @@ -109,7 +125,7 @@ module('Acceptance | task fs', function(hooks) { }); FS.directoryEntries[2].as(file => { - const fileRecord = files[4]; + const fileRecord = sortedFiles[2]; assert.equal(file.name, fileRecord.name); assert.ok(file.isFile); assert.equal(file.size, formatBytes([fileRecord.size])); @@ -281,9 +297,12 @@ module('Acceptance | task fs', function(hooks) { test('viewing a file', async function(assert) { await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); - await FS.directoryEntries[2].visit(); - const fileRecord = files[4]; + 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}`); From e97c91191c5f0f2f9533b4d35dd0fbcc00fef5fc Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 7 Aug 2019 15:08:38 -0700 Subject: [PATCH 29/38] Use a data-uri instead of an image for the image-file-test --- ui/tests/integration/image-file-test.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ui/tests/integration/image-file-test.js b/ui/tests/integration/image-file-test.js index be615e9b82e9..7557cc954e7a 100644 --- a/ui/tests/integration/image-file-test.js +++ b/ui/tests/integration/image-file-test.js @@ -14,12 +14,7 @@ module('Integration | Component | image file', function(hooks) { `; const commonProperties = { - // HACK! - // Without mocking the entire image element, this component will make a - // real request for a real image. It's unfortuante to couple this test to the - // favicon, but it's an unlikely asset to be removed, and it's better than - // attempting to mock this. - src: '/ui/favicon.png', + src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', alt: 'This is the alt text', size: 123456, }; From 55039b6b828d1882a063d60c5bcaab0ba4c11cc7 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 7 Aug 2019 15:11:32 -0700 Subject: [PATCH 30/38] Minor fixes from code review --- ui/app/components/streaming-file.js | 10 +++--- ui/app/components/task-file.js | 5 ++- ui/app/styles/components/image-file.scss | 6 ---- .../allocations/allocation/task/fs.hbs | 32 +++++++++---------- ui/app/templates/components/image-file.hbs | 2 +- ui/app/templates/components/task-file.hbs | 2 +- 6 files changed, 25 insertions(+), 32 deletions(-) diff --git a/ui/app/components/streaming-file.js b/ui/app/components/streaming-file.js index 7b498ccf1f8d..9a85d41a19f4 100644 --- a/ui/app/components/streaming-file.js +++ b/ui/app/components/streaming-file.js @@ -76,16 +76,16 @@ export default Component.extend(WindowResizable, { }, stream: task(function*() { - // Follow the log if the scroll position is near the bottom of the cli window - this.logger.on('tick', () => { - run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition()); - }); - // 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'); }), diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index a37126aab71a..9a6100402df1 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -1,6 +1,7 @@ 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'; @@ -41,9 +42,7 @@ export default Component.extend({ } }), - isLarge: computed('stat.Size', function() { - return this.stat.Size > 50000; - }), + isLarge: gt('stat.Size', 50000), fileTypeIsUnknown: equal('fileComponent', 'unknown'), isStreamable: equal('fileComponent', 'stream'), diff --git a/ui/app/styles/components/image-file.scss b/ui/app/styles/components/image-file.scss index 02b99efeb206..a9b6d59201f5 100644 --- a/ui/app/styles/components/image-file.scss +++ b/ui/app/styles/components/image-file.scss @@ -17,10 +17,4 @@ display: block; color: $grey; } - - .image-file-caption-secondary { - display: block; - color: $grey-light; - font-style: italic; - } } diff --git a/ui/app/templates/allocations/allocation/task/fs.hbs b/ui/app/templates/allocations/allocation/task/fs.hbs index 78f5f34fc806..ec5507dfa703 100644 --- a/ui/app/templates/allocations/allocation/task/fs.hbs +++ b/ui/app/templates/allocations/allocation/task/fs.hbs @@ -2,24 +2,24 @@
    {{#if task.isRunning}} {{#if isFile}} - {{#task-file allocation=task.allocation task=task file=path stat=stat class="fs-explorer"}} - + {{/task-file}} {{else}}
    diff --git a/ui/app/templates/components/image-file.hbs b/ui/app/templates/components/image-file.hbs index a60d6352272f..9228740d4871 100644 --- a/ui/app/templates/components/image-file.hbs +++ b/ui/app/templates/components/image-file.hbs @@ -5,7 +5,7 @@ {{fileName}} {{#if (and width height)}} - ({{width}}px × {{height}}{{#if size}}px, {{format-bytes size}}{{/if}}) + ({{width}}px × {{height}}px{{#if size}}, {{format-bytes size}}{{/if}}) {{/if}}
    \ No newline at end of file diff --git a/ui/app/templates/components/task-file.hbs b/ui/app/templates/components/task-file.hbs index 3d9e1f518334..20faa4b4a3a0 100644 --- a/ui/app/templates/components/task-file.hbs +++ b/ui/app/templates/components/task-file.hbs @@ -30,7 +30,7 @@ {{else}}

    Unsupported File Type

    -

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

    +

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

    View Raw File

    From 26e74fe2e211a490210cf80b26a3bd197e5bcf0c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 7 Aug 2019 15:29:14 -0700 Subject: [PATCH 31/38] Make a dedicated fs-breadcrumbs component --- ui/app/components/fs-breadcrumbs.js | 42 +++++++++++++++++++ .../allocations/allocation/task/fs.js | 30 ------------- .../allocations/allocation/task/fs.hbs | 34 +-------------- .../templates/components/fs-breadcrumbs.hbs | 14 +++++++ 4 files changed, 58 insertions(+), 62 deletions(-) create mode 100644 ui/app/components/fs-breadcrumbs.js create mode 100644 ui/app/templates/components/fs-breadcrumbs.hbs 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/controllers/allocations/allocation/task/fs.js b/ui/app/controllers/allocations/allocation/task/fs.js index 9ad6df2c72db..d901c53ddafe 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: { @@ -42,33 +41,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/templates/allocations/allocation/task/fs.hbs b/ui/app/templates/allocations/allocation/task/fs.hbs index ec5507dfa703..9d6ece792bab 100644 --- a/ui/app/templates/allocations/allocation/task/fs.hbs +++ b/ui/app/templates/allocations/allocation/task/fs.hbs @@ -3,42 +3,12 @@ {{#if task.isRunning}} {{#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}}
    {{#list-table source=sortedDirectoryEntries 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 From 55d8ff4b0b6b44880a97a4f866f03cfff058c5a3 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 7 Aug 2019 15:29:57 -0700 Subject: [PATCH 32/38] Add additional troublesome characters to the alloc-file name factory --- ui/mirage/factories/alloc-file.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/mirage/factories/alloc-file.js b/ui/mirage/factories/alloc-file.js index d8ef7c893597..6de58b6370a6 100644 --- a/ui/mirage/factories/alloc-file.js +++ b/ui/mirage/factories/alloc-file.js @@ -2,7 +2,7 @@ import { Factory, faker, trait } from 'ember-cli-mirage'; import { pickOne } from '../utils'; const REF_TIME = new Date(); -const EMOJI = '🏆 💃 🤩 🙌🏿 🖨'.split(' '); +const TROUBLESOME_CHARACTERS = '🏆 💃 🤩 🙌🏿 🖨 ? / + ; %'.split(' '); const makeWord = () => Math.round(Math.random() * 10000000 + 50000).toString(36); const makeSentence = (count = 10) => new Array(count) @@ -77,7 +77,7 @@ export default Factory.extend({ }, name() { - return `${faker.hacker.noun().dasherize()}-${pickOne(EMOJI)}${ + return `${faker.hacker.noun().dasherize()}-${pickOne(TROUBLESOME_CHARACTERS)}${ this.isDir ? '' : `.${this.fileType}` }`; }, From 186a620ef1d013fb20a9f0eab168bcff1fe8a308 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 7 Aug 2019 17:34:41 -0700 Subject: [PATCH 33/38] Include all client fs endpoints in the hosts block --- ui/mirage/config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index ff177ef0e602..9098e85fb981 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -413,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)); From 038fc27ef046d3e5e5bcc6b00f37ab8bd145ec50 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 7 Aug 2019 17:35:15 -0700 Subject: [PATCH 34/38] Always preload the alloc node so the client can be dialed first --- ui/app/components/task-file.js | 23 ++++++++++++------- .../routes/allocations/allocation/task/fs.js | 5 ++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js index 9a6100402df1..4f982a7a63c8 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -100,15 +100,13 @@ export default Component.extend({ const timing = this.useServer ? this.serverTimeout : this.clientTimeout; const logFetch = url => RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then( - response => response, - error => { - if (this.useServer) { - this.set('noConnection', true); - } else { - this.send('failoverToServer'); + response => { + if (!response || !response.ok) { + this.nextErrorState(response); } - throw error; - } + return response; + }, + error => this.nextErrorState(error) ); return Log.create({ @@ -119,6 +117,15 @@ export default Component.extend({ }); }), + nextErrorState(error) { + if (this.useServer) { + this.set('noConnection', true); + } else { + this.send('failoverToServer'); + } + throw error; + }, + actions: { toggleStream() { this.set('mode', 'streaming'); diff --git a/ui/app/routes/allocations/allocation/task/fs.js b/ui/app/routes/allocations/allocation/task/fs.js index 12ee70fc4da8..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, From ed55a7b09a4401dc39fbfaf56886c9af52d8eeee Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 7 Aug 2019 17:35:43 -0700 Subject: [PATCH 35/38] Test that the client is contacted correctly, and the server is used as a fallback --- ui/tests/acceptance/task-fs-test.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/ui/tests/acceptance/task-fs-test.js b/ui/tests/acceptance/task-fs-test.js index 6d41f191ff25..4c3481d925f8 100644 --- a/ui/tests/acceptance/task-fs-test.js +++ b/ui/tests/acceptance/task-fs-test.js @@ -59,8 +59,8 @@ module('Acceptance | task fs', function(hooks) { ); files.push(server.create('allocFile', { isDir: true, name: 'empty-directory' })); - files.push(server.create('allocFile', 'file')); - files.push(server.create('allocFile', 'file')); + 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) { @@ -296,6 +296,13 @@ module('Acceptance | task fs', function(hooks) { }); test('viewing a file', async function(assert) { + const node = server.db.nodes.find(allocation.nodeId); + + server.logging = true; + 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: '/' }); const sortedFiles = fileSort('name', filesForPath(this.server.schema.allocFiles, '').models); @@ -307,6 +314,22 @@ module('Acceptance | task fs', function(hooks) { 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) { From 0fad368fe927167450a7415a2dbb8758fcf23045 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 8 Aug 2019 09:58:10 -0700 Subject: [PATCH 36/38] Limit the width of the right page layout column This was causing elements to flow off the page, since the element was assuming 100% but also had a 250px margin for the left column. This had previously been "fixed" by setting overflow-x: auto, but that resulted in tooltips from being clipped. This is a better solution to the same problem. --- ui/app/styles/components/page-layout.scss | 2 ++ 1 file changed, 2 insertions(+) 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%; } } } From a728ed13e1718f795d41c022273b6604b72e221b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 8 Aug 2019 15:41:47 -0700 Subject: [PATCH 37/38] Prevent a change in height when switching from a dir to a file --- ui/app/styles/core/buttons.scss | 2 +- ui/app/templates/components/task-file.hbs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/components/task-file.hbs b/ui/app/templates/components/task-file.hbs index 20faa4b4a3a0..bed5c27620f9 100644 --- a/ui/app/templates/components/task-file.hbs +++ b/ui/app/templates/components/task-file.hbs @@ -9,14 +9,14 @@ {{#if (not fileTypeIsUnknown)}} - View Raw File + View Raw File {{/if}} {{#if (and isLarge isStreamable)}} - - + + {{/if}} {{#if isStreamable}} - {{/if}} From a321145457df2edf39a1e620fbba6d3d6b328585 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 12 Aug 2019 13:51:38 -0700 Subject: [PATCH 38/38] Encode characters in file paths to ensure proper URIs --- ui/app/adapters/task-state.js | 6 ++++-- ui/app/components/fs-directory-entry.js | 2 +- ui/app/components/task-file.js | 4 +++- ui/mirage/factories/alloc-file.js | 2 +- ui/tests/acceptance/task-fs-test.js | 1 - ui/tests/integration/task-file-test.js | 4 +++- 6 files changed, 12 insertions(+), 7 deletions(-) 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-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/task-file.js b/ui/app/components/task-file.js index 4f982a7a63c8..03f4e52c4e09 100644 --- a/ui/app/components/task-file.js +++ b/ui/app/components/task-file.js @@ -49,7 +49,8 @@ export default Component.extend({ isStreaming: false, catUrl: computed('allocation.id', 'task.name', 'file', function() { - return `/v1/client/fs/cat/${this.allocation.id}?path=${this.task.name}/${this.file}`; + const encodedPath = encodeURIComponent(`${this.task.name}/${this.file}`); + return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`; }), fetchMode: computed('isLarge', 'mode', function() { @@ -77,6 +78,7 @@ export default Component.extend({ ), fileParams: computed('task.name', 'file', 'mode', function() { + // The Log class handles encoding query params const path = `${this.task.name}/${this.file}`; switch (this.mode) { diff --git a/ui/mirage/factories/alloc-file.js b/ui/mirage/factories/alloc-file.js index 6de58b6370a6..db9f4a9261f2 100644 --- a/ui/mirage/factories/alloc-file.js +++ b/ui/mirage/factories/alloc-file.js @@ -2,7 +2,7 @@ import { Factory, faker, trait } from 'ember-cli-mirage'; import { pickOne } from '../utils'; const REF_TIME = new Date(); -const TROUBLESOME_CHARACTERS = '🏆 💃 🤩 🙌🏿 🖨 ? / + ; %'.split(' '); +const TROUBLESOME_CHARACTERS = '🏆 💃 🤩 🙌🏿 🖨 ? ; %'.split(' '); const makeWord = () => Math.round(Math.random() * 10000000 + 50000).toString(36); const makeSentence = (count = 10) => new Array(count) diff --git a/ui/tests/acceptance/task-fs-test.js b/ui/tests/acceptance/task-fs-test.js index 4c3481d925f8..d8c4bc9949c3 100644 --- a/ui/tests/acceptance/task-fs-test.js +++ b/ui/tests/acceptance/task-fs-test.js @@ -298,7 +298,6 @@ module('Acceptance | task fs', function(hooks) { test('viewing a file', async function(assert) { const node = server.db.nodes.find(allocation.nodeId); - server.logging = true; server.get(`http://${node.httpAddr}/v1/client/fs/readat/:allocation_id`, function() { return new Response(500); }); diff --git a/ui/tests/integration/task-file-test.js b/ui/tests/integration/task-file-test.js index e23ef26fcdc6..05f944579dcb 100644 --- a/ui/tests/integration/task-file-test.js +++ b/ui/tests/integration/task-file-test.js @@ -132,7 +132,9 @@ module('Integration | Component | task file', function(hooks) { const rawLink = find('[data-test-log-action="raw"]'); assert.equal( rawLink.getAttribute('href'), - `/v1/client/fs/cat/${props.allocation.id}?path=${props.task.name}/${props.file}`, + `/v1/client/fs/cat/${props.allocation.id}?path=${encodeURIComponent( + `${props.task.name}/${props.file}` + )}`, 'Raw link href is correct' );