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) {
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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() {
|
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;
|
||||||
|
|
|
@ -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 { 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);
|
||||||
|
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -18,4 +18,8 @@
|
||||||
color: $grey;
|
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 {
|
&.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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
<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>
|
||||||
|
|
|
@ -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, '<').replace(/>/g, '>');
|
||||||
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');
|
||||||
|
|
|
@ -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(''));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(''));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 { 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));
|
||||||
|
|
|
@ -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('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
|
||||||
|
|
|
@ -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 shouldn’t have redundant separators');
|
assert.notOk(directory.path.includes('//'), 'paths shouldn’t 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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
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');
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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