Merge pull request #6048 from hashicorp/f-ui/alloc-fs-files
UI: Alloc FS: File Viewer
This commit is contained in:
commit
fd6d5b274f
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,14 +1,11 @@
|
|||
import { inject as service } from '@ember/service';
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
import { run } from '@ember/runloop';
|
||||
import RSVP from 'rsvp';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { logger } from 'nomad-ui/utils/classes/log';
|
||||
import WindowResizable from 'nomad-ui/mixins/window-resizable';
|
||||
import timeout from 'nomad-ui/utils/timeout';
|
||||
|
||||
export default Component.extend(WindowResizable, {
|
||||
export default Component.extend({
|
||||
token: service(),
|
||||
|
||||
classNames: ['boxed-section', 'task-log'],
|
||||
|
@ -25,26 +22,8 @@ export default Component.extend(WindowResizable, {
|
|||
clientTimeout: 1000,
|
||||
serverTimeout: 5000,
|
||||
|
||||
didReceiveAttrs() {
|
||||
if (this.allocation && this.task) {
|
||||
this.send('toggleStream');
|
||||
}
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this.fillAvailableHeight();
|
||||
},
|
||||
|
||||
windowResizeHandler() {
|
||||
run.once(this, this.fillAvailableHeight);
|
||||
},
|
||||
|
||||
fillAvailableHeight() {
|
||||
// This math is arbitrary and far from bulletproof, but the UX
|
||||
// of having the log window fill available height is worth the hack.
|
||||
const cliWindow = this.$('.cli-window');
|
||||
cliWindow.height(window.innerHeight - cliWindow.offset().top - 25);
|
||||
},
|
||||
isStreaming: true,
|
||||
streamMode: 'streaming',
|
||||
|
||||
mode: 'stdout',
|
||||
|
||||
|
@ -75,56 +54,28 @@ export default Component.extend(WindowResizable, {
|
|||
this.set('noConnection', true);
|
||||
} else {
|
||||
this.send('failoverToServer');
|
||||
this.stream.perform();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}),
|
||||
|
||||
head: task(function*() {
|
||||
yield this.get('logger.gotoHead').perform();
|
||||
run.scheduleOnce('afterRender', () => {
|
||||
this.$('.cli-window').scrollTop(0);
|
||||
});
|
||||
}),
|
||||
|
||||
tail: task(function*() {
|
||||
yield this.get('logger.gotoTail').perform();
|
||||
run.scheduleOnce('afterRender', () => {
|
||||
const cliWindow = this.$('.cli-window');
|
||||
cliWindow.scrollTop(cliWindow[0].scrollHeight);
|
||||
});
|
||||
}),
|
||||
|
||||
stream: task(function*() {
|
||||
this.logger.on('tick', () => {
|
||||
run.scheduleOnce('afterRender', () => {
|
||||
const cliWindow = this.$('.cli-window');
|
||||
cliWindow.scrollTop(cliWindow[0].scrollHeight);
|
||||
});
|
||||
});
|
||||
|
||||
yield this.logger.startStreaming();
|
||||
this.logger.off('tick');
|
||||
}),
|
||||
|
||||
willDestroy() {
|
||||
this.logger.stop();
|
||||
},
|
||||
|
||||
actions: {
|
||||
setMode(mode) {
|
||||
this.logger.stop();
|
||||
this.set('mode', mode);
|
||||
this.stream.perform();
|
||||
},
|
||||
toggleStream() {
|
||||
if (this.get('logger.isStreaming')) {
|
||||
this.logger.stop();
|
||||
} else {
|
||||
this.stream.perform();
|
||||
}
|
||||
this.set('streamMode', 'streaming');
|
||||
this.toggleProperty('isStreaming');
|
||||
},
|
||||
gotoHead() {
|
||||
this.set('streamMode', 'head');
|
||||
this.set('isStreaming', false);
|
||||
},
|
||||
gotoTail() {
|
||||
this.set('streamMode', 'tail');
|
||||
this.set('isStreaming', false);
|
||||
},
|
||||
failoverToServer() {
|
||||
this.set('useServer', true);
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { computed } from '@ember/object';
|
||||
import { filterBy } from '@ember/object/computed';
|
||||
import { isEmpty } from '@ember/utils';
|
||||
|
||||
export default Controller.extend({
|
||||
queryParams: {
|
||||
|
@ -16,6 +15,7 @@ export default Controller.extend({
|
|||
task: null,
|
||||
directoryEntries: null,
|
||||
isFile: null,
|
||||
stat: null,
|
||||
|
||||
directories: filterBy('directoryEntries', 'IsDir'),
|
||||
files: filterBy('directoryEntries', 'IsDir', false),
|
||||
|
@ -51,33 +51,4 @@ export default Controller.extend({
|
|||
}
|
||||
}
|
||||
),
|
||||
|
||||
breadcrumbs: computed('path', function() {
|
||||
const breadcrumbs = this.path
|
||||
.split('/')
|
||||
.reject(isEmpty)
|
||||
.reduce((breadcrumbs, pathSegment, index) => {
|
||||
let breadcrumbPath;
|
||||
|
||||
if (index > 0) {
|
||||
const lastBreadcrumb = breadcrumbs[index - 1];
|
||||
breadcrumbPath = `${lastBreadcrumb.path}/${pathSegment}`;
|
||||
} else {
|
||||
breadcrumbPath = pathSegment;
|
||||
}
|
||||
|
||||
breadcrumbs.push({
|
||||
name: pathSegment,
|
||||
path: breadcrumbPath,
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
}, []);
|
||||
|
||||
if (breadcrumbs.length) {
|
||||
breadcrumbs[breadcrumbs.length - 1].isLast = true;
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -9,9 +9,8 @@ export default Route.extend({
|
|||
|
||||
const pathWithTaskName = `${task.name}${decodedPath.startsWith('/') ? '' : '/'}${decodedPath}`;
|
||||
|
||||
return task
|
||||
.stat(pathWithTaskName)
|
||||
.then(statJson => {
|
||||
return RSVP.all([task.stat(pathWithTaskName), task.get('allocation.node')])
|
||||
.then(([statJson]) => {
|
||||
if (statJson.IsDir) {
|
||||
return RSVP.hash({
|
||||
path: decodedPath,
|
||||
|
@ -24,14 +23,15 @@ export default Route.extend({
|
|||
path: decodedPath,
|
||||
task,
|
||||
isFile: true,
|
||||
stat: statJson,
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch(notifyError(this));
|
||||
},
|
||||
|
||||
setupController(controller, { path, task, directoryEntries, isFile } = {}) {
|
||||
setupController(controller, { path, task, directoryEntries, isFile, stat } = {}) {
|
||||
this._super(...arguments);
|
||||
controller.setProperties({ path, task, directoryEntries, isFile });
|
||||
controller.setProperties({ path, task, directoryEntries, isFile, stat });
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -18,4 +18,8 @@
|
|||
color: $grey;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-hollow {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,32 +1,16 @@
|
|||
{{title pathWithLeadingSlash " - Task " task.name " filesystem"}}
|
||||
{{task-subnav task=task}}
|
||||
<section class="section is-closer">
|
||||
<section class="section is-closer {{if isFile "full-width-section"}}">
|
||||
{{#if task.isRunning}}
|
||||
<div class="fs-explorer boxed-section">
|
||||
<div class="boxed-section-head is-compact">
|
||||
<nav class="breadcrumb" data-test-fs-breadcrumbs>
|
||||
<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>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{#if isFile}}
|
||||
<div class="boxed-section-body">
|
||||
<div data-test-file-viewer>placeholder file viewer</div>
|
||||
{{#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}}
|
||||
<div class="fs-explorer boxed-section">
|
||||
<div class="boxed-section-head">
|
||||
{{fs-breadcrumbs task=task path=path}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{#list-table
|
||||
source=sortedDirectoryEntries
|
||||
sortProperty=sortProperty
|
||||
|
@ -52,8 +36,8 @@
|
|||
</tbody>
|
||||
{{/if}}
|
||||
{{/list-table}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div data-test-not-running class="empty-message">
|
||||
<h3 data-test-not-running-headline class="empty-message-headline">Task is not Running</h3>
|
||||
|
|
|
@ -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>
|
|
@ -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 × {{height}}px{{#if size}}, {{format-bytes size}}{{/if}})</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
</figcaption>
|
|
@ -0,0 +1 @@
|
|||
<code data-test-output>{{logger.output}}</code>
|
|
@ -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>
|
|
@ -10,13 +10,13 @@
|
|||
<button data-test-log-action="stderr" class="button {{if (eq mode "stderr") "is-danger"}}" {{action "setMode" "stderr"}}>stderr</button>
|
||||
</span>
|
||||
<span class="pull-right">
|
||||
<button data-test-log-action="head" class="button is-white" onclick={{perform head}}>Head</button>
|
||||
<button data-test-log-action="tail" class="button is-white" onclick={{perform tail}}>Tail</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={{action "gotoTail"}}>Tail</button>
|
||||
<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"}}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
@ -8,6 +8,7 @@ import queryString from 'query-string';
|
|||
import { task } from 'ember-concurrency';
|
||||
import StreamLogger from 'nomad-ui/utils/classes/stream-logger';
|
||||
import PollLogger from 'nomad-ui/utils/classes/poll-logger';
|
||||
import { decode } from 'nomad-ui/utils/stream-frames';
|
||||
import Anser from 'anser';
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 50000;
|
||||
|
@ -20,6 +21,7 @@ const Log = EmberObject.extend(Evented, {
|
|||
|
||||
url: '',
|
||||
params: computed(() => ({})),
|
||||
plainText: false,
|
||||
logFetch() {
|
||||
assert('Log objects need a logFetch method, which should have an interface like window.fetch');
|
||||
},
|
||||
|
@ -40,6 +42,7 @@ const Log = EmberObject.extend(Evented, {
|
|||
// the logPointer is pointed at head or tail
|
||||
output: computed('logPointer', 'head', 'tail', function() {
|
||||
let logs = this.logPointer === 'head' ? this.head : this.tail;
|
||||
logs = logs.replace(/</g, '<').replace(/>/g, '>');
|
||||
let colouredLogs = Anser.ansiToHtml(logs);
|
||||
return htmlSafe(colouredLogs);
|
||||
}),
|
||||
|
@ -72,16 +75,19 @@ const Log = EmberObject.extend(Evented, {
|
|||
gotoHead: task(function*() {
|
||||
const logFetch = this.logFetch;
|
||||
const queryParams = queryString.stringify(
|
||||
assign(this.params, {
|
||||
plain: true,
|
||||
origin: 'start',
|
||||
offset: 0,
|
||||
})
|
||||
assign(
|
||||
{
|
||||
origin: 'start',
|
||||
offset: 0,
|
||||
},
|
||||
this.params
|
||||
)
|
||||
);
|
||||
const url = `${this.url}?${queryParams}`;
|
||||
|
||||
this.stop();
|
||||
let text = yield logFetch(url).then(res => res.text(), fetchFailure(url));
|
||||
const response = yield logFetch(url).then(res => res.text(), fetchFailure(url));
|
||||
let text = this.plainText ? response : decode(response).message;
|
||||
|
||||
if (text && text.length > MAX_OUTPUT_LENGTH) {
|
||||
text = text.substr(0, MAX_OUTPUT_LENGTH);
|
||||
|
@ -94,16 +100,19 @@ const Log = EmberObject.extend(Evented, {
|
|||
gotoTail: task(function*() {
|
||||
const logFetch = this.logFetch;
|
||||
const queryParams = queryString.stringify(
|
||||
assign(this.params, {
|
||||
plain: true,
|
||||
origin: 'end',
|
||||
offset: MAX_OUTPUT_LENGTH,
|
||||
})
|
||||
assign(
|
||||
{
|
||||
origin: 'end',
|
||||
offset: MAX_OUTPUT_LENGTH,
|
||||
},
|
||||
this.params
|
||||
)
|
||||
);
|
||||
const url = `${this.url}?${queryParams}`;
|
||||
|
||||
this.stop();
|
||||
let text = yield logFetch(url).then(res => res.text(), fetchFailure(url));
|
||||
const response = yield logFetch(url).then(res => res.text(), fetchFailure(url));
|
||||
let text = this.plainText ? response : decode(response).message;
|
||||
|
||||
this.set('tail', text);
|
||||
this.set('logPointer', 'tail');
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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 {};
|
||||
}
|
|
@ -5,13 +5,21 @@ import { logFrames, logEncode } from './data/logs';
|
|||
import { generateDiff } from './factories/job-version';
|
||||
import { generateTaskGroupFailures } from './factories/evaluation';
|
||||
import { copy } from 'ember-copy';
|
||||
import moment from 'moment';
|
||||
|
||||
export function findLeader(schema) {
|
||||
const agent = schema.agents.first();
|
||||
return `${agent.address}:${agent.tags.port}`;
|
||||
}
|
||||
|
||||
export function filesForPath(allocFiles, filterPath) {
|
||||
return allocFiles.where(
|
||||
file =>
|
||||
(!filterPath || file.path.startsWith(filterPath)) &&
|
||||
file.path.length > filterPath.length &&
|
||||
!file.path.substr(filterPath.length + 1).includes('/')
|
||||
);
|
||||
}
|
||||
|
||||
export default function() {
|
||||
this.timing = 0; // delay for each request, automatically set to 0 during testing
|
||||
|
||||
|
@ -305,63 +313,68 @@ export default function() {
|
|||
return logEncode(logFrames, logFrames.length - 1);
|
||||
};
|
||||
|
||||
const clientAllocationFSLsHandler = function(schema, { queryParams }) {
|
||||
if (queryParams.path.endsWith('empty-directory')) {
|
||||
return [];
|
||||
} else if (queryParams.path.endsWith('directory')) {
|
||||
return [
|
||||
{
|
||||
Name: 'another',
|
||||
IsDir: true,
|
||||
ModTime: moment().format(),
|
||||
},
|
||||
];
|
||||
} else if (queryParams.path.endsWith('another')) {
|
||||
return [
|
||||
{
|
||||
Name: 'something.txt',
|
||||
IsDir: false,
|
||||
ModTime: moment().format(),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
Name: '🤩.txt',
|
||||
IsDir: false,
|
||||
Size: 1919,
|
||||
ModTime: moment()
|
||||
.subtract(2, 'day')
|
||||
.format(),
|
||||
},
|
||||
{
|
||||
Name: '🙌🏿.txt',
|
||||
IsDir: false,
|
||||
ModTime: moment()
|
||||
.subtract(2, 'minute')
|
||||
.format(),
|
||||
},
|
||||
{
|
||||
Name: 'directory',
|
||||
IsDir: true,
|
||||
Size: 3682561,
|
||||
ModTime: moment()
|
||||
.subtract(1, 'year')
|
||||
.format(),
|
||||
},
|
||||
{
|
||||
Name: 'empty-directory',
|
||||
IsDir: true,
|
||||
ModTime: moment().format(),
|
||||
},
|
||||
];
|
||||
}
|
||||
const clientAllocationFSLsHandler = function({ allocFiles }, { queryParams }) {
|
||||
// Ignore the task name at the beginning of the path
|
||||
const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1);
|
||||
const files = filesForPath(allocFiles, filterPath);
|
||||
return this.serialize(files);
|
||||
};
|
||||
|
||||
const clientAllocationFSStatHandler = function(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);
|
||||
};
|
||||
|
||||
const clientAllocationCatHandler = function({ allocFiles }, { queryParams }) {
|
||||
const [file, err] = fileOrError(allocFiles, queryParams.path);
|
||||
|
||||
if (err) return err;
|
||||
return file.body;
|
||||
};
|
||||
|
||||
const clientAllocationStreamHandler = function({ allocFiles }, { queryParams }) {
|
||||
const [file, err] = fileOrError(allocFiles, queryParams.path);
|
||||
|
||||
if (err) return err;
|
||||
|
||||
// Pretender, and therefore Mirage, doesn't support streaming responses.
|
||||
return file.body;
|
||||
};
|
||||
|
||||
const clientAllocationReadAtHandler = function({ allocFiles }, { queryParams }) {
|
||||
const [file, err] = fileOrError(allocFiles, queryParams.path);
|
||||
|
||||
if (err) return err;
|
||||
return file.body.substr(queryParams.offset || 0, queryParams.limit);
|
||||
};
|
||||
|
||||
const fileOrError = function(allocFiles, path, message = 'Operation not allowed on a directory') {
|
||||
// Ignore the task name at the beginning of the path
|
||||
const filterPath = path.substr(path.indexOf('/') + 1);
|
||||
|
||||
// Root path
|
||||
if (!filterPath) {
|
||||
return [null, new Response(400, {}, message)];
|
||||
}
|
||||
|
||||
const file = allocFiles.where({ path: filterPath }).models[0];
|
||||
if (file.isDir) {
|
||||
return [null, new Response(400, {}, message)];
|
||||
}
|
||||
|
||||
return [file, null];
|
||||
};
|
||||
|
||||
// Client requests are available on the server and the client
|
||||
|
@ -374,6 +387,9 @@ export default function() {
|
|||
|
||||
this.get('/client/fs/ls/:allocation_id', clientAllocationFSLsHandler);
|
||||
this.get('/client/fs/stat/:allocation_id', clientAllocationFSStatHandler);
|
||||
this.get('/client/fs/cat/:allocation_id', clientAllocationCatHandler);
|
||||
this.get('/client/fs/stream/:allocation_id', clientAllocationStreamHandler);
|
||||
this.get('/client/fs/readat/:allocation_id', clientAllocationReadAtHandler);
|
||||
|
||||
this.get('/client/stats', function({ clientStats }, { queryParams }) {
|
||||
const seed = Math.random();
|
||||
|
@ -397,6 +413,9 @@ export default function() {
|
|||
|
||||
this.get(`http://${host}/v1/client/fs/ls/:allocation_id`, clientAllocationFSLsHandler);
|
||||
this.get(`http://${host}/v1/client/stat/ls/:allocation_id`, clientAllocationFSStatHandler);
|
||||
this.get(`http://${host}/v1/client/fs/cat/:allocation_id`, clientAllocationCatHandler);
|
||||
this.get(`http://${host}/v1/client/fs/stream/:allocation_id`, clientAllocationStreamHandler);
|
||||
this.get(`http://${host}/v1/client/fs/readat/:allocation_id`, clientAllocationReadAtHandler);
|
||||
|
||||
this.get(`http://${host}/v1/client/stats`, function({ clientStats }) {
|
||||
return this.serialize(clientStats.find(host));
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
import { Model, belongsTo } from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
parent: belongsTo('alloc-file'),
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -2,15 +2,33 @@ import { currentURL, visit } from '@ember/test-helpers';
|
|||
import { Promise } from 'rsvp';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import moment from 'moment';
|
||||
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import Response from 'ember-cli-mirage/response';
|
||||
import moment from 'moment';
|
||||
|
||||
import { formatBytes } from 'nomad-ui/helpers/format-bytes';
|
||||
import { filesForPath } from 'nomad-ui/mirage/config';
|
||||
|
||||
import FS from 'nomad-ui/tests/pages/allocations/task/fs';
|
||||
|
||||
let allocation;
|
||||
let task;
|
||||
let files;
|
||||
|
||||
const fileSort = (prop, files) => {
|
||||
let dir = [];
|
||||
let file = [];
|
||||
files.forEach(f => {
|
||||
if (f.isDir) {
|
||||
dir.push(f);
|
||||
} else {
|
||||
file.push(f);
|
||||
}
|
||||
});
|
||||
|
||||
return dir.sortBy(prop).concat(file.sortBy(prop));
|
||||
};
|
||||
|
||||
module('Acceptance | task fs', function(hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
@ -25,6 +43,24 @@ module('Acceptance | task fs', function(hooks) {
|
|||
task = server.schema.taskStates.where({ allocationId: allocation.id }).models[0];
|
||||
task.name = 'task-name';
|
||||
task.save();
|
||||
|
||||
// Reset files
|
||||
files = [];
|
||||
|
||||
// Nested files
|
||||
files.push(server.create('allocFile', { isDir: true, name: 'directory' }));
|
||||
files.push(server.create('allocFile', { isDir: true, name: 'another', parent: files[0] }));
|
||||
files.push(
|
||||
server.create('allocFile', 'file', {
|
||||
name: 'something.txt',
|
||||
fileType: 'txt',
|
||||
parent: files[1],
|
||||
})
|
||||
);
|
||||
|
||||
files.push(server.create('allocFile', { isDir: true, name: 'empty-directory' }));
|
||||
files.push(server.create('allocFile', 'file', { fileType: 'txt' }));
|
||||
files.push(server.create('allocFile', 'file', { fileType: 'txt' }));
|
||||
});
|
||||
|
||||
test('visiting /allocations/:allocation_id/:task_name/fs', async function(assert) {
|
||||
|
@ -77,6 +113,8 @@ module('Acceptance | task fs', function(hooks) {
|
|||
test('navigating allocation filesystem', async function(assert) {
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/' });
|
||||
|
||||
const sortedFiles = fileSort('name', filesForPath(this.server.schema.allocFiles, '').models);
|
||||
|
||||
assert.ok(FS.fileViewer.isHidden);
|
||||
|
||||
assert.equal(FS.directoryEntries.length, 4);
|
||||
|
@ -88,18 +126,20 @@ module('Acceptance | task fs', function(hooks) {
|
|||
assert.equal(FS.breadcrumbs[0].text, 'task-name');
|
||||
|
||||
FS.directoryEntries[0].as(directory => {
|
||||
assert.equal(directory.name, 'directory', 'directories should come first');
|
||||
const fileRecord = sortedFiles[0];
|
||||
assert.equal(directory.name, fileRecord.name, 'directories should come first');
|
||||
assert.ok(directory.isDirectory);
|
||||
assert.equal(directory.size, '', 'directory sizes are hidden');
|
||||
assert.equal(directory.lastModified, 'a year ago');
|
||||
assert.equal(directory.lastModified, moment(fileRecord.modTime).fromNow());
|
||||
assert.notOk(directory.path.includes('//'), 'paths shouldn’t have redundant separators');
|
||||
});
|
||||
|
||||
FS.directoryEntries[2].as(file => {
|
||||
assert.equal(file.name, '🤩.txt');
|
||||
const fileRecord = sortedFiles[2];
|
||||
assert.equal(file.name, fileRecord.name);
|
||||
assert.ok(file.isFile);
|
||||
assert.equal(file.size, '1 KiB');
|
||||
assert.equal(file.lastModified, '2 days ago');
|
||||
assert.equal(file.size, formatBytes([fileRecord.size]));
|
||||
assert.equal(file.lastModified, moment(fileRecord.modTime).fromNow());
|
||||
});
|
||||
|
||||
await FS.directoryEntries[0].visit();
|
||||
|
@ -107,11 +147,11 @@ module('Acceptance | task fs', function(hooks) {
|
|||
assert.equal(FS.directoryEntries.length, 1);
|
||||
|
||||
assert.equal(FS.breadcrumbs.length, 2);
|
||||
assert.equal(FS.breadcrumbsText, 'task-name directory');
|
||||
assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name}`);
|
||||
|
||||
assert.notOk(FS.breadcrumbs[0].isActive);
|
||||
|
||||
assert.equal(FS.breadcrumbs[1].text, 'directory');
|
||||
assert.equal(FS.breadcrumbs[1].text, files[0].name);
|
||||
assert.ok(FS.breadcrumbs[1].isActive);
|
||||
|
||||
await FS.directoryEntries[0].visit();
|
||||
|
@ -123,8 +163,8 @@ module('Acceptance | task fs', function(hooks) {
|
|||
);
|
||||
|
||||
assert.equal(FS.breadcrumbs.length, 3);
|
||||
assert.equal(FS.breadcrumbsText, 'task-name directory another');
|
||||
assert.equal(FS.breadcrumbs[2].text, 'another');
|
||||
assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name} ${files[1].name}`);
|
||||
assert.equal(FS.breadcrumbs[2].text, files[1].name);
|
||||
|
||||
assert.notOk(
|
||||
FS.breadcrumbs[0].path.includes('//'),
|
||||
|
@ -136,7 +176,7 @@ module('Acceptance | task fs', function(hooks) {
|
|||
);
|
||||
|
||||
await FS.breadcrumbs[1].visit();
|
||||
assert.equal(FS.breadcrumbsText, 'task-name directory');
|
||||
assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name}`);
|
||||
assert.equal(FS.breadcrumbs.length, 2);
|
||||
});
|
||||
|
||||
|
@ -266,12 +306,39 @@ module('Acceptance | task fs', function(hooks) {
|
|||
});
|
||||
|
||||
test('viewing a file', async function(assert) {
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/' });
|
||||
await FS.directoryEntries[2].visit();
|
||||
const node = server.db.nodes.find(allocation.nodeId);
|
||||
|
||||
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);
|
||||
|
||||
const requests = this.server.pretender.handledRequests;
|
||||
const secondAttempt = requests.pop();
|
||||
const firstAttempt = requests.pop();
|
||||
|
||||
assert.equal(
|
||||
firstAttempt.url.split('?')[0],
|
||||
`//${node.httpAddr}/v1/client/fs/readat/${allocation.id}`,
|
||||
'Client is hit first'
|
||||
);
|
||||
assert.equal(firstAttempt.status, 500, 'Client request fails');
|
||||
assert.equal(
|
||||
secondAttempt.url.split('?')[0],
|
||||
`/v1/client/fs/readat/${allocation.id}`,
|
||||
'Server is hit second'
|
||||
);
|
||||
});
|
||||
|
||||
test('viewing an empty directory', async function(assert) {
|
||||
|
@ -304,7 +371,7 @@ module('Acceptance | task fs', function(hooks) {
|
|||
return new Response(500, {}, 'no such file or directory');
|
||||
});
|
||||
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/what-is-this' });
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: files[0].name });
|
||||
assert.equal(FS.error.title, 'Not Found', '500 is interpreted as 404');
|
||||
|
||||
await visit('/');
|
||||
|
@ -313,7 +380,7 @@ module('Acceptance | task fs', function(hooks) {
|
|||
return new Response(999);
|
||||
});
|
||||
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/what-is-this' });
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: files[0].name });
|
||||
assert.equal(FS.error.title, 'Error', 'other statuses are passed through');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue