Merge pull request #6048 from hashicorp/f-ui/alloc-fs-files

UI: Alloc FS: File Viewer
This commit is contained in:
Michael Lange 2019-08-19 10:36:28 -07:00 committed by GitHub
commit fd6d5b274f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1291 additions and 249 deletions

View File

@ -6,13 +6,15 @@ export default ApplicationAdapter.extend({
ls(model, path) { ls(model, path) {
return this.token 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); .then(handleFSResponse);
}, },
stat(model, path) { stat(model, path) {
return this.token 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); .then(handleFSResponse);
}, },
}); });

View File

@ -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;
}),
});

View File

@ -7,7 +7,7 @@ export default Component.extend({
pathToEntry: computed('path', 'entry.Name', function() { pathToEntry: computed('path', 'entry.Name', function() {
const pathWithNoLeadingSlash = this.get('path').replace(/^\//, ''); const pathWithNoLeadingSlash = this.get('path').replace(/^\//, '');
const name = this.get('entry.Name'); const name = encodeURIComponent(this.get('entry.Name'));
if (isEmpty(pathWithNoLeadingSlash)) { if (isEmpty(pathWithNoLeadingSlash)) {
return name; return name;

View File

@ -0,0 +1,29 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
tagName: 'figure',
classNames: 'image-file',
'data-test-image-file': true,
src: null,
alt: null,
size: null,
// Set by updateImageMeta
width: 0,
height: 0,
fileName: computed('src', function() {
if (!this.src) return;
return this.src.includes('/') ? this.src.match(/^.*\/(.*)$/)[1] : this.src;
}),
updateImageMeta(event) {
const img = event.target;
this.setProperties({
width: img.naturalWidth,
height: img.naturalHeight,
});
},
});

View File

@ -0,0 +1,96 @@
import Component from '@ember/component';
import { run } from '@ember/runloop';
import { task } from 'ember-concurrency';
import WindowResizable from 'nomad-ui/mixins/window-resizable';
export default Component.extend(WindowResizable, {
tagName: 'pre',
classNames: ['cli-window'],
'data-test-log-cli': true,
mode: 'streaming', // head, tail, streaming
isStreaming: true,
logger: null,
didReceiveAttrs() {
if (!this.logger) {
return;
}
run.scheduleOnce('actions', () => {
switch (this.mode) {
case 'head':
this.head.perform();
break;
case 'tail':
this.tail.perform();
break;
case 'streaming':
if (this.isStreaming) {
this.stream.perform();
} else {
this.logger.stop();
}
break;
}
});
},
didInsertElement() {
this.fillAvailableHeight();
},
windowResizeHandler() {
run.once(this, this.fillAvailableHeight);
},
fillAvailableHeight() {
// This math is arbitrary and far from bulletproof, but the UX
// of having the log window fill available height is worth the hack.
const margins = 30; // Account for padding and margin on either side of the CLI
const cliWindow = this.element;
cliWindow.style.height = `${window.innerHeight - cliWindow.offsetTop - margins}px`;
},
head: task(function*() {
yield this.get('logger.gotoHead').perform();
run.scheduleOnce('afterRender', () => {
this.element.scrollTop = 0;
});
}),
tail: task(function*() {
yield this.get('logger.gotoTail').perform();
run.scheduleOnce('afterRender', () => {
const cliWindow = this.element;
cliWindow.scrollTop = cliWindow.scrollHeight;
});
}),
synchronizeScrollPosition(force = false) {
const cliWindow = this.element;
if (cliWindow.scrollHeight - cliWindow.scrollTop < 10 || force) {
// If the window is approximately scrolled to the bottom, follow the log
cliWindow.scrollTop = cliWindow.scrollHeight;
}
},
stream: task(function*() {
// Force the scroll position to the bottom of the window when starting streaming
this.logger.one('tick', () => {
run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition(true));
});
// Follow the log if the scroll position is near the bottom of the cli window
this.logger.on('tick', () => {
run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition());
});
yield this.logger.startStreaming();
this.logger.off('tick');
}),
willDestroy() {
this.logger.stop();
},
});

View File

@ -0,0 +1,148 @@
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { computed } from '@ember/object';
import { gt } from '@ember/object/computed';
import { equal } from '@ember/object/computed';
import RSVP from 'rsvp';
import Log from 'nomad-ui/utils/classes/log';
import timeout from 'nomad-ui/utils/timeout';
export default Component.extend({
token: service(),
classNames: ['boxed-section', 'task-log'],
'data-test-file-viewer': true,
allocation: null,
task: null,
file: null,
stat: null, // { Name, IsDir, Size, FileMode, ModTime, ContentType }
// When true, request logs from the server agent
useServer: false,
// When true, logs cannot be fetched from either the client or the server
noConnection: false,
clientTimeout: 1000,
serverTimeout: 5000,
mode: 'head',
fileComponent: computed('stat.ContentType', function() {
const contentType = this.stat.ContentType || '';
if (contentType.startsWith('image/')) {
return 'image';
} else if (contentType.startsWith('text/') || contentType.startsWith('application/json')) {
return 'stream';
} else {
return 'unknown';
}
}),
isLarge: gt('stat.Size', 50000),
fileTypeIsUnknown: equal('fileComponent', 'unknown'),
isStreamable: equal('fileComponent', 'stream'),
isStreaming: false,
catUrl: computed('allocation.id', 'task.name', 'file', function() {
const encodedPath = encodeURIComponent(`${this.task.name}/${this.file}`);
return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`;
}),
fetchMode: computed('isLarge', 'mode', function() {
if (this.mode === 'streaming') {
return 'stream';
}
if (!this.isLarge) {
return 'cat';
} else if (this.mode === 'head' || this.mode === 'tail') {
return 'readat';
}
}),
fileUrl: computed(
'allocation.id',
'allocation.node.httpAddr',
'fetchMode',
'useServer',
function() {
const address = this.get('allocation.node.httpAddr');
const url = `/v1/client/fs/${this.fetchMode}/${this.allocation.id}`;
return this.useServer ? url : `//${address}${url}`;
}
),
fileParams: computed('task.name', 'file', 'mode', function() {
// The Log class handles encoding query params
const path = `${this.task.name}/${this.file}`;
switch (this.mode) {
case 'head':
return { path, offset: 0, limit: 50000 };
case 'tail':
return { path, offset: this.stat.Size - 50000, limit: 50000 };
case 'streaming':
return { path, offset: 50000, origin: 'end' };
default:
return { path };
}
}),
logger: computed('fileUrl', 'fileParams', 'mode', function() {
// The cat and readat APIs are in plainText while the stream API is always encoded.
const plainText = this.mode === 'head' || this.mode === 'tail';
// If the file request can't settle in one second, the client
// must be unavailable and the server should be used instead
const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
const logFetch = url =>
RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then(
response => {
if (!response || !response.ok) {
this.nextErrorState(response);
}
return response;
},
error => this.nextErrorState(error)
);
return Log.create({
logFetch,
plainText,
params: this.fileParams,
url: this.fileUrl,
});
}),
nextErrorState(error) {
if (this.useServer) {
this.set('noConnection', true);
} else {
this.send('failoverToServer');
}
throw error;
},
actions: {
toggleStream() {
this.set('mode', 'streaming');
this.toggleProperty('isStreaming');
},
gotoHead() {
this.set('mode', 'head');
this.set('isStreaming', false);
},
gotoTail() {
this.set('mode', 'tail');
this.set('isStreaming', false);
},
failoverToServer() {
this.set('useServer', true);
},
},
});

View File

@ -1,14 +1,11 @@
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import Component from '@ember/component'; import Component from '@ember/component';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import { run } from '@ember/runloop';
import RSVP from 'rsvp'; import RSVP from 'rsvp';
import { task } from 'ember-concurrency';
import { logger } from 'nomad-ui/utils/classes/log'; import { logger } from 'nomad-ui/utils/classes/log';
import WindowResizable from 'nomad-ui/mixins/window-resizable';
import timeout from 'nomad-ui/utils/timeout'; import timeout from 'nomad-ui/utils/timeout';
export default Component.extend(WindowResizable, { export default Component.extend({
token: service(), token: service(),
classNames: ['boxed-section', 'task-log'], classNames: ['boxed-section', 'task-log'],
@ -25,26 +22,8 @@ export default Component.extend(WindowResizable, {
clientTimeout: 1000, clientTimeout: 1000,
serverTimeout: 5000, serverTimeout: 5000,
didReceiveAttrs() { isStreaming: true,
if (this.allocation && this.task) { streamMode: 'streaming',
this.send('toggleStream');
}
},
didInsertElement() {
this.fillAvailableHeight();
},
windowResizeHandler() {
run.once(this, this.fillAvailableHeight);
},
fillAvailableHeight() {
// This math is arbitrary and far from bulletproof, but the UX
// of having the log window fill available height is worth the hack.
const cliWindow = this.$('.cli-window');
cliWindow.height(window.innerHeight - cliWindow.offset().top - 25);
},
mode: 'stdout', mode: 'stdout',
@ -75,56 +54,28 @@ export default Component.extend(WindowResizable, {
this.set('noConnection', true); this.set('noConnection', true);
} else { } else {
this.send('failoverToServer'); this.send('failoverToServer');
this.stream.perform();
} }
throw error; 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: { actions: {
setMode(mode) { setMode(mode) {
this.logger.stop(); this.logger.stop();
this.set('mode', mode); this.set('mode', mode);
this.stream.perform();
}, },
toggleStream() { toggleStream() {
if (this.get('logger.isStreaming')) { this.set('streamMode', 'streaming');
this.logger.stop(); this.toggleProperty('isStreaming');
} else { },
this.stream.perform(); gotoHead() {
} this.set('streamMode', 'head');
this.set('isStreaming', false);
},
gotoTail() {
this.set('streamMode', 'tail');
this.set('isStreaming', false);
}, },
failoverToServer() { failoverToServer() {
this.set('useServer', true); this.set('useServer', true);

View File

@ -1,7 +1,6 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import { filterBy } from '@ember/object/computed'; import { filterBy } from '@ember/object/computed';
import { isEmpty } from '@ember/utils';
export default Controller.extend({ export default Controller.extend({
queryParams: { queryParams: {
@ -16,6 +15,7 @@ export default Controller.extend({
task: null, task: null,
directoryEntries: null, directoryEntries: null,
isFile: null, isFile: null,
stat: null,
directories: filterBy('directoryEntries', 'IsDir'), directories: filterBy('directoryEntries', 'IsDir'),
files: filterBy('directoryEntries', 'IsDir', false), files: filterBy('directoryEntries', 'IsDir', false),
@ -51,33 +51,4 @@ export default Controller.extend({
} }
} }
), ),
breadcrumbs: computed('path', function() {
const breadcrumbs = this.path
.split('/')
.reject(isEmpty)
.reduce((breadcrumbs, pathSegment, index) => {
let breadcrumbPath;
if (index > 0) {
const lastBreadcrumb = breadcrumbs[index - 1];
breadcrumbPath = `${lastBreadcrumb.path}/${pathSegment}`;
} else {
breadcrumbPath = pathSegment;
}
breadcrumbs.push({
name: pathSegment,
path: breadcrumbPath,
});
return breadcrumbs;
}, []);
if (breadcrumbs.length) {
breadcrumbs[breadcrumbs.length - 1].isLast = true;
}
return breadcrumbs;
}),
}); });

View File

@ -9,9 +9,8 @@ export default Route.extend({
const pathWithTaskName = `${task.name}${decodedPath.startsWith('/') ? '' : '/'}${decodedPath}`; const pathWithTaskName = `${task.name}${decodedPath.startsWith('/') ? '' : '/'}${decodedPath}`;
return task return RSVP.all([task.stat(pathWithTaskName), task.get('allocation.node')])
.stat(pathWithTaskName) .then(([statJson]) => {
.then(statJson => {
if (statJson.IsDir) { if (statJson.IsDir) {
return RSVP.hash({ return RSVP.hash({
path: decodedPath, path: decodedPath,
@ -24,14 +23,15 @@ export default Route.extend({
path: decodedPath, path: decodedPath,
task, task,
isFile: true, isFile: true,
stat: statJson,
}; };
} }
}) })
.catch(notifyError(this)); .catch(notifyError(this));
}, },
setupController(controller, { path, task, directoryEntries, isFile } = {}) { setupController(controller, { path, task, directoryEntries, isFile, stat } = {}) {
this._super(...arguments); this._super(...arguments);
controller.setProperties({ path, task, directoryEntries, isFile }); controller.setProperties({ path, task, directoryEntries, isFile, stat });
}, },
}); });

View File

@ -11,6 +11,7 @@
@import './components/fs-explorer'; @import './components/fs-explorer';
@import './components/gutter'; @import './components/gutter';
@import './components/gutter-toggle'; @import './components/gutter-toggle';
@import './components/image-file.scss';
@import './components/inline-definitions'; @import './components/inline-definitions';
@import './components/job-diff'; @import './components/job-diff';
@import './components/loading-spinner'; @import './components/loading-spinner';

View File

@ -18,4 +18,8 @@
color: $grey; color: $grey;
} }
} }
&.is-hollow {
background: transparent;
}
} }

View File

@ -0,0 +1,20 @@
.image-file {
width: 100%;
height: 100%;
background: $white;
text-align: center;
color: $text;
.image-file-image {
margin: auto;
}
.image-file-caption {
margin-top: 0.5em;
}
.image-file-caption-primary {
display: block;
color: $grey;
}
}

View File

@ -36,6 +36,7 @@
&.is-right { &.is-right {
margin-left: $gutter-width; margin-left: $gutter-width;
width: calc(100% - #{$gutter-width});
} }
@media #{$mq-hidden-gutter} { @media #{$mq-hidden-gutter} {
@ -51,6 +52,7 @@
&.is-right { &.is-right {
margin-left: 0; margin-left: 0;
width: 100%;
} }
} }
} }

View File

@ -30,7 +30,7 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
&.is-compact { &.is-compact {
padding: 0.25em 0.75em; padding: 0.25em 0.75em;
margin: -0.25em -0.25em -0.25em 0; margin: -0.25em 0;
&.pull-right { &.pull-right {
margin-right: -1em; margin-right: -1em;

View File

@ -1,32 +1,16 @@
{{title pathWithLeadingSlash " - Task " task.name " filesystem"}} {{title pathWithLeadingSlash " - Task " task.name " filesystem"}}
{{task-subnav task=task}} {{task-subnav task=task}}
<section class="section is-closer"> <section class="section is-closer {{if isFile "full-width-section"}}">
{{#if task.isRunning}} {{#if task.isRunning}}
<div class="fs-explorer boxed-section"> {{#if isFile}}
<div class="boxed-section-head is-compact"> {{#task-file allocation=task.allocation task=task file=path stat=stat class="fs-explorer"}}
<nav class="breadcrumb" data-test-fs-breadcrumbs> {{fs-breadcrumbs task=task path=path}}
<ul> {{/task-file}}
<li class={{if breadcrumbs "" "is-active"}}> {{else}}
{{#link-to "allocations.allocation.task.fs-root" task.allocation task activeClass="is-active"}} <div class="fs-explorer boxed-section">
{{task.name}} <div class="boxed-section-head">
{{/link-to}} {{fs-breadcrumbs task=task path=path}}
</li>
{{#each breadcrumbs as |breadcrumb|}}
<li class={{if breadcrumb.isLast "is-active"}}>
{{#link-to "allocations.allocation.task.fs" task.allocation task breadcrumb.path activeClass="is-active"}}
{{breadcrumb.name}}
{{/link-to}}
</li>
{{/each}}
</ul>
</nav>
</div>
{{#if isFile}}
<div class="boxed-section-body">
<div data-test-file-viewer>placeholder file viewer</div>
</div> </div>
{{else}}
{{#list-table {{#list-table
source=sortedDirectoryEntries source=sortedDirectoryEntries
sortProperty=sortProperty sortProperty=sortProperty
@ -52,8 +36,8 @@
</tbody> </tbody>
{{/if}} {{/if}}
{{/list-table}} {{/list-table}}
{{/if}} </div>
</div> {{/if}}
{{else}} {{else}}
<div data-test-not-running class="empty-message"> <div data-test-not-running class="empty-message">
<h3 data-test-not-running-headline class="empty-message-headline">Task is not Running</h3> <h3 data-test-not-running-headline class="empty-message-headline">Task is not Running</h3>

View File

@ -0,0 +1,14 @@
<ul>
<li class={{if breadcrumbs "" "is-active"}}>
{{#link-to "allocations.allocation.task.fs-root" task.allocation task activeClass="is-active"}}
{{task.name}}
{{/link-to}}
</li>
{{#each breadcrumbs as |breadcrumb|}}
<li class={{if breadcrumb.isLast "is-active"}}>
{{#link-to "allocations.allocation.task.fs" task.allocation task breadcrumb.path activeClass="is-active"}}
{{breadcrumb.name}}
{{/link-to}}
</li>
{{/each}}
</ul>

View File

@ -0,0 +1,11 @@
<a data-test-image-link href={{src}} target="_blank" rel="noopener noreferrer" class="image-file-image">
<img data-test-image src={{src}} alt={{or alt fileName}} title={{fileName}} onload={{action updateImageMeta}} />
</a>
<figcaption class="image-file-caption">
<span class="image-file-caption-primary">
<strong data-test-file-name>{{fileName}}</strong>
{{#if (and width height)}}
<span data-test-file-stats>({{width}}px &times; {{height}}px{{#if size}}, {{format-bytes size}}{{/if}})</span>
{{/if}}
</span>
</figcaption>

View File

@ -0,0 +1 @@
<code data-test-output>{{logger.output}}</code>

View File

@ -0,0 +1,39 @@
{{#if noConnection}}
<div data-test-connection-error class="notification is-error">
<h3 class="title is-4">Cannot fetch file</h3>
<p>The files for this task are inaccessible. Check the condition of the client the allocation is on.</p>
</div>
{{/if}}
<div data-test-header class="boxed-section-head">
{{yield}}
<span class="pull-right">
{{#if (not fileTypeIsUnknown)}}
<a data-test-log-action="raw" class="button is-white is-compact" href="{{catUrl}}" target="_blank" rel="noopener noreferrer">View Raw File</a>
{{/if}}
{{#if (and isLarge isStreamable)}}
<button data-test-log-action="head" class="button is-white is-compact" onclick={{action "gotoHead"}}>Head</button>
<button data-test-log-action="tail" class="button is-white is-compact" onclick={{action "gotoTail"}}>Tail</button>
{{/if}}
{{#if isStreamable}}
<button data-test-log-action="toggle-stream" class="button is-white is-compact" onclick={{action "toggleStream"}}>
{{x-icon (if logger.isStreaming "media-pause" "media-play") class="is-text"}}
</button>
{{/if}}
</span>
</div>
<div data-test-file-box class="boxed-section-body {{if (eq fileComponent "stream") "is-dark is-full-bleed"}}">
{{#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}}
<div data-test-unsupported-type class="empty-message is-hollow">
<h3 class="empty-message-headline">Unsupported File Type</h3>
<p class="empty-message-body message">The Nomad UI could not render this file, but you can still view the file directly.</p>
<p class="empty-message-body">
<a data-test-log-action="raw" class="button is-light" href="{{catUrl}}" target="_blank" rel="noopener noreferrer">View Raw File</a>
</p>
</div>
{{/if}}
</div>

View File

@ -10,13 +10,13 @@
<button data-test-log-action="stderr" class="button {{if (eq mode "stderr") "is-danger"}}" {{action "setMode" "stderr"}}>stderr</button> <button data-test-log-action="stderr" class="button {{if (eq mode "stderr") "is-danger"}}" {{action "setMode" "stderr"}}>stderr</button>
</span> </span>
<span class="pull-right"> <span class="pull-right">
<button data-test-log-action="head" class="button is-white" onclick={{perform head}}>Head</button> <button data-test-log-action="head" class="button is-white" onclick={{action "gotoHead"}}>Head</button>
<button data-test-log-action="tail" class="button is-white" onclick={{perform tail}}>Tail</button> <button data-test-log-action="tail" class="button is-white" onclick={{action "gotoTail"}}>Tail</button>
<button data-test-log-action="toggle-stream" class="button is-white" onclick={{action "toggleStream"}}> <button data-test-log-action="toggle-stream" class="button is-white" onclick={{action "toggleStream"}}>
{{x-icon (if logger.isStreaming "media-pause" "media-play") class="is-text"}} {{x-icon (if logger.isStreaming "media-pause" "media-play") class="is-text"}}
</button> </button>
</span> </span>
</div> </div>
<div data-test-log-box class="boxed-section-body is-dark is-full-bleed"> <div data-test-log-box class="boxed-section-body is-dark is-full-bleed">
<pre data-test-log-cli class="cli-window"><code>{{logger.output}}</code></pre> {{streaming-file logger=logger mode=streamMode isStreaming=isStreaming}}
</div> </div>

View File

@ -8,6 +8,7 @@ import queryString from 'query-string';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import StreamLogger from 'nomad-ui/utils/classes/stream-logger'; import StreamLogger from 'nomad-ui/utils/classes/stream-logger';
import PollLogger from 'nomad-ui/utils/classes/poll-logger'; import PollLogger from 'nomad-ui/utils/classes/poll-logger';
import { decode } from 'nomad-ui/utils/stream-frames';
import Anser from 'anser'; import Anser from 'anser';
const MAX_OUTPUT_LENGTH = 50000; const MAX_OUTPUT_LENGTH = 50000;
@ -20,6 +21,7 @@ const Log = EmberObject.extend(Evented, {
url: '', url: '',
params: computed(() => ({})), params: computed(() => ({})),
plainText: false,
logFetch() { logFetch() {
assert('Log objects need a logFetch method, which should have an interface like window.fetch'); assert('Log objects need a logFetch method, which should have an interface like window.fetch');
}, },
@ -40,6 +42,7 @@ const Log = EmberObject.extend(Evented, {
// the logPointer is pointed at head or tail // the logPointer is pointed at head or tail
output: computed('logPointer', 'head', 'tail', function() { output: computed('logPointer', 'head', 'tail', function() {
let logs = this.logPointer === 'head' ? this.head : this.tail; let logs = this.logPointer === 'head' ? this.head : this.tail;
logs = logs.replace(/</g, '&lt;').replace(/>/g, '&gt;');
let colouredLogs = Anser.ansiToHtml(logs); let colouredLogs = Anser.ansiToHtml(logs);
return htmlSafe(colouredLogs); return htmlSafe(colouredLogs);
}), }),
@ -72,16 +75,19 @@ const Log = EmberObject.extend(Evented, {
gotoHead: task(function*() { gotoHead: task(function*() {
const logFetch = this.logFetch; const logFetch = this.logFetch;
const queryParams = queryString.stringify( const queryParams = queryString.stringify(
assign(this.params, { assign(
plain: true, {
origin: 'start', origin: 'start',
offset: 0, offset: 0,
}) },
this.params
)
); );
const url = `${this.url}?${queryParams}`; const url = `${this.url}?${queryParams}`;
this.stop(); this.stop();
let text = yield logFetch(url).then(res => res.text(), fetchFailure(url)); const response = yield logFetch(url).then(res => res.text(), fetchFailure(url));
let text = this.plainText ? response : decode(response).message;
if (text && text.length > MAX_OUTPUT_LENGTH) { if (text && text.length > MAX_OUTPUT_LENGTH) {
text = text.substr(0, MAX_OUTPUT_LENGTH); text = text.substr(0, MAX_OUTPUT_LENGTH);
@ -94,16 +100,19 @@ const Log = EmberObject.extend(Evented, {
gotoTail: task(function*() { gotoTail: task(function*() {
const logFetch = this.logFetch; const logFetch = this.logFetch;
const queryParams = queryString.stringify( const queryParams = queryString.stringify(
assign(this.params, { assign(
plain: true, {
origin: 'end', origin: 'end',
offset: MAX_OUTPUT_LENGTH, offset: MAX_OUTPUT_LENGTH,
}) },
this.params
)
); );
const url = `${this.url}?${queryParams}`; const url = `${this.url}?${queryParams}`;
this.stop(); this.stop();
let text = yield logFetch(url).then(res => res.text(), fetchFailure(url)); const response = yield logFetch(url).then(res => res.text(), fetchFailure(url));
let text = this.plainText ? response : decode(response).message;
this.set('tail', text); this.set('tail', text);
this.set('logPointer', 'tail'); this.set('logPointer', 'tail');

View File

@ -1,5 +1,6 @@
import EmberObject from '@ember/object'; import EmberObject from '@ember/object';
import { task, timeout } from 'ember-concurrency'; import { task, timeout } from 'ember-concurrency';
import { decode } from 'nomad-ui/utils/stream-frames';
import AbstractLogger from './abstract-logger'; import AbstractLogger from './abstract-logger';
import { fetchFailure } from './log'; import { fetchFailure } from './log';
@ -7,9 +8,7 @@ export default EmberObject.extend(AbstractLogger, {
interval: 1000, interval: 1000,
start() { start() {
return this.poll return this.poll.linked().perform();
.linked()
.perform();
}, },
stop() { stop() {
@ -29,15 +28,10 @@ export default EmberObject.extend(AbstractLogger, {
let text = yield response.text(); let text = yield response.text();
if (text) { if (text) {
const lines = text.replace(/\}\{/g, '}\n{').split('\n'); const { offset, message } = decode(text);
const frames = lines if (message) {
.map(line => JSON.parse(line)) this.set('endOffset', offset);
.filter(frame => frame.Data != null && frame.Offset != null); this.write(message);
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(''));
} }
} }

View File

@ -1,6 +1,7 @@
import EmberObject, { computed } from '@ember/object'; import EmberObject, { computed } from '@ember/object';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import TextDecoder from 'nomad-ui/utils/classes/text-decoder'; import TextDecoder from 'nomad-ui/utils/classes/text-decoder';
import { decode } from 'nomad-ui/utils/stream-frames';
import AbstractLogger from './abstract-logger'; import AbstractLogger from './abstract-logger';
import { fetchFailure } from './log'; 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 // 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. // point chunk is a series of valid JSON objects with no delimiter.
const lines = chunk.replace(/\}\{/g, '}\n{').split('\n'); const { offset, message } = decode(chunk);
const frames = lines.map(line => JSON.parse(line)).filter(frame => frame.Data); if (message) {
this.set('endOffset', offset);
if (frames.length) { this.write(message);
frames.forEach(frame => (frame.Data = window.atob(frame.Data)));
this.set('endOffset', frames[frames.length - 1].Offset);
this.write(frames.mapBy('Data').join(''));
} }
} }
}); });

View File

@ -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 {};
}

View File

@ -5,13 +5,21 @@ import { logFrames, logEncode } from './data/logs';
import { generateDiff } from './factories/job-version'; import { generateDiff } from './factories/job-version';
import { generateTaskGroupFailures } from './factories/evaluation'; import { generateTaskGroupFailures } from './factories/evaluation';
import { copy } from 'ember-copy'; import { copy } from 'ember-copy';
import moment from 'moment';
export function findLeader(schema) { export function findLeader(schema) {
const agent = schema.agents.first(); const agent = schema.agents.first();
return `${agent.address}:${agent.tags.port}`; 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() { export default function() {
this.timing = 0; // delay for each request, automatically set to 0 during testing this.timing = 0; // delay for each request, automatically set to 0 during testing
@ -305,63 +313,68 @@ export default function() {
return logEncode(logFrames, logFrames.length - 1); return logEncode(logFrames, logFrames.length - 1);
}; };
const clientAllocationFSLsHandler = function(schema, { queryParams }) { const clientAllocationFSLsHandler = function({ allocFiles }, { queryParams }) {
if (queryParams.path.endsWith('empty-directory')) { // Ignore the task name at the beginning of the path
return []; const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1);
} else if (queryParams.path.endsWith('directory')) { const files = filesForPath(allocFiles, filterPath);
return [ return this.serialize(files);
{
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 clientAllocationFSStatHandler = function(schema, { queryParams }) { const clientAllocationFSStatHandler = function({ allocFiles }, { queryParams }) {
return { // Ignore the task name at the beginning of the path
IsDir: !queryParams.path.endsWith('.txt'), const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1);
};
// Root path
if (!filterPath) {
return this.serialize({
IsDir: true,
ModTime: new Date(),
});
}
// Either a file or a nested directory
const file = allocFiles.where({ path: filterPath }).models[0];
return this.serialize(file);
};
const 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 // Client requests are available on the server and the client
@ -374,6 +387,9 @@ export default function() {
this.get('/client/fs/ls/:allocation_id', clientAllocationFSLsHandler); this.get('/client/fs/ls/:allocation_id', clientAllocationFSLsHandler);
this.get('/client/fs/stat/:allocation_id', clientAllocationFSStatHandler); 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 }) { this.get('/client/stats', function({ clientStats }, { queryParams }) {
const seed = Math.random(); const seed = Math.random();
@ -397,6 +413,9 @@ export default function() {
this.get(`http://${host}/v1/client/fs/ls/:allocation_id`, clientAllocationFSLsHandler); this.get(`http://${host}/v1/client/fs/ls/:allocation_id`, clientAllocationFSLsHandler);
this.get(`http://${host}/v1/client/stat/ls/:allocation_id`, clientAllocationFSStatHandler); 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 }) { this.get(`http://${host}/v1/client/stats`, function({ clientStats }) {
return this.serialize(clientStats.find(host)); return this.serialize(clientStats.find(host));

View File

@ -0,0 +1,113 @@
import { Factory, faker, trait } from 'ember-cli-mirage';
import { pickOne } from '../utils';
const REF_TIME = new Date();
const TROUBLESOME_CHARACTERS = '🏆 💃 🤩 🙌🏿 🖨 ? ; %'.split(' ');
const makeWord = () => Math.round(Math.random() * 10000000 + 50000).toString(36);
const makeSentence = (count = 10) =>
new Array(count)
.fill(null)
.map(makeWord)
.join(' ');
const fileTypeMapping = {
svg: 'image/svg',
txt: 'text/plain',
json: 'application/json',
app: 'application/octet-stream',
exe: 'application/octet-stream',
};
const fileBodyMapping = {
svg: () => `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 207 60">
<g fill="none">
<path class="top" fill="#25BA81" d="M26.03.01L0 15.05l17.56 10.32 3.56-2.17 8.63 4.82v-10l8.27-4.97v10.02l14.48-8.02v-.04"/>
<path class="left" fill="#25BA81" d="M22.75 32.03v9.99l-7.88 5v-20l2.99-1.83L.15 15.05H0v29.96l26.25 15V34.03"/>
<path class="right" fill="#1F9967" d="M38.02 23.07v9.95l-6.93 4.01-4.84-3v25.98h.14l26.11-15V15.05l-.49-.01"/>
<path class="text" fill="#000" d="M78.49 21.83v24.24h-5.9v-32h8.06l12.14 24.32V14.1h5.9v32h-8.06m22.46.45c-8 0-10.18-4.42-10.18-9.22v-5.9c0-4.8 2.16-9.22 10.18-9.22s10.18 4.42 10.18 9.22v5.91c0 4.79-2.16 9.21-10.18 9.21zm0-19.35c-3.12 0-4.32 1.39-4.32 4v6.29c0 2.64 1.2 4 4.32 4s4.32-1.39 4.32-4v-6.25c0-2.64-1.2-4.04-4.32-4.04zm27.99 18.87V29.75c0-1.25-.53-1.87-1.87-1.87-2.147.252-4.22.932-6.1 2v16.19h-5.86V22.69h4.46l.58 2c2.916-1.46 6.104-2.293 9.36-2.45 1.852-.175 3.616.823 4.42 2.5 2.922-1.495 6.13-2.348 9.41-2.5 3.89 0 5.28 2.74 5.28 6.91v16.92h-5.86V29.75c0-1.25-.53-1.87-1.87-1.87-2.15.234-4.23.915-6.1 2v16.19h-5.85zm41.81 0h-4.8l-.43-1.58c-2.084 1.352-4.516 2.068-7 2.06-4.27 0-6.1-2.93-6.1-7 0-4.75 2.06-6.58 6.82-6.58H177v-2.41c0-2.59-.72-3.5-4.46-3.5-2.18.024-4.35.265-6.48.72l-.72-4.46c2.606-.72 5.296-1.09 8-1.1 7.34 0 9.5 2.59 9.5 8.45l.05 15.4zM177 37.24h-4.32c-1.92 0-2.45.53-2.45 2.3 0 1.77.53 2.35 2.35 2.35 1.55-.02 3.07-.434 4.42-1.2v-3.45zm9.48-6.77c0-5.18 2.3-8.26 7.73-8.26 2.097.02 4.187.244 6.24.67v-9.74l5.86-.82v33.75h-4.66l-.58-2c-2.133 1.595-4.726 2.454-7.39 2.45-4.7 0-7.2-2.79-7.2-8.11v-7.94zm14-2.64c-1.702-.38-3.437-.588-5.18-.62-2.11 0-2.93 1-2.93 3.12v8.26c0 1.92.72 3 2.88 3 1.937-.07 3.787-.816 5.23-2.11V27.83z"/>
</g>
</svg>
`,
txt: () =>
new Array(3000)
.fill(null)
.map((_, i) => {
const date = new Date(2019, 6, 23);
date.setSeconds(i * 5);
return `${date.toISOString()} ${makeSentence(Math.round(Math.random() * 5 + 7))}`;
})
.join('\n'),
json: () =>
JSON.stringify({
key: 'value',
array: [1, 'two', [3]],
deep: {
ly: {
nest: 'ed',
},
},
}),
};
export default Factory.extend({
id: i => i,
isDir: faker.random.boolean,
// Depth is used to recursively create nested directories.
depth: 0,
parent: null,
fileType() {
if (this.isDir) return 'dir';
return pickOne(['svg', 'txt', 'json', 'app', 'exe']);
},
contentType() {
return fileTypeMapping[this.fileType] || null;
},
path() {
if (this.parent) {
return `${this.parent.path}/${this.name}`;
}
return this.name;
},
name() {
return `${faker.hacker.noun().dasherize()}-${pickOne(TROUBLESOME_CHARACTERS)}${
this.isDir ? '' : `.${this.fileType}`
}`;
},
body() {
const strategy = fileBodyMapping[this.fileType];
return strategy ? strategy() : '';
},
size() {
return this.body.length;
},
modTime: () => faker.date.past(2 / 365, REF_TIME),
dir: trait({
isDir: true,
afterCreate(allocFile, server) {
// create files for the directory
if (allocFile.depth > 0) {
server.create('allocFile', 'dir', { parent: allocFile, depth: allocFile.depth - 1 });
}
server.createList('allocFile', faker.random.number({ min: 1, max: 3 }), 'file', {
parent: allocFile,
});
},
}),
file: trait({
isDir: false,
}),
});

View File

@ -0,0 +1,5 @@
import { Model, belongsTo } from 'ember-cli-mirage';
export default Model.extend({
parent: belongsTo('alloc-file'),
});

View File

@ -40,6 +40,8 @@ function smallCluster(server) {
server.createList('agent', 3); server.createList('agent', 3);
server.createList('node', 5); server.createList('node', 5);
server.createList('job', 5); server.createList('job', 5);
server.createList('allocFile', 5);
server.create('allocFile', 'dir', { depth: 2 });
} }
function mediumCluster(server) { function mediumCluster(server) {
@ -141,6 +143,8 @@ function allocationFileExplorer(server) {
jobId: job.id, jobId: job.id,
taskGroup: taskGroup.name, taskGroup: taskGroup.name,
}); });
server.createList('allocFile', 5);
server.create('allocFile', 'dir', { depth: 2 });
} }
// Behaviors // Behaviors

View File

@ -2,15 +2,33 @@ import { currentURL, visit } from '@ember/test-helpers';
import { Promise } from 'rsvp'; import { Promise } from 'rsvp';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
import moment from 'moment';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import Response from 'ember-cli-mirage/response'; 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'; import FS from 'nomad-ui/tests/pages/allocations/task/fs';
let allocation; let allocation;
let task; 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) { module('Acceptance | task fs', function(hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
@ -25,6 +43,24 @@ module('Acceptance | task fs', function(hooks) {
task = server.schema.taskStates.where({ allocationId: allocation.id }).models[0]; task = server.schema.taskStates.where({ allocationId: allocation.id }).models[0];
task.name = 'task-name'; task.name = 'task-name';
task.save(); task.save();
// Reset files
files = [];
// Nested files
files.push(server.create('allocFile', { isDir: true, name: 'directory' }));
files.push(server.create('allocFile', { isDir: true, name: 'another', parent: files[0] }));
files.push(
server.create('allocFile', 'file', {
name: 'something.txt',
fileType: 'txt',
parent: files[1],
})
);
files.push(server.create('allocFile', { isDir: true, name: 'empty-directory' }));
files.push(server.create('allocFile', 'file', { fileType: 'txt' }));
files.push(server.create('allocFile', 'file', { fileType: 'txt' }));
}); });
test('visiting /allocations/:allocation_id/:task_name/fs', async function(assert) { test('visiting /allocations/:allocation_id/:task_name/fs', async function(assert) {
@ -77,6 +113,8 @@ module('Acceptance | task fs', function(hooks) {
test('navigating allocation filesystem', async function(assert) { test('navigating allocation filesystem', async function(assert) {
await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); 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.ok(FS.fileViewer.isHidden);
assert.equal(FS.directoryEntries.length, 4); assert.equal(FS.directoryEntries.length, 4);
@ -88,18 +126,20 @@ module('Acceptance | task fs', function(hooks) {
assert.equal(FS.breadcrumbs[0].text, 'task-name'); assert.equal(FS.breadcrumbs[0].text, 'task-name');
FS.directoryEntries[0].as(directory => { FS.directoryEntries[0].as(directory => {
assert.equal(directory.name, 'directory', 'directories should come first'); const fileRecord = sortedFiles[0];
assert.equal(directory.name, fileRecord.name, 'directories should come first');
assert.ok(directory.isDirectory); assert.ok(directory.isDirectory);
assert.equal(directory.size, '', 'directory sizes are hidden'); 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 shouldnt have redundant separators'); assert.notOk(directory.path.includes('//'), 'paths shouldnt have redundant separators');
}); });
FS.directoryEntries[2].as(file => { FS.directoryEntries[2].as(file => {
assert.equal(file.name, '🤩.txt'); const fileRecord = sortedFiles[2];
assert.equal(file.name, fileRecord.name);
assert.ok(file.isFile); assert.ok(file.isFile);
assert.equal(file.size, '1 KiB'); assert.equal(file.size, formatBytes([fileRecord.size]));
assert.equal(file.lastModified, '2 days ago'); assert.equal(file.lastModified, moment(fileRecord.modTime).fromNow());
}); });
await FS.directoryEntries[0].visit(); await FS.directoryEntries[0].visit();
@ -107,11 +147,11 @@ module('Acceptance | task fs', function(hooks) {
assert.equal(FS.directoryEntries.length, 1); assert.equal(FS.directoryEntries.length, 1);
assert.equal(FS.breadcrumbs.length, 2); 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.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); assert.ok(FS.breadcrumbs[1].isActive);
await FS.directoryEntries[0].visit(); await FS.directoryEntries[0].visit();
@ -123,8 +163,8 @@ module('Acceptance | task fs', function(hooks) {
); );
assert.equal(FS.breadcrumbs.length, 3); assert.equal(FS.breadcrumbs.length, 3);
assert.equal(FS.breadcrumbsText, 'task-name directory another'); assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name} ${files[1].name}`);
assert.equal(FS.breadcrumbs[2].text, 'another'); assert.equal(FS.breadcrumbs[2].text, files[1].name);
assert.notOk( assert.notOk(
FS.breadcrumbs[0].path.includes('//'), FS.breadcrumbs[0].path.includes('//'),
@ -136,7 +176,7 @@ module('Acceptance | task fs', function(hooks) {
); );
await FS.breadcrumbs[1].visit(); 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); assert.equal(FS.breadcrumbs.length, 2);
}); });
@ -266,12 +306,39 @@ module('Acceptance | task fs', function(hooks) {
}); });
test('viewing a file', async function(assert) { test('viewing a file', async function(assert) {
await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); const node = server.db.nodes.find(allocation.nodeId);
await FS.directoryEntries[2].visit();
assert.equal(FS.breadcrumbsText, 'task-name 🤩.txt'); 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);
const fileRecord = sortedFiles.find(f => !f.isDir);
const fileIndex = sortedFiles.indexOf(fileRecord);
await FS.directoryEntries[fileIndex].visit();
assert.equal(FS.breadcrumbsText, `${task.name} ${fileRecord.name}`);
assert.ok(FS.fileViewer.isPresent); 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) { test('viewing an empty directory', async function(assert) {
@ -304,7 +371,7 @@ module('Acceptance | task fs', function(hooks) {
return new Response(500, {}, 'no such file or directory'); 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'); assert.equal(FS.error.title, 'Not Found', '500 is interpreted as 404');
await visit('/'); await visit('/');
@ -313,7 +380,7 @@ module('Acceptance | task fs', function(hooks) {
return new Response(999); 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'); assert.equal(FS.error.title, 'Error', 'other statuses are passed through');
}); });
}); });

View File

@ -0,0 +1,106 @@
import { find, settled } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import RSVP from 'rsvp';
import { formatBytes } from 'nomad-ui/helpers/format-bytes';
module('Integration | Component | image file', function(hooks) {
setupRenderingTest(hooks);
const commonTemplate = hbs`
{{image-file src=src alt=alt size=size}}
`;
const commonProperties = {
src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
alt: 'This is the alt text',
size: 123456,
};
test('component displays the image', async function(assert) {
this.setProperties(commonProperties);
await this.render(commonTemplate);
assert.ok(find('img'), 'Image is in the DOM');
assert.equal(
find('img').getAttribute('src'),
commonProperties.src,
`src is ${commonProperties.src}`
);
});
test('the image is wrapped in an anchor that links directly to the image', async function(assert) {
this.setProperties(commonProperties);
await this.render(commonTemplate);
assert.ok(find('a'), 'Anchor');
assert.ok(find('a > img'), 'Image in anchor');
assert.equal(
find('a').getAttribute('href'),
commonProperties.src,
`href is ${commonProperties.src}`
);
assert.equal(find('a').getAttribute('target'), '_blank', 'Anchor opens to a new tab');
assert.equal(
find('a').getAttribute('rel'),
'noopener noreferrer',
'Anchor rel correctly bars openers and referrers'
);
});
test('component updates image meta when the image loads', async function(assert) {
const { spy, wrapper, notifier } = notifyingSpy();
this.setProperties(commonProperties);
this.set('spy', wrapper);
this.render(hbs`
{{image-file src=src alt=alt size=size updateImageMeta=spy}}
`);
await notifier;
assert.ok(spy.calledOnce);
});
test('component shows the width, height, and size of the image', async function(assert) {
this.setProperties(commonProperties);
await this.render(commonTemplate);
await settled();
const statsEl = find('[data-test-file-stats]');
assert.ok(
/\d+px \u00d7 \d+px/.test(statsEl.textContent),
'Width and height are formatted correctly'
);
assert.ok(
statsEl.textContent.trim().endsWith(formatBytes([commonProperties.size]) + ')'),
'Human-formatted size is included'
);
});
});
function notifyingSpy() {
// The notifier must resolve when the spy wrapper is called
let dispatch;
const notifier = new RSVP.Promise(resolve => {
dispatch = resolve;
});
const spy = sinon.spy();
// The spy wrapper must call the spy, passing all arguments through, and it must
// call dispatch in order to resolve the promise.
const wrapper = (...args) => {
spy(...args);
dispatch();
};
// All three pieces are required to wire up a component, pause test execution, and
// write assertions.
return { spy, wrapper, notifier };
}

View File

@ -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');
});
});

View File

@ -0,0 +1,228 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled } from '@ember/test-helpers';
import { find } from 'ember-native-dom-helpers';
import hbs from 'htmlbars-inline-precompile';
import Pretender from 'pretender';
import { logEncode } from '../../mirage/data/logs';
const { assign } = Object;
const HOST = '1.1.1.1:1111';
module('Integration | Component | task file', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.server = new Pretender(function() {
this.get('/v1/regions', () => [200, {}, JSON.stringify(['default'])]);
this.get('/v1/client/fs/stream/:alloc_id', () => [200, {}, logEncode(['Hello World'], 0)]);
this.get('/v1/client/fs/cat/:alloc_id', () => [200, {}, 'Hello World']);
this.get('/v1/client/fs/readat/:alloc_id', () => [200, {}, 'Hello World']);
});
});
hooks.afterEach(function() {
this.server.shutdown();
});
const commonTemplate = hbs`
{{task-file allocation=allocation task=task file=file stat=stat}}
`;
const fileStat = (type, size = 0) => ({
stat: {
Size: size,
ContentType: type,
},
});
const makeProps = (props = {}) =>
assign(
{},
{
allocation: {
id: 'alloc-1',
node: {
httpAddr: HOST,
},
},
task: {
name: 'task-name',
},
file: 'path/to/file',
stat: {
Size: 12345,
ContentType: 'text/plain',
},
},
props
);
test('When a file is text-based, the file mode is streaming', async function(assert) {
const props = makeProps(fileStat('text/plain', 500));
this.setProperties(props);
await render(commonTemplate);
assert.ok(
find('[data-test-file-box] [data-test-log-cli]'),
'The streaming file component was rendered'
);
assert.notOk(
find('[data-test-file-box] [data-test-image-file]'),
'The image file component was not rendered'
);
});
test('When a file is an image, the file mode is image', async function(assert) {
const props = makeProps(fileStat('image/png', 1234));
this.setProperties(props);
await render(commonTemplate);
assert.ok(
find('[data-test-file-box] [data-test-image-file]'),
'The image file component was rendered'
);
assert.notOk(
find('[data-test-file-box] [data-test-log-cli]'),
'The streaming file component was not rendered'
);
});
test('When the file is neither text-based or an image, the unsupported file type empty state is shown', async function(assert) {
const props = makeProps(fileStat('wat/ohno', 1234));
this.setProperties(props);
await render(commonTemplate);
assert.notOk(
find('[data-test-file-box] [data-test-image-file]'),
'The image file component was not rendered'
);
assert.notOk(
find('[data-test-file-box] [data-test-log-cli]'),
'The streaming file component was not rendered'
);
assert.ok(find('[data-test-unsupported-type]'), 'Unsupported file type message is shown');
});
test('The unsupported file type empty state includes a link to the raw file', async function(assert) {
const props = makeProps(fileStat('wat/ohno', 1234));
this.setProperties(props);
await render(commonTemplate);
assert.ok(
find('[data-test-unsupported-type] [data-test-log-action="raw"]'),
'Unsupported file type message includes a link to the raw file'
);
assert.notOk(
find('[data-test-header] [data-test-log-action="raw"]'),
'Raw link is no longer in the header'
);
});
test('The view raw button goes to the correct API url', async function(assert) {
const props = makeProps(fileStat('image/png', 1234));
this.setProperties(props);
await render(commonTemplate);
const rawLink = find('[data-test-log-action="raw"]');
assert.equal(
rawLink.getAttribute('href'),
`/v1/client/fs/cat/${props.allocation.id}?path=${encodeURIComponent(
`${props.task.name}/${props.file}`
)}`,
'Raw link href is correct'
);
assert.equal(rawLink.getAttribute('target'), '_blank', 'Raw link opens in a new tab');
assert.equal(
rawLink.getAttribute('rel'),
'noopener noreferrer',
'Raw link rel correctly bars openers and referrers'
);
});
test('The head and tail buttons are not shown when the file is small', async function(assert) {
const props = makeProps(fileStat('application/json', 5000));
this.setProperties(props);
await render(commonTemplate);
assert.notOk(find('[data-test-log-action="head"]'), 'No head button');
assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button');
this.set('stat.Size', 100000);
await settled();
assert.ok(find('[data-test-log-action="head"]'), 'Head button is shown');
assert.ok(find('[data-test-log-action="tail"]'), 'Tail button is shown');
});
test('The head and tail buttons are not shown for an image file', async function(assert) {
const props = makeProps(fileStat('image/svg', 5000));
this.setProperties(props);
await render(commonTemplate);
assert.notOk(find('[data-test-log-action="head"]'), 'No head button');
assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button');
this.set('stat.Size', 100000);
await settled();
assert.notOk(find('[data-test-log-action="head"]'), 'Still no head button');
assert.notOk(find('[data-test-log-action="tail"]'), 'Still no tail button');
});
test('Yielded content goes in the top-left header area', async function(assert) {
const props = makeProps(fileStat('image/svg', 5000));
this.setProperties(props);
await render(hbs`
{{#task-file allocation=allocation task=task file=file stat=stat}}
<div data-test-yield-spy>Yielded content</div>
{{/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');
});
});

View File

@ -21,24 +21,23 @@ const commonProps = {
serverTimeout: allowedConnectionTime, serverTimeout: allowedConnectionTime,
}; };
const logHead = ['HEAD']; const logHead = [logEncode(['HEAD'], 0)];
const logTail = ['TAIL']; const logTail = [logEncode(['TAIL'], 0)];
const streamFrames = ['one\n', 'two\n', 'three\n', 'four\n', 'five\n']; const streamFrames = ['one\n', 'two\n', 'three\n', 'four\n', 'five\n'];
let streamPointer = 0; let streamPointer = 0;
let logMode = null;
module('Integration | Component | task log', function(hooks) { module('Integration | Component | task log', function(hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
hooks.beforeEach(function() { hooks.beforeEach(function() {
const handler = ({ queryParams }) => { const handler = ({ queryParams }) => {
const { origin, offset, plain, follow } = queryParams;
let frames; let frames;
let data; let data;
if (origin === 'start' && offset === '0' && plain && !follow) { if (logMode === 'head') {
frames = logHead; frames = logHead;
} else if (origin === 'end' && plain && !follow) { } else if (logMode === 'tail') {
frames = logTail; frames = logTail;
} else { } else {
frames = streamFrames; frames = streamFrames;
@ -64,6 +63,7 @@ module('Integration | Component | task log', function(hooks) {
hooks.afterEach(function() { hooks.afterEach(function() {
this.server.shutdown(); this.server.shutdown();
streamPointer = 0; streamPointer = 0;
logMode = null;
}); });
test('Basic appearance', async function(assert) { 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) { test('Clicking Head loads the log head', async function(assert) {
logMode = 'head';
run.later(run, run.cancelTimers, commonProps.interval); run.later(run, run.cancelTimers, commonProps.interval);
this.setProperties(commonProps); this.setProperties(commonProps);
@ -117,7 +118,7 @@ module('Integration | Component | task log', function(hooks) {
await settled(); await settled();
assert.ok( assert.ok(
this.server.handledRequests.find( 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' '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) { test('Clicking Tail loads the log tail', async function(assert) {
logMode = 'tail';
run.later(run, run.cancelTimers, commonProps.interval); run.later(run, run.cancelTimers, commonProps.interval);
this.setProperties(commonProps); this.setProperties(commonProps);
@ -134,9 +136,7 @@ module('Integration | Component | task log', function(hooks) {
await settled(); await settled();
assert.ok( assert.ok(
this.server.handledRequests.find( this.server.handledRequests.find(({ queryParams: qp }) => qp.origin === 'end'),
({ queryParams: qp }) => qp.origin === 'end' && qp.plain === 'true'
),
'Log tail request was made' 'Log tail request was made'
); );
assert.equal(find('[data-test-log-cli]').textContent, logTail[0], 'Tail of the log is shown'); assert.equal(find('[data-test-log-cli]').textContent, logTail[0], 'Tail of the log is shown');

View File

@ -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-1-2', nodeId: 'node-1' });
this.server.create('allocation', { id: 'node-2-1', nodeId: 'node-2' }); this.server.create('allocation', { id: 'node-2-1', nodeId: 'node-2' });
this.server.create('allocation', { id: 'node-2-2', nodeId: 'node-2' }); this.server.create('allocation', { id: 'node-2-2', nodeId: 'node-2' });
this.server.logging = true;
}); });
hooks.afterEach(function() { hooks.afterEach(function() {

View File

@ -76,7 +76,7 @@ module('Unit | Util | Log', function(hooks) {
test('gotoHead builds the correct URL', async function(assert) { test('gotoHead builds the correct URL', async function(assert) {
const mocks = makeMocks(''); 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); const log = Log.create(mocks);
run(() => { run(() => {
@ -89,10 +89,11 @@ module('Unit | Util | Log', function(hooks) {
const longLog = Array(50001) const longLog = Array(50001)
.fill('a') .fill('a')
.join(''); .join('');
const encodedLongLog = `{"Offset":0,"Data":"${window.btoa(longLog)}"}`;
const truncationMessage = const truncationMessage =
'\n\n---------- TRUNCATED: Click "tail" to view the bottom of the log ----------'; '\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); const log = Log.create(mocks);
run(() => { run(() => {
@ -100,7 +101,13 @@ module('Unit | Util | Log', function(hooks) {
}); });
await settled(); 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( assert.equal(
log.get('output').toString().length, log.get('output').toString().length,
50000 + truncationMessage.length, 50000 + truncationMessage.length,
@ -110,7 +117,7 @@ module('Unit | Util | Log', function(hooks) {
test('gotoTail builds the correct URL', async function(assert) { test('gotoTail builds the correct URL', async function(assert) {
const mocks = makeMocks(''); 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); const log = Log.create(mocks);
run(() => { run(() => {

View File

@ -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);
});
});
});