mirror of
https://github.com/kemko/nomad.git
synced 2026-01-03 08:55:43 +03:00
Merge pull request #5871 from hashicorp/f-ui/alloc-fs
UI: Allocation file system explorer
This commit is contained in:
39
ui/app/adapters/task-state.js
Normal file
39
ui/app/adapters/task-state.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import ApplicationAdapter from './application';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default ApplicationAdapter.extend({
|
||||
token: service(),
|
||||
|
||||
ls(model, path) {
|
||||
return this.token
|
||||
.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=${encodeURIComponent(path)}`
|
||||
)
|
||||
.then(handleFSResponse);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleFSResponse(response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
const body = await response.text();
|
||||
|
||||
// TODO update this if/when endpoint returns 404 as expected
|
||||
const statusIs500 = response.status === 500;
|
||||
const bodyIncludes404Text = body.includes('no such file or directory');
|
||||
|
||||
const translatedCode = statusIs500 && bodyIncludes404Text ? 404 : response.status;
|
||||
|
||||
throw {
|
||||
code: translatedCode,
|
||||
toString: () => body,
|
||||
};
|
||||
}
|
||||
}
|
||||
42
ui/app/components/fs-breadcrumbs.js
Normal file
42
ui/app/components/fs-breadcrumbs.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
import { isEmpty } from '@ember/utils';
|
||||
|
||||
export default Component.extend({
|
||||
tagName: 'nav',
|
||||
classNames: ['breadcrumb'],
|
||||
|
||||
'data-test-fs-breadcrumbs': true,
|
||||
|
||||
task: null,
|
||||
path: null,
|
||||
|
||||
breadcrumbs: computed('path', function() {
|
||||
const breadcrumbs = this.path
|
||||
.split('/')
|
||||
.reject(isEmpty)
|
||||
.reduce((breadcrumbs, pathSegment, index) => {
|
||||
let breadcrumbPath;
|
||||
|
||||
if (index > 0) {
|
||||
const lastBreadcrumb = breadcrumbs[index - 1];
|
||||
breadcrumbPath = `${lastBreadcrumb.path}/${pathSegment}`;
|
||||
} else {
|
||||
breadcrumbPath = pathSegment;
|
||||
}
|
||||
|
||||
breadcrumbs.push({
|
||||
name: pathSegment,
|
||||
path: breadcrumbPath,
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
}, []);
|
||||
|
||||
if (breadcrumbs.length) {
|
||||
breadcrumbs[breadcrumbs.length - 1].isLast = true;
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}),
|
||||
});
|
||||
18
ui/app/components/fs-directory-entry.js
Normal file
18
ui/app/components/fs-directory-entry.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
import { isEmpty } from '@ember/utils';
|
||||
|
||||
export default Component.extend({
|
||||
tagName: '',
|
||||
|
||||
pathToEntry: computed('path', 'entry.Name', function() {
|
||||
const pathWithNoLeadingSlash = this.get('path').replace(/^\//, '');
|
||||
const name = encodeURIComponent(this.get('entry.Name'));
|
||||
|
||||
if (isEmpty(pathWithNoLeadingSlash)) {
|
||||
return name;
|
||||
} else {
|
||||
return `${pathWithNoLeadingSlash}/${name}`;
|
||||
}
|
||||
}),
|
||||
});
|
||||
29
ui/app/components/image-file.js
Normal file
29
ui/app/components/image-file.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
|
||||
export default Component.extend({
|
||||
tagName: 'figure',
|
||||
classNames: 'image-file',
|
||||
'data-test-image-file': true,
|
||||
|
||||
src: null,
|
||||
alt: null,
|
||||
size: null,
|
||||
|
||||
// Set by updateImageMeta
|
||||
width: 0,
|
||||
height: 0,
|
||||
|
||||
fileName: computed('src', function() {
|
||||
if (!this.src) return;
|
||||
return this.src.includes('/') ? this.src.match(/^.*\/(.*)$/)[1] : this.src;
|
||||
}),
|
||||
|
||||
updateImageMeta(event) {
|
||||
const img = event.target;
|
||||
this.setProperties({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
},
|
||||
});
|
||||
96
ui/app/components/streaming-file.js
Normal file
96
ui/app/components/streaming-file.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import Component from '@ember/component';
|
||||
import { run } from '@ember/runloop';
|
||||
import { task } from 'ember-concurrency';
|
||||
import WindowResizable from 'nomad-ui/mixins/window-resizable';
|
||||
|
||||
export default Component.extend(WindowResizable, {
|
||||
tagName: 'pre',
|
||||
classNames: ['cli-window'],
|
||||
'data-test-log-cli': true,
|
||||
|
||||
mode: 'streaming', // head, tail, streaming
|
||||
isStreaming: true,
|
||||
logger: null,
|
||||
|
||||
didReceiveAttrs() {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
|
||||
run.scheduleOnce('actions', () => {
|
||||
switch (this.mode) {
|
||||
case 'head':
|
||||
this.head.perform();
|
||||
break;
|
||||
case 'tail':
|
||||
this.tail.perform();
|
||||
break;
|
||||
case 'streaming':
|
||||
if (this.isStreaming) {
|
||||
this.stream.perform();
|
||||
} else {
|
||||
this.logger.stop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this.fillAvailableHeight();
|
||||
},
|
||||
|
||||
windowResizeHandler() {
|
||||
run.once(this, this.fillAvailableHeight);
|
||||
},
|
||||
|
||||
fillAvailableHeight() {
|
||||
// This math is arbitrary and far from bulletproof, but the UX
|
||||
// of having the log window fill available height is worth the hack.
|
||||
const margins = 30; // Account for padding and margin on either side of the CLI
|
||||
const cliWindow = this.element;
|
||||
cliWindow.style.height = `${window.innerHeight - cliWindow.offsetTop - margins}px`;
|
||||
},
|
||||
|
||||
head: task(function*() {
|
||||
yield this.get('logger.gotoHead').perform();
|
||||
run.scheduleOnce('afterRender', () => {
|
||||
this.element.scrollTop = 0;
|
||||
});
|
||||
}),
|
||||
|
||||
tail: task(function*() {
|
||||
yield this.get('logger.gotoTail').perform();
|
||||
run.scheduleOnce('afterRender', () => {
|
||||
const cliWindow = this.element;
|
||||
cliWindow.scrollTop = cliWindow.scrollHeight;
|
||||
});
|
||||
}),
|
||||
|
||||
synchronizeScrollPosition(force = false) {
|
||||
const cliWindow = this.element;
|
||||
if (cliWindow.scrollHeight - cliWindow.scrollTop < 10 || force) {
|
||||
// If the window is approximately scrolled to the bottom, follow the log
|
||||
cliWindow.scrollTop = cliWindow.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
stream: task(function*() {
|
||||
// Force the scroll position to the bottom of the window when starting streaming
|
||||
this.logger.one('tick', () => {
|
||||
run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition(true));
|
||||
});
|
||||
|
||||
// Follow the log if the scroll position is near the bottom of the cli window
|
||||
this.logger.on('tick', () => {
|
||||
run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition());
|
||||
});
|
||||
|
||||
yield this.logger.startStreaming();
|
||||
this.logger.off('tick');
|
||||
}),
|
||||
|
||||
willDestroy() {
|
||||
this.logger.stop();
|
||||
},
|
||||
});
|
||||
148
ui/app/components/task-file.js
Normal file
148
ui/app/components/task-file.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
import { gt } from '@ember/object/computed';
|
||||
import { equal } from '@ember/object/computed';
|
||||
import RSVP from 'rsvp';
|
||||
import Log from 'nomad-ui/utils/classes/log';
|
||||
import timeout from 'nomad-ui/utils/timeout';
|
||||
|
||||
export default Component.extend({
|
||||
token: service(),
|
||||
|
||||
classNames: ['boxed-section', 'task-log'],
|
||||
|
||||
'data-test-file-viewer': true,
|
||||
|
||||
allocation: null,
|
||||
task: null,
|
||||
file: null,
|
||||
stat: null, // { Name, IsDir, Size, FileMode, ModTime, ContentType }
|
||||
|
||||
// When true, request logs from the server agent
|
||||
useServer: false,
|
||||
|
||||
// When true, logs cannot be fetched from either the client or the server
|
||||
noConnection: false,
|
||||
|
||||
clientTimeout: 1000,
|
||||
serverTimeout: 5000,
|
||||
|
||||
mode: 'head',
|
||||
|
||||
fileComponent: computed('stat.ContentType', function() {
|
||||
const contentType = this.stat.ContentType || '';
|
||||
|
||||
if (contentType.startsWith('image/')) {
|
||||
return 'image';
|
||||
} else if (contentType.startsWith('text/') || contentType.startsWith('application/json')) {
|
||||
return 'stream';
|
||||
} else {
|
||||
return 'unknown';
|
||||
}
|
||||
}),
|
||||
|
||||
isLarge: gt('stat.Size', 50000),
|
||||
|
||||
fileTypeIsUnknown: equal('fileComponent', 'unknown'),
|
||||
isStreamable: equal('fileComponent', 'stream'),
|
||||
isStreaming: false,
|
||||
|
||||
catUrl: computed('allocation.id', 'task.name', 'file', function() {
|
||||
const encodedPath = encodeURIComponent(`${this.task.name}/${this.file}`);
|
||||
return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`;
|
||||
}),
|
||||
|
||||
fetchMode: computed('isLarge', 'mode', function() {
|
||||
if (this.mode === 'streaming') {
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
if (!this.isLarge) {
|
||||
return 'cat';
|
||||
} else if (this.mode === 'head' || this.mode === 'tail') {
|
||||
return 'readat';
|
||||
}
|
||||
}),
|
||||
|
||||
fileUrl: computed(
|
||||
'allocation.id',
|
||||
'allocation.node.httpAddr',
|
||||
'fetchMode',
|
||||
'useServer',
|
||||
function() {
|
||||
const address = this.get('allocation.node.httpAddr');
|
||||
const url = `/v1/client/fs/${this.fetchMode}/${this.allocation.id}`;
|
||||
return this.useServer ? url : `//${address}${url}`;
|
||||
}
|
||||
),
|
||||
|
||||
fileParams: computed('task.name', 'file', 'mode', function() {
|
||||
// The Log class handles encoding query params
|
||||
const path = `${this.task.name}/${this.file}`;
|
||||
|
||||
switch (this.mode) {
|
||||
case 'head':
|
||||
return { path, offset: 0, limit: 50000 };
|
||||
case 'tail':
|
||||
return { path, offset: this.stat.Size - 50000, limit: 50000 };
|
||||
case 'streaming':
|
||||
return { path, offset: 50000, origin: 'end' };
|
||||
default:
|
||||
return { path };
|
||||
}
|
||||
}),
|
||||
|
||||
logger: computed('fileUrl', 'fileParams', 'mode', function() {
|
||||
// The cat and readat APIs are in plainText while the stream API is always encoded.
|
||||
const plainText = this.mode === 'head' || this.mode === 'tail';
|
||||
|
||||
// If the file request can't settle in one second, the client
|
||||
// must be unavailable and the server should be used instead
|
||||
const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
|
||||
const logFetch = url =>
|
||||
RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then(
|
||||
response => {
|
||||
if (!response || !response.ok) {
|
||||
this.nextErrorState(response);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
error => this.nextErrorState(error)
|
||||
);
|
||||
|
||||
return Log.create({
|
||||
logFetch,
|
||||
plainText,
|
||||
params: this.fileParams,
|
||||
url: this.fileUrl,
|
||||
});
|
||||
}),
|
||||
|
||||
nextErrorState(error) {
|
||||
if (this.useServer) {
|
||||
this.set('noConnection', true);
|
||||
} else {
|
||||
this.send('failoverToServer');
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
|
||||
actions: {
|
||||
toggleStream() {
|
||||
this.set('mode', 'streaming');
|
||||
this.toggleProperty('isStreaming');
|
||||
},
|
||||
gotoHead() {
|
||||
this.set('mode', 'head');
|
||||
this.set('isStreaming', false);
|
||||
},
|
||||
gotoTail() {
|
||||
this.set('mode', 'tail');
|
||||
this.set('isStreaming', false);
|
||||
},
|
||||
failoverToServer() {
|
||||
this.set('useServer', true);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
14
ui/app/components/task-subnav.js
Normal file
14
ui/app/components/task-subnav.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Component from '@ember/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { equal, or } from '@ember/object/computed';
|
||||
|
||||
export default Component.extend({
|
||||
router: service(),
|
||||
|
||||
tagName: '',
|
||||
|
||||
fsIsActive: equal('router.currentRouteName', 'allocations.allocation.task.fs'),
|
||||
fsRootIsActive: equal('router.currentRouteName', 'allocations.allocation.task.fs-root'),
|
||||
|
||||
filesLinkActive: or('fsIsActive', 'fsRootIsActive'),
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import FSController from './fs';
|
||||
|
||||
export default FSController.extend();
|
||||
54
ui/app/controllers/allocations/allocation/task/fs.js
Normal file
54
ui/app/controllers/allocations/allocation/task/fs.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { computed } from '@ember/object';
|
||||
import { filterBy } from '@ember/object/computed';
|
||||
|
||||
export default Controller.extend({
|
||||
queryParams: {
|
||||
sortProperty: 'sort',
|
||||
sortDescending: 'desc',
|
||||
},
|
||||
|
||||
sortProperty: 'Name',
|
||||
sortDescending: false,
|
||||
|
||||
path: null,
|
||||
task: null,
|
||||
directoryEntries: null,
|
||||
isFile: null,
|
||||
stat: null,
|
||||
|
||||
directories: filterBy('directoryEntries', 'IsDir'),
|
||||
files: filterBy('directoryEntries', 'IsDir', false),
|
||||
|
||||
pathWithLeadingSlash: computed('path', function() {
|
||||
const path = this.path;
|
||||
|
||||
if (path.startsWith('/')) {
|
||||
return path;
|
||||
} else {
|
||||
return `/${path}`;
|
||||
}
|
||||
}),
|
||||
|
||||
sortedDirectoryEntries: computed(
|
||||
'directoryEntries.[]',
|
||||
'sortProperty',
|
||||
'sortDescending',
|
||||
function() {
|
||||
const sortProperty = this.sortProperty;
|
||||
|
||||
const directorySortProperty = sortProperty === 'Size' ? 'Name' : sortProperty;
|
||||
|
||||
const sortedDirectories = this.directories.sortBy(directorySortProperty);
|
||||
const sortedFiles = this.files.sortBy(sortProperty);
|
||||
|
||||
const sortedDirectoryEntries = sortedDirectories.concat(sortedFiles);
|
||||
|
||||
if (this.sortDescending) {
|
||||
return sortedDirectoryEntries.reverse();
|
||||
} else {
|
||||
return sortedDirectoryEntries;
|
||||
}
|
||||
}
|
||||
),
|
||||
});
|
||||
@@ -47,4 +47,12 @@ export default Fragment.extend({
|
||||
restart() {
|
||||
return this.allocation.restart(this.name);
|
||||
},
|
||||
|
||||
ls(path) {
|
||||
return this.store.adapterFor('task-state').ls(this, path);
|
||||
},
|
||||
|
||||
stat(path) {
|
||||
return this.store.adapterFor('task-state').stat(this, path);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ Router.map(function() {
|
||||
this.route('allocation', { path: '/:allocation_id' }, function() {
|
||||
this.route('task', { path: '/:name' }, function() {
|
||||
this.route('logs');
|
||||
this.route('fs-root', { path: '/fs' });
|
||||
this.route('fs', { path: '/fs/*path' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
5
ui/app/routes/allocations/allocation/task/fs-root.js
Normal file
5
ui/app/routes/allocations/allocation/task/fs-root.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import FSRoute from './fs';
|
||||
|
||||
export default FSRoute.extend({
|
||||
templateName: 'allocations/allocation/task/fs',
|
||||
});
|
||||
37
ui/app/routes/allocations/allocation/task/fs.js
Normal file
37
ui/app/routes/allocations/allocation/task/fs.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import RSVP from 'rsvp';
|
||||
import notifyError from 'nomad-ui/utils/notify-error';
|
||||
|
||||
export default Route.extend({
|
||||
model({ path = '/' }) {
|
||||
const decodedPath = decodeURIComponent(path);
|
||||
const task = this.modelFor('allocations.allocation.task');
|
||||
|
||||
const pathWithTaskName = `${task.name}${decodedPath.startsWith('/') ? '' : '/'}${decodedPath}`;
|
||||
|
||||
return RSVP.all([task.stat(pathWithTaskName), task.get('allocation.node')])
|
||||
.then(([statJson]) => {
|
||||
if (statJson.IsDir) {
|
||||
return RSVP.hash({
|
||||
path: decodedPath,
|
||||
task,
|
||||
directoryEntries: task.ls(pathWithTaskName).catch(notifyError(this)),
|
||||
isFile: false,
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
path: decodedPath,
|
||||
task,
|
||||
isFile: true,
|
||||
stat: statJson,
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch(notifyError(this));
|
||||
},
|
||||
|
||||
setupController(controller, { path, task, directoryEntries, isFile, stat } = {}) {
|
||||
this._super(...arguments);
|
||||
controller.setProperties({ path, task, directoryEntries, isFile, stat });
|
||||
},
|
||||
});
|
||||
@@ -8,8 +8,10 @@
|
||||
@import './components/ember-power-select';
|
||||
@import './components/empty-message';
|
||||
@import './components/error-container';
|
||||
@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,6 +18,10 @@
|
||||
.pull-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.is-compact {
|
||||
padding: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.boxed-section-head {
|
||||
|
||||
@@ -18,4 +18,8 @@
|
||||
color: $grey;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-hollow {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
74
ui/app/styles/components/fs-explorer.scss
Normal file
74
ui/app/styles/components/fs-explorer.scss
Normal file
@@ -0,0 +1,74 @@
|
||||
.fs-explorer {
|
||||
width: 100%;
|
||||
|
||||
.table.boxed-section-body.is-full-bleed {
|
||||
border: 1px solid $grey-blue;
|
||||
}
|
||||
|
||||
tbody {
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
.name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb a,
|
||||
tbody a {
|
||||
position: relative;
|
||||
|
||||
// This is adapted from Bulma’s .button.is-loading::after
|
||||
&.ember-transitioning-in::after {
|
||||
animation: spinAround 500ms infinite linear;
|
||||
border: 2px solid $grey-light;
|
||||
border-radius: 290486px;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
position: absolute;
|
||||
right: -1.5em;
|
||||
top: calc(50% - (1em / 2));
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin: 0;
|
||||
|
||||
li::before {
|
||||
color: $grey-light;
|
||||
}
|
||||
|
||||
a {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
color: $blue;
|
||||
opacity: 1;
|
||||
font-weight: $weight-bold;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.ember-transitioning-in {
|
||||
margin-right: 1.5em;
|
||||
|
||||
&::after {
|
||||
right: -1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-active a {
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
ui/app/styles/components/image-file.scss
Normal file
20
ui/app/styles/components/image-file.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
.image-file {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $white;
|
||||
text-align: center;
|
||||
color: $text;
|
||||
|
||||
.image-file-image {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.image-file-caption {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.image-file-caption-primary {
|
||||
display: block;
|
||||
color: $grey;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
td {
|
||||
padding: 0.75em;
|
||||
}
|
||||
|
||||
.is-selectable a {
|
||||
padding: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-darkened {
|
||||
@@ -149,33 +153,50 @@
|
||||
padding: 0.75em 1.5em;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($white-ter, 5%);
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
color: $grey-light;
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 10px;
|
||||
right: 1.5em;
|
||||
top: 0.75em;
|
||||
bottom: 0.75em;
|
||||
position: absolute;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
&.desc a::after {
|
||||
content: '⬆';
|
||||
}
|
||||
|
||||
&.asc::after {
|
||||
&.asc a::after {
|
||||
content: '⬇';
|
||||
}
|
||||
|
||||
&.desc::after {
|
||||
content: '⬆';
|
||||
&.has-text-right {
|
||||
a::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
&.desc a::before {
|
||||
content: '⬆';
|
||||
}
|
||||
|
||||
&.asc a::before {
|
||||
content: '⬇';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
|
||||
+ * {
|
||||
margin-top: 5em;
|
||||
|
||||
&.is-closer {
|
||||
margin-top: calc(3.5em + 1px);
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$mq-hidden-gutter} {
|
||||
|
||||
49
ui/app/templates/allocations/allocation/task/fs.hbs
Normal file
49
ui/app/templates/allocations/allocation/task/fs.hbs
Normal file
@@ -0,0 +1,49 @@
|
||||
{{title pathWithLeadingSlash " - Task " task.name " filesystem"}}
|
||||
{{task-subnav task=task}}
|
||||
<section class="section is-closer {{if isFile "full-width-section"}}">
|
||||
{{#if task.isRunning}}
|
||||
{{#if isFile}}
|
||||
{{#task-file allocation=task.allocation task=task file=path stat=stat class="fs-explorer"}}
|
||||
{{fs-breadcrumbs task=task path=path}}
|
||||
{{/task-file}}
|
||||
{{else}}
|
||||
<div class="fs-explorer boxed-section">
|
||||
<div class="boxed-section-head">
|
||||
{{fs-breadcrumbs task=task path=path}}
|
||||
</div>
|
||||
{{#if directoryEntries}}
|
||||
{{#list-table
|
||||
source=sortedDirectoryEntries
|
||||
sortProperty=sortProperty
|
||||
sortDescending=sortDescending
|
||||
class="boxed-section-body is-full-bleed is-compact" as |t|}}
|
||||
{{#t.head}}
|
||||
{{#t.sort-by prop="Name" class="is-two-thirds"}}Name{{/t.sort-by}}
|
||||
{{#t.sort-by prop="Size" class="has-text-right"}}File Size{{/t.sort-by}}
|
||||
{{#t.sort-by prop="ModTime" class="has-text-right"}}Last Modified{{/t.sort-by}}
|
||||
{{/t.head}}
|
||||
{{#t.body as |row|}}
|
||||
{{fs-directory-entry path=path task=task entry=row.model}}
|
||||
{{/t.body}}
|
||||
{{/list-table}}
|
||||
{{else}}
|
||||
<div class="boxed-section-body">
|
||||
<div data-test-empty-directory class="empty-message">
|
||||
<h3 data-test-empty-directory-headline class="empty-message-headline">No Files</h3>
|
||||
<p data-test-empty-directory-body class="empty-message-body">
|
||||
Directory is currently empty.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</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>
|
||||
<p data-test-not-running-body class="empty-message-body">
|
||||
Cannot access files of a task that is not running.
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
</section>
|
||||
@@ -1,5 +1,5 @@
|
||||
{{title "Task " model.name}}
|
||||
{{partial "allocations/allocation/task/subnav"}}
|
||||
{{task-subnav task=model}}
|
||||
<section class="section">
|
||||
{{#if error}}
|
||||
<div data-test-inline-error class="notification is-danger">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{title "Task " model.name " logs"}}
|
||||
{{partial "allocations/allocation/task/subnav"}}
|
||||
{{task-subnav task=model}}
|
||||
<section class="section full-width-section">
|
||||
{{task-log data-test-task-log allocation=model.allocation task=model.name}}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<div class="tabs is-subnav">
|
||||
<ul>
|
||||
<li>{{#link-to "allocations.allocation.task.index" model.allocation model activeClass="is-active"}}Overview{{/link-to}}</li>
|
||||
<li>{{#link-to "allocations.allocation.task.logs" model.allocation model activeClass="is-active"}}Logs{{/link-to}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
14
ui/app/templates/components/fs-breadcrumbs.hbs
Normal file
14
ui/app/templates/components/fs-breadcrumbs.hbs
Normal file
@@ -0,0 +1,14 @@
|
||||
<ul>
|
||||
<li class={{if breadcrumbs "" "is-active"}}>
|
||||
{{#link-to "allocations.allocation.task.fs-root" task.allocation task activeClass="is-active"}}
|
||||
{{task.name}}
|
||||
{{/link-to}}
|
||||
</li>
|
||||
{{#each breadcrumbs as |breadcrumb|}}
|
||||
<li class={{if breadcrumb.isLast "is-active"}}>
|
||||
{{#link-to "allocations.allocation.task.fs" task.allocation task breadcrumb.path activeClass="is-active"}}
|
||||
{{breadcrumb.name}}
|
||||
{{/link-to}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
15
ui/app/templates/components/fs-directory-entry.hbs
Normal file
15
ui/app/templates/components/fs-directory-entry.hbs
Normal file
@@ -0,0 +1,15 @@
|
||||
<tr data-test-entry>
|
||||
<td>
|
||||
{{#link-to "allocations.allocation.task.fs" task.allocation task pathToEntry activeClass="is-active"}}
|
||||
{{#if entry.IsDir}}
|
||||
{{x-icon "folder-outline"}}
|
||||
{{else}}
|
||||
{{x-icon "file-outline"}}
|
||||
{{/if}}
|
||||
|
||||
<span class="name" data-test-name>{{entry.Name}}</span>
|
||||
{{/link-to}}
|
||||
</td>
|
||||
<td class="has-text-right" data-test-size>{{#unless entry.IsDir}}{{format-bytes entry.Size}}{{/unless}}</td>
|
||||
<td class="has-text-right" title={{format-ts entry.ModTime}} data-test-last-modified>{{moment-from entry.ModTime interval=1000}}</td>
|
||||
</tr>
|
||||
11
ui/app/templates/components/image-file.hbs
Normal file
11
ui/app/templates/components/image-file.hbs
Normal file
@@ -0,0 +1,11 @@
|
||||
<a data-test-image-link href={{src}} target="_blank" rel="noopener noreferrer" class="image-file-image">
|
||||
<img data-test-image src={{src}} alt={{or alt fileName}} title={{fileName}} onload={{action updateImageMeta}} />
|
||||
</a>
|
||||
<figcaption class="image-file-caption">
|
||||
<span class="image-file-caption-primary">
|
||||
<strong data-test-file-name>{{fileName}}</strong>
|
||||
{{#if (and width height)}}
|
||||
<span data-test-file-stats>({{width}}px × {{height}}px{{#if size}}, {{format-bytes size}}{{/if}})</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
</figcaption>
|
||||
1
ui/app/templates/components/streaming-file.hbs
Normal file
1
ui/app/templates/components/streaming-file.hbs
Normal file
@@ -0,0 +1 @@
|
||||
<code data-test-output>{{logger.output}}</code>
|
||||
39
ui/app/templates/components/task-file.hbs
Normal file
39
ui/app/templates/components/task-file.hbs
Normal file
@@ -0,0 +1,39 @@
|
||||
{{#if noConnection}}
|
||||
<div data-test-connection-error class="notification is-error">
|
||||
<h3 class="title is-4">Cannot fetch file</h3>
|
||||
<p>The files for this task are inaccessible. Check the condition of the client the allocation is on.</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div data-test-header class="boxed-section-head">
|
||||
{{yield}}
|
||||
<span class="pull-right">
|
||||
|
||||
{{#if (not fileTypeIsUnknown)}}
|
||||
<a data-test-log-action="raw" class="button is-white is-compact" href="{{catUrl}}" target="_blank" rel="noopener noreferrer">View Raw File</a>
|
||||
{{/if}}
|
||||
{{#if (and isLarge isStreamable)}}
|
||||
<button data-test-log-action="head" class="button is-white is-compact" onclick={{action "gotoHead"}}>Head</button>
|
||||
<button data-test-log-action="tail" class="button is-white is-compact" onclick={{action "gotoTail"}}>Tail</button>
|
||||
{{/if}}
|
||||
{{#if isStreamable}}
|
||||
<button data-test-log-action="toggle-stream" class="button is-white is-compact" onclick={{action "toggleStream"}}>
|
||||
{{x-icon (if logger.isStreaming "media-pause" "media-play") class="is-text"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
<div data-test-file-box class="boxed-section-body {{if (eq fileComponent "stream") "is-dark is-full-bleed"}}">
|
||||
{{#if (eq fileComponent "stream")}}
|
||||
{{streaming-file logger=logger mode=mode isStreaming=isStreaming}}
|
||||
{{else if (eq fileComponent "image")}}
|
||||
{{image-file src=catUrl alt=stat.Name size=stat.Size}}
|
||||
{{else}}
|
||||
<div data-test-unsupported-type class="empty-message is-hollow">
|
||||
<h3 class="empty-message-headline">Unsupported File Type</h3>
|
||||
<p class="empty-message-body message">The Nomad UI could not render this file, but you can still view the file directly.</p>
|
||||
<p class="empty-message-body">
|
||||
<a data-test-log-action="raw" class="button is-light" href="{{catUrl}}" target="_blank" rel="noopener noreferrer">View Raw File</a>
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
7
ui/app/templates/components/task-subnav.hbs
Normal file
7
ui/app/templates/components/task-subnav.hbs
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="tabs is-subnav">
|
||||
<ul>
|
||||
<li>{{#link-to "allocations.allocation.task.index" task.allocation task activeClass="is-active"}}Overview{{/link-to}}</li>
|
||||
<li>{{#link-to "allocations.allocation.task.logs" task.allocation task activeClass="is-active"}}Logs{{/link-to}}</li>
|
||||
<li>{{#link-to "allocations.allocation.task.fs-root" task.allocation task class=(if filesLinkActive "is-active")}}Files{{/link-to}}</li>
|
||||
</ul>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
26
ui/app/utils/stream-frames.js
Normal file
26
ui/app/utils/stream-frames.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
*
|
||||
* @param {string} chunk
|
||||
* Chunk is an undelimited string of valid JSON objects as returned by a streaming endpoint.
|
||||
* Each JSON object in a chunk contains two properties:
|
||||
* Offset {number} The index from the beginning of the stream at which this JSON object starts
|
||||
* Data {string} A base64 encoded string representing the contents of the stream this JSON
|
||||
* object represents.
|
||||
*/
|
||||
export function decode(chunk) {
|
||||
const lines = chunk
|
||||
.replace(/\}\{/g, '}\n{')
|
||||
.split('\n')
|
||||
.without('');
|
||||
const frames = lines.map(line => JSON.parse(line)).filter(frame => frame.Data);
|
||||
|
||||
if (frames.length) {
|
||||
frames.forEach(frame => (frame.Data = window.atob(frame.Data)));
|
||||
return {
|
||||
offset: frames[frames.length - 1].Offset,
|
||||
message: frames.mapBy('Data').join(''),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -11,6 +11,15 @@ export function findLeader(schema) {
|
||||
return `${agent.address}:${agent.tags.port}`;
|
||||
}
|
||||
|
||||
export function filesForPath(allocFiles, filterPath) {
|
||||
return allocFiles.where(
|
||||
file =>
|
||||
(!filterPath || file.path.startsWith(filterPath)) &&
|
||||
file.path.length > filterPath.length &&
|
||||
!file.path.substr(filterPath.length + 1).includes('/')
|
||||
);
|
||||
}
|
||||
|
||||
export default function() {
|
||||
this.timing = 0; // delay for each request, automatically set to 0 during testing
|
||||
|
||||
@@ -306,6 +315,70 @@ export default function() {
|
||||
return logEncode(logFrames, logFrames.length - 1);
|
||||
};
|
||||
|
||||
const clientAllocationFSLsHandler = function({ allocFiles }, { queryParams }) {
|
||||
// Ignore the task name at the beginning of the path
|
||||
const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1);
|
||||
const files = filesForPath(allocFiles, filterPath);
|
||||
return this.serialize(files);
|
||||
};
|
||||
|
||||
const clientAllocationFSStatHandler = function({ allocFiles }, { queryParams }) {
|
||||
// Ignore the task name at the beginning of the path
|
||||
const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1);
|
||||
|
||||
// Root path
|
||||
if (!filterPath) {
|
||||
return this.serialize({
|
||||
IsDir: true,
|
||||
ModTime: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Either a file or a nested directory
|
||||
const file = allocFiles.where({ path: filterPath }).models[0];
|
||||
return this.serialize(file);
|
||||
};
|
||||
|
||||
const clientAllocationCatHandler = function({ allocFiles }, { queryParams }) {
|
||||
const [file, err] = fileOrError(allocFiles, queryParams.path);
|
||||
|
||||
if (err) return err;
|
||||
return file.body;
|
||||
};
|
||||
|
||||
const clientAllocationStreamHandler = function({ allocFiles }, { queryParams }) {
|
||||
const [file, err] = fileOrError(allocFiles, queryParams.path);
|
||||
|
||||
if (err) return err;
|
||||
|
||||
// Pretender, and therefore Mirage, doesn't support streaming responses.
|
||||
return file.body;
|
||||
};
|
||||
|
||||
const clientAllocationReadAtHandler = function({ allocFiles }, { queryParams }) {
|
||||
const [file, err] = fileOrError(allocFiles, queryParams.path);
|
||||
|
||||
if (err) return err;
|
||||
return file.body.substr(queryParams.offset || 0, queryParams.limit);
|
||||
};
|
||||
|
||||
const fileOrError = function(allocFiles, path, message = 'Operation not allowed on a directory') {
|
||||
// Ignore the task name at the beginning of the path
|
||||
const filterPath = path.substr(path.indexOf('/') + 1);
|
||||
|
||||
// Root path
|
||||
if (!filterPath) {
|
||||
return [null, new Response(400, {}, message)];
|
||||
}
|
||||
|
||||
const file = allocFiles.where({ path: filterPath }).models[0];
|
||||
if (file.isDir) {
|
||||
return [null, new Response(400, {}, message)];
|
||||
}
|
||||
|
||||
return [file, null];
|
||||
};
|
||||
|
||||
// Client requests are available on the server and the client
|
||||
this.put('/client/allocation/:id/restart', function() {
|
||||
return new Response(204, {}, '');
|
||||
@@ -314,6 +387,12 @@ export default function() {
|
||||
this.get('/client/allocation/:id/stats', clientAllocationStatsHandler);
|
||||
this.get('/client/fs/logs/:allocation_id', clientAllocationLog);
|
||||
|
||||
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();
|
||||
if (seed > 0.8) {
|
||||
@@ -334,6 +413,12 @@ export default function() {
|
||||
this.get(`http://${host}/v1/client/allocation/:id/stats`, clientAllocationStatsHandler);
|
||||
this.get(`http://${host}/v1/client/fs/logs/:allocation_id`, clientAllocationLog);
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
113
ui/mirage/factories/alloc-file.js
Normal file
113
ui/mirage/factories/alloc-file.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Factory, faker, trait } from 'ember-cli-mirage';
|
||||
import { pickOne } from '../utils';
|
||||
|
||||
const REF_TIME = new Date();
|
||||
const TROUBLESOME_CHARACTERS = '🏆 💃 🤩 🙌🏿 🖨 ? ; %'.split(' ');
|
||||
const makeWord = () => Math.round(Math.random() * 10000000 + 50000).toString(36);
|
||||
const makeSentence = (count = 10) =>
|
||||
new Array(count)
|
||||
.fill(null)
|
||||
.map(makeWord)
|
||||
.join(' ');
|
||||
|
||||
const fileTypeMapping = {
|
||||
svg: 'image/svg',
|
||||
txt: 'text/plain',
|
||||
json: 'application/json',
|
||||
app: 'application/octet-stream',
|
||||
exe: 'application/octet-stream',
|
||||
};
|
||||
|
||||
const fileBodyMapping = {
|
||||
svg: () => `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 207 60">
|
||||
<g fill="none">
|
||||
<path class="top" fill="#25BA81" d="M26.03.01L0 15.05l17.56 10.32 3.56-2.17 8.63 4.82v-10l8.27-4.97v10.02l14.48-8.02v-.04"/>
|
||||
<path class="left" fill="#25BA81" d="M22.75 32.03v9.99l-7.88 5v-20l2.99-1.83L.15 15.05H0v29.96l26.25 15V34.03"/>
|
||||
<path class="right" fill="#1F9967" d="M38.02 23.07v9.95l-6.93 4.01-4.84-3v25.98h.14l26.11-15V15.05l-.49-.01"/>
|
||||
<path class="text" fill="#000" d="M78.49 21.83v24.24h-5.9v-32h8.06l12.14 24.32V14.1h5.9v32h-8.06m22.46.45c-8 0-10.18-4.42-10.18-9.22v-5.9c0-4.8 2.16-9.22 10.18-9.22s10.18 4.42 10.18 9.22v5.91c0 4.79-2.16 9.21-10.18 9.21zm0-19.35c-3.12 0-4.32 1.39-4.32 4v6.29c0 2.64 1.2 4 4.32 4s4.32-1.39 4.32-4v-6.25c0-2.64-1.2-4.04-4.32-4.04zm27.99 18.87V29.75c0-1.25-.53-1.87-1.87-1.87-2.147.252-4.22.932-6.1 2v16.19h-5.86V22.69h4.46l.58 2c2.916-1.46 6.104-2.293 9.36-2.45 1.852-.175 3.616.823 4.42 2.5 2.922-1.495 6.13-2.348 9.41-2.5 3.89 0 5.28 2.74 5.28 6.91v16.92h-5.86V29.75c0-1.25-.53-1.87-1.87-1.87-2.15.234-4.23.915-6.1 2v16.19h-5.85zm41.81 0h-4.8l-.43-1.58c-2.084 1.352-4.516 2.068-7 2.06-4.27 0-6.1-2.93-6.1-7 0-4.75 2.06-6.58 6.82-6.58H177v-2.41c0-2.59-.72-3.5-4.46-3.5-2.18.024-4.35.265-6.48.72l-.72-4.46c2.606-.72 5.296-1.09 8-1.1 7.34 0 9.5 2.59 9.5 8.45l.05 15.4zM177 37.24h-4.32c-1.92 0-2.45.53-2.45 2.3 0 1.77.53 2.35 2.35 2.35 1.55-.02 3.07-.434 4.42-1.2v-3.45zm9.48-6.77c0-5.18 2.3-8.26 7.73-8.26 2.097.02 4.187.244 6.24.67v-9.74l5.86-.82v33.75h-4.66l-.58-2c-2.133 1.595-4.726 2.454-7.39 2.45-4.7 0-7.2-2.79-7.2-8.11v-7.94zm14-2.64c-1.702-.38-3.437-.588-5.18-.62-2.11 0-2.93 1-2.93 3.12v8.26c0 1.92.72 3 2.88 3 1.937-.07 3.787-.816 5.23-2.11V27.83z"/>
|
||||
</g>
|
||||
</svg>
|
||||
`,
|
||||
txt: () =>
|
||||
new Array(3000)
|
||||
.fill(null)
|
||||
.map((_, i) => {
|
||||
const date = new Date(2019, 6, 23);
|
||||
date.setSeconds(i * 5);
|
||||
return `${date.toISOString()} ${makeSentence(Math.round(Math.random() * 5 + 7))}`;
|
||||
})
|
||||
.join('\n'),
|
||||
json: () =>
|
||||
JSON.stringify({
|
||||
key: 'value',
|
||||
array: [1, 'two', [3]],
|
||||
deep: {
|
||||
ly: {
|
||||
nest: 'ed',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export default Factory.extend({
|
||||
id: i => i,
|
||||
|
||||
isDir: faker.random.boolean,
|
||||
|
||||
// Depth is used to recursively create nested directories.
|
||||
depth: 0,
|
||||
parent: null,
|
||||
|
||||
fileType() {
|
||||
if (this.isDir) return 'dir';
|
||||
return pickOne(['svg', 'txt', 'json', 'app', 'exe']);
|
||||
},
|
||||
|
||||
contentType() {
|
||||
return fileTypeMapping[this.fileType] || null;
|
||||
},
|
||||
|
||||
path() {
|
||||
if (this.parent) {
|
||||
return `${this.parent.path}/${this.name}`;
|
||||
}
|
||||
|
||||
return this.name;
|
||||
},
|
||||
|
||||
name() {
|
||||
return `${faker.hacker.noun().dasherize()}-${pickOne(TROUBLESOME_CHARACTERS)}${
|
||||
this.isDir ? '' : `.${this.fileType}`
|
||||
}`;
|
||||
},
|
||||
|
||||
body() {
|
||||
const strategy = fileBodyMapping[this.fileType];
|
||||
return strategy ? strategy() : '';
|
||||
},
|
||||
|
||||
size() {
|
||||
return this.body.length;
|
||||
},
|
||||
|
||||
modTime: () => faker.date.past(2 / 365, REF_TIME),
|
||||
|
||||
dir: trait({
|
||||
isDir: true,
|
||||
afterCreate(allocFile, server) {
|
||||
// create files for the directory
|
||||
if (allocFile.depth > 0) {
|
||||
server.create('allocFile', 'dir', { parent: allocFile, depth: allocFile.depth - 1 });
|
||||
}
|
||||
|
||||
server.createList('allocFile', faker.random.number({ min: 1, max: 3 }), 'file', {
|
||||
parent: allocFile,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
file: trait({
|
||||
isDir: false,
|
||||
}),
|
||||
});
|
||||
5
ui/mirage/models/alloc-file.js
Normal file
5
ui/mirage/models/alloc-file.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Model, belongsTo } from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
parent: belongsTo('alloc-file'),
|
||||
});
|
||||
@@ -39,6 +39,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) {
|
||||
|
||||
385
ui/tests/acceptance/task-fs-test.js
Normal file
385
ui/tests/acceptance/task-fs-test.js
Normal file
@@ -0,0 +1,385 @@
|
||||
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 { 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);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function() {
|
||||
server.create('agent');
|
||||
server.create('node', 'forceIPv4');
|
||||
const job = server.create('job', { createAllocations: false });
|
||||
|
||||
allocation = server.create('allocation', { jobId: job.id, clientStatus: 'running' });
|
||||
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) {
|
||||
await FS.visit({ id: allocation.id, name: task.name });
|
||||
assert.equal(currentURL(), `/allocations/${allocation.id}/${task.name}/fs`, 'No redirect');
|
||||
});
|
||||
|
||||
test('when the task is not running, an empty state is shown', async function(assert) {
|
||||
task.update({
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
|
||||
await FS.visit({ id: allocation.id, name: task.name });
|
||||
assert.ok(FS.hasEmptyState, 'Non-running task has no files');
|
||||
assert.ok(
|
||||
FS.emptyState.headline.includes('Task is not Running'),
|
||||
'Empty state explains the condition'
|
||||
);
|
||||
});
|
||||
|
||||
test('visiting /allocations/:allocation_id/:task_name/fs/:path', async function(assert) {
|
||||
const paths = ['some-file.log', 'a/deep/path/to/a/file.log', '/', 'Unicode™®'];
|
||||
|
||||
const testPath = async filePath => {
|
||||
let pathWithLeadingSlash = filePath;
|
||||
|
||||
if (!pathWithLeadingSlash.startsWith('/')) {
|
||||
pathWithLeadingSlash = `/${filePath}`;
|
||||
}
|
||||
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: filePath });
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/allocations/${allocation.id}/${task.name}/fs/${encodeURIComponent(filePath)}`,
|
||||
'No redirect'
|
||||
);
|
||||
assert.equal(
|
||||
document.title,
|
||||
`${pathWithLeadingSlash} - Task ${task.name} filesystem - Nomad`
|
||||
);
|
||||
assert.equal(FS.breadcrumbsText, `${task.name} ${filePath.replace(/\//g, ' ')}`.trim());
|
||||
};
|
||||
|
||||
await paths.reduce(async (prev, filePath) => {
|
||||
await prev;
|
||||
return testPath(filePath);
|
||||
}, Promise.resolve());
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
assert.equal(FS.breadcrumbsText, task.name);
|
||||
|
||||
assert.equal(FS.breadcrumbs.length, 1);
|
||||
assert.ok(FS.breadcrumbs[0].isActive);
|
||||
assert.equal(FS.breadcrumbs[0].text, 'task-name');
|
||||
|
||||
FS.directoryEntries[0].as(directory => {
|
||||
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, moment(fileRecord.modTime).fromNow());
|
||||
assert.notOk(directory.path.includes('//'), 'paths shouldn’t have redundant separators');
|
||||
});
|
||||
|
||||
FS.directoryEntries[2].as(file => {
|
||||
const fileRecord = sortedFiles[2];
|
||||
assert.equal(file.name, fileRecord.name);
|
||||
assert.ok(file.isFile);
|
||||
assert.equal(file.size, formatBytes([fileRecord.size]));
|
||||
assert.equal(file.lastModified, moment(fileRecord.modTime).fromNow());
|
||||
});
|
||||
|
||||
await FS.directoryEntries[0].visit();
|
||||
|
||||
assert.equal(FS.directoryEntries.length, 1);
|
||||
|
||||
assert.equal(FS.breadcrumbs.length, 2);
|
||||
assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name}`);
|
||||
|
||||
assert.notOk(FS.breadcrumbs[0].isActive);
|
||||
|
||||
assert.equal(FS.breadcrumbs[1].text, files[0].name);
|
||||
assert.ok(FS.breadcrumbs[1].isActive);
|
||||
|
||||
await FS.directoryEntries[0].visit();
|
||||
|
||||
assert.equal(FS.directoryEntries.length, 1);
|
||||
assert.notOk(
|
||||
FS.directoryEntries[0].path.includes('//'),
|
||||
'paths shouldn’t have redundant separators'
|
||||
);
|
||||
|
||||
assert.equal(FS.breadcrumbs.length, 3);
|
||||
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('//'),
|
||||
'paths shouldn’t have redundant separators'
|
||||
);
|
||||
assert.notOk(
|
||||
FS.breadcrumbs[1].path.includes('//'),
|
||||
'paths shouldn’t have redundant separators'
|
||||
);
|
||||
|
||||
await FS.breadcrumbs[1].visit();
|
||||
assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name}`);
|
||||
assert.equal(FS.breadcrumbs.length, 2);
|
||||
});
|
||||
|
||||
test('sorting allocation filesystem directory', async function(assert) {
|
||||
this.server.get('/client/fs/ls/:allocation_id', () => {
|
||||
return [
|
||||
{
|
||||
Name: 'aaa-big-old-file',
|
||||
IsDir: false,
|
||||
Size: 19190000,
|
||||
ModTime: moment()
|
||||
.subtract(1, 'year')
|
||||
.format(),
|
||||
},
|
||||
{
|
||||
Name: 'mmm-small-mid-file',
|
||||
IsDir: false,
|
||||
Size: 1919,
|
||||
ModTime: moment()
|
||||
.subtract(6, 'month')
|
||||
.format(),
|
||||
},
|
||||
{
|
||||
Name: 'zzz-med-new-file',
|
||||
IsDir: false,
|
||||
Size: 191900,
|
||||
ModTime: moment().format(),
|
||||
},
|
||||
{
|
||||
Name: 'aaa-big-old-directory',
|
||||
IsDir: true,
|
||||
Size: 19190000,
|
||||
ModTime: moment()
|
||||
.subtract(1, 'year')
|
||||
.format(),
|
||||
},
|
||||
{
|
||||
Name: 'mmm-small-mid-directory',
|
||||
IsDir: true,
|
||||
Size: 1919,
|
||||
ModTime: moment()
|
||||
.subtract(6, 'month')
|
||||
.format(),
|
||||
},
|
||||
{
|
||||
Name: 'zzz-med-new-directory',
|
||||
IsDir: true,
|
||||
Size: 191900,
|
||||
ModTime: moment().format(),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/' });
|
||||
|
||||
assert.deepEqual(FS.directoryEntryNames(), [
|
||||
'aaa-big-old-directory',
|
||||
'mmm-small-mid-directory',
|
||||
'zzz-med-new-directory',
|
||||
'aaa-big-old-file',
|
||||
'mmm-small-mid-file',
|
||||
'zzz-med-new-file',
|
||||
]);
|
||||
|
||||
await FS.sortBy('Name');
|
||||
|
||||
assert.deepEqual(FS.directoryEntryNames(), [
|
||||
'zzz-med-new-file',
|
||||
'mmm-small-mid-file',
|
||||
'aaa-big-old-file',
|
||||
'zzz-med-new-directory',
|
||||
'mmm-small-mid-directory',
|
||||
'aaa-big-old-directory',
|
||||
]);
|
||||
|
||||
await FS.sortBy('ModTime');
|
||||
|
||||
assert.deepEqual(FS.directoryEntryNames(), [
|
||||
'zzz-med-new-file',
|
||||
'mmm-small-mid-file',
|
||||
'aaa-big-old-file',
|
||||
'zzz-med-new-directory',
|
||||
'mmm-small-mid-directory',
|
||||
'aaa-big-old-directory',
|
||||
]);
|
||||
|
||||
await FS.sortBy('ModTime');
|
||||
|
||||
assert.deepEqual(FS.directoryEntryNames(), [
|
||||
'aaa-big-old-directory',
|
||||
'mmm-small-mid-directory',
|
||||
'zzz-med-new-directory',
|
||||
'aaa-big-old-file',
|
||||
'mmm-small-mid-file',
|
||||
'zzz-med-new-file',
|
||||
]);
|
||||
|
||||
await FS.sortBy('Size');
|
||||
|
||||
assert.deepEqual(
|
||||
FS.directoryEntryNames(),
|
||||
[
|
||||
'aaa-big-old-file',
|
||||
'zzz-med-new-file',
|
||||
'mmm-small-mid-file',
|
||||
'zzz-med-new-directory',
|
||||
'mmm-small-mid-directory',
|
||||
'aaa-big-old-directory',
|
||||
],
|
||||
'expected files to be sorted by descending size and directories to be sorted by descending name'
|
||||
);
|
||||
|
||||
await FS.sortBy('Size');
|
||||
|
||||
assert.deepEqual(
|
||||
FS.directoryEntryNames(),
|
||||
[
|
||||
'aaa-big-old-directory',
|
||||
'mmm-small-mid-directory',
|
||||
'zzz-med-new-directory',
|
||||
'mmm-small-mid-file',
|
||||
'zzz-med-new-file',
|
||||
'aaa-big-old-file',
|
||||
],
|
||||
'expected directories to be sorted by name and files to be sorted by ascending size'
|
||||
);
|
||||
});
|
||||
|
||||
test('viewing a file', async function(assert) {
|
||||
const node = server.db.nodes.find(allocation.nodeId);
|
||||
|
||||
server.get(`http://${node.httpAddr}/v1/client/fs/readat/:allocation_id`, function() {
|
||||
return new Response(500);
|
||||
});
|
||||
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/' });
|
||||
|
||||
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) {
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/empty-directory' });
|
||||
|
||||
assert.ok(FS.isEmptyDirectory);
|
||||
});
|
||||
|
||||
test('viewing paths that produce stat API errors', async function(assert) {
|
||||
this.server.get('/client/fs/stat/:allocation_id', () => {
|
||||
return new Response(500, {}, 'no such file or directory');
|
||||
});
|
||||
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/what-is-this' });
|
||||
assert.equal(FS.error.title, 'Not Found', '500 is interpreted as 404');
|
||||
|
||||
await visit('/');
|
||||
|
||||
this.server.get('/client/fs/stat/:allocation_id', () => {
|
||||
return new Response(999);
|
||||
});
|
||||
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: '/what-is-this' });
|
||||
assert.equal(FS.error.title, 'Error', 'other statuses are passed through');
|
||||
});
|
||||
|
||||
test('viewing paths that produce ls API errors', async function(assert) {
|
||||
this.server.get('/client/fs/ls/:allocation_id', () => {
|
||||
return new Response(500, {}, 'no such file or directory');
|
||||
});
|
||||
|
||||
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('/');
|
||||
|
||||
this.server.get('/client/fs/ls/:allocation_id', () => {
|
||||
return new Response(999);
|
||||
});
|
||||
|
||||
await FS.visitPath({ id: allocation.id, name: task.name, path: files[0].name });
|
||||
assert.equal(FS.error.title, 'Error', 'other statuses are passed through');
|
||||
});
|
||||
});
|
||||
106
ui/tests/integration/image-file-test.js
Normal file
106
ui/tests/integration/image-file-test.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { find, settled } from '@ember/test-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
import RSVP from 'rsvp';
|
||||
import { formatBytes } from 'nomad-ui/helpers/format-bytes';
|
||||
|
||||
module('Integration | Component | image file', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
const commonTemplate = hbs`
|
||||
{{image-file src=src alt=alt size=size}}
|
||||
`;
|
||||
|
||||
const commonProperties = {
|
||||
src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
||||
alt: 'This is the alt text',
|
||||
size: 123456,
|
||||
};
|
||||
|
||||
test('component displays the image', async function(assert) {
|
||||
this.setProperties(commonProperties);
|
||||
|
||||
await this.render(commonTemplate);
|
||||
|
||||
assert.ok(find('img'), 'Image is in the DOM');
|
||||
assert.equal(
|
||||
find('img').getAttribute('src'),
|
||||
commonProperties.src,
|
||||
`src is ${commonProperties.src}`
|
||||
);
|
||||
});
|
||||
|
||||
test('the image is wrapped in an anchor that links directly to the image', async function(assert) {
|
||||
this.setProperties(commonProperties);
|
||||
|
||||
await this.render(commonTemplate);
|
||||
|
||||
assert.ok(find('a'), 'Anchor');
|
||||
assert.ok(find('a > img'), 'Image in anchor');
|
||||
assert.equal(
|
||||
find('a').getAttribute('href'),
|
||||
commonProperties.src,
|
||||
`href is ${commonProperties.src}`
|
||||
);
|
||||
assert.equal(find('a').getAttribute('target'), '_blank', 'Anchor opens to a new tab');
|
||||
assert.equal(
|
||||
find('a').getAttribute('rel'),
|
||||
'noopener noreferrer',
|
||||
'Anchor rel correctly bars openers and referrers'
|
||||
);
|
||||
});
|
||||
|
||||
test('component updates image meta when the image loads', async function(assert) {
|
||||
const { spy, wrapper, notifier } = notifyingSpy();
|
||||
|
||||
this.setProperties(commonProperties);
|
||||
this.set('spy', wrapper);
|
||||
|
||||
this.render(hbs`
|
||||
{{image-file src=src alt=alt size=size updateImageMeta=spy}}
|
||||
`);
|
||||
|
||||
await notifier;
|
||||
assert.ok(spy.calledOnce);
|
||||
});
|
||||
|
||||
test('component shows the width, height, and size of the image', async function(assert) {
|
||||
this.setProperties(commonProperties);
|
||||
|
||||
await this.render(commonTemplate);
|
||||
await settled();
|
||||
|
||||
const statsEl = find('[data-test-file-stats]');
|
||||
assert.ok(
|
||||
/\d+px \u00d7 \d+px/.test(statsEl.textContent),
|
||||
'Width and height are formatted correctly'
|
||||
);
|
||||
assert.ok(
|
||||
statsEl.textContent.trim().endsWith(formatBytes([commonProperties.size]) + ')'),
|
||||
'Human-formatted size is included'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function notifyingSpy() {
|
||||
// The notifier must resolve when the spy wrapper is called
|
||||
let dispatch;
|
||||
const notifier = new RSVP.Promise(resolve => {
|
||||
dispatch = resolve;
|
||||
});
|
||||
|
||||
const spy = sinon.spy();
|
||||
|
||||
// The spy wrapper must call the spy, passing all arguments through, and it must
|
||||
// call dispatch in order to resolve the promise.
|
||||
const wrapper = (...args) => {
|
||||
spy(...args);
|
||||
dispatch();
|
||||
};
|
||||
|
||||
// All three pieces are required to wire up a component, pause test execution, and
|
||||
// write assertions.
|
||||
return { spy, wrapper, notifier };
|
||||
}
|
||||
111
ui/tests/integration/streaming-file-test.js
Normal file
111
ui/tests/integration/streaming-file-test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { run } from '@ember/runloop';
|
||||
import { find, settled } from '@ember/test-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import Pretender from 'pretender';
|
||||
import { logEncode } from '../../mirage/data/logs';
|
||||
import fetch from 'nomad-ui/utils/fetch';
|
||||
import Log from 'nomad-ui/utils/classes/log';
|
||||
|
||||
const { assign } = Object;
|
||||
|
||||
const stringifyValues = obj =>
|
||||
Object.keys(obj).reduce((newObj, key) => {
|
||||
newObj[key] = obj[key].toString();
|
||||
return newObj;
|
||||
}, {});
|
||||
|
||||
const makeLogger = (url, params) =>
|
||||
Log.create({
|
||||
url,
|
||||
params,
|
||||
plainText: true,
|
||||
logFetch: url => fetch(url).then(res => res),
|
||||
});
|
||||
|
||||
module('Integration | Component | streaming file', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function() {
|
||||
this.server = new Pretender(function() {
|
||||
this.get('/file/endpoint', () => [200, {}, 'Hello World']);
|
||||
this.get('/file/stream', () => [200, {}, logEncode(['Hello World'], 0)]);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function() {
|
||||
this.server.shutdown();
|
||||
});
|
||||
|
||||
const commonTemplate = hbs`
|
||||
{{streaming-file logger=logger mode=mode isStreaming=isStreaming}}
|
||||
`;
|
||||
|
||||
test('when mode is `head`, the logger signals head', async function(assert) {
|
||||
const url = '/file/endpoint';
|
||||
const params = { path: 'hello/world.txt', offset: 0, limit: 50000 };
|
||||
this.setProperties({
|
||||
logger: makeLogger(url, params),
|
||||
mode: 'head',
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
await this.render(commonTemplate);
|
||||
await settled();
|
||||
|
||||
const request = this.server.handledRequests[0];
|
||||
assert.equal(this.server.handledRequests.length, 1, 'One request made');
|
||||
assert.equal(request.url.split('?')[0], url, `URL is ${url}`);
|
||||
assert.deepEqual(
|
||||
request.queryParams,
|
||||
stringifyValues(assign({ origin: 'start' }, params)),
|
||||
'Query params are correct'
|
||||
);
|
||||
assert.equal(find('[data-test-output]').textContent, 'Hello World');
|
||||
});
|
||||
|
||||
test('when mode is `tail`, the logger signals tail', async function(assert) {
|
||||
const url = '/file/endpoint';
|
||||
const params = { path: 'hello/world.txt', limit: 50000 };
|
||||
this.setProperties({
|
||||
logger: makeLogger(url, params),
|
||||
mode: 'tail',
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
await this.render(commonTemplate);
|
||||
await settled();
|
||||
|
||||
const request = this.server.handledRequests[0];
|
||||
assert.equal(this.server.handledRequests.length, 1, 'One request made');
|
||||
assert.equal(request.url.split('?')[0], url, `URL is ${url}`);
|
||||
assert.deepEqual(
|
||||
request.queryParams,
|
||||
stringifyValues(assign({ origin: 'end', offset: 50000 }, params)),
|
||||
'Query params are correct'
|
||||
);
|
||||
assert.equal(find('[data-test-output]').textContent, 'Hello World');
|
||||
});
|
||||
|
||||
test('when mode is `streaming` and `isStreaming` is true, streaming starts', async function(assert) {
|
||||
const url = '/file/stream';
|
||||
const params = { path: 'hello/world.txt', limit: 50000 };
|
||||
this.setProperties({
|
||||
logger: makeLogger(url, params),
|
||||
mode: 'streaming',
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
assert.ok(true);
|
||||
|
||||
run.later(run, run.cancelTimers, 500);
|
||||
|
||||
await this.render(commonTemplate);
|
||||
await settled();
|
||||
|
||||
const request = this.server.handledRequests[0];
|
||||
assert.equal(request.url.split('?')[0], url, `URL is ${url}`);
|
||||
assert.equal(find('[data-test-output]').textContent, 'Hello World');
|
||||
});
|
||||
});
|
||||
228
ui/tests/integration/task-file-test.js
Normal file
228
ui/tests/integration/task-file-test.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, settled } from '@ember/test-helpers';
|
||||
import { find } from 'ember-native-dom-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import Pretender from 'pretender';
|
||||
import { logEncode } from '../../mirage/data/logs';
|
||||
|
||||
const { assign } = Object;
|
||||
const HOST = '1.1.1.1:1111';
|
||||
|
||||
module('Integration | Component | task file', function(hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function() {
|
||||
this.server = new Pretender(function() {
|
||||
this.get('/v1/regions', () => [200, {}, JSON.stringify(['default'])]);
|
||||
this.get('/v1/client/fs/stream/:alloc_id', () => [200, {}, logEncode(['Hello World'], 0)]);
|
||||
this.get('/v1/client/fs/cat/:alloc_id', () => [200, {}, 'Hello World']);
|
||||
this.get('/v1/client/fs/readat/:alloc_id', () => [200, {}, 'Hello World']);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function() {
|
||||
this.server.shutdown();
|
||||
});
|
||||
|
||||
const commonTemplate = hbs`
|
||||
{{task-file allocation=allocation task=task file=file stat=stat}}
|
||||
`;
|
||||
|
||||
const fileStat = (type, size = 0) => ({
|
||||
stat: {
|
||||
Size: size,
|
||||
ContentType: type,
|
||||
},
|
||||
});
|
||||
const makeProps = (props = {}) =>
|
||||
assign(
|
||||
{},
|
||||
{
|
||||
allocation: {
|
||||
id: 'alloc-1',
|
||||
node: {
|
||||
httpAddr: HOST,
|
||||
},
|
||||
},
|
||||
task: {
|
||||
name: 'task-name',
|
||||
},
|
||||
file: 'path/to/file',
|
||||
stat: {
|
||||
Size: 12345,
|
||||
ContentType: 'text/plain',
|
||||
},
|
||||
},
|
||||
props
|
||||
);
|
||||
|
||||
test('When a file is text-based, the file mode is streaming', async function(assert) {
|
||||
const props = makeProps(fileStat('text/plain', 500));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.ok(
|
||||
find('[data-test-file-box] [data-test-log-cli]'),
|
||||
'The streaming file component was rendered'
|
||||
);
|
||||
assert.notOk(
|
||||
find('[data-test-file-box] [data-test-image-file]'),
|
||||
'The image file component was not rendered'
|
||||
);
|
||||
});
|
||||
|
||||
test('When a file is an image, the file mode is image', async function(assert) {
|
||||
const props = makeProps(fileStat('image/png', 1234));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.ok(
|
||||
find('[data-test-file-box] [data-test-image-file]'),
|
||||
'The image file component was rendered'
|
||||
);
|
||||
assert.notOk(
|
||||
find('[data-test-file-box] [data-test-log-cli]'),
|
||||
'The streaming file component was not rendered'
|
||||
);
|
||||
});
|
||||
|
||||
test('When the file is neither text-based or an image, the unsupported file type empty state is shown', async function(assert) {
|
||||
const props = makeProps(fileStat('wat/ohno', 1234));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.notOk(
|
||||
find('[data-test-file-box] [data-test-image-file]'),
|
||||
'The image file component was not rendered'
|
||||
);
|
||||
assert.notOk(
|
||||
find('[data-test-file-box] [data-test-log-cli]'),
|
||||
'The streaming file component was not rendered'
|
||||
);
|
||||
assert.ok(find('[data-test-unsupported-type]'), 'Unsupported file type message is shown');
|
||||
});
|
||||
|
||||
test('The unsupported file type empty state includes a link to the raw file', async function(assert) {
|
||||
const props = makeProps(fileStat('wat/ohno', 1234));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.ok(
|
||||
find('[data-test-unsupported-type] [data-test-log-action="raw"]'),
|
||||
'Unsupported file type message includes a link to the raw file'
|
||||
);
|
||||
|
||||
assert.notOk(
|
||||
find('[data-test-header] [data-test-log-action="raw"]'),
|
||||
'Raw link is no longer in the header'
|
||||
);
|
||||
});
|
||||
|
||||
test('The view raw button goes to the correct API url', async function(assert) {
|
||||
const props = makeProps(fileStat('image/png', 1234));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
const rawLink = find('[data-test-log-action="raw"]');
|
||||
assert.equal(
|
||||
rawLink.getAttribute('href'),
|
||||
`/v1/client/fs/cat/${props.allocation.id}?path=${encodeURIComponent(
|
||||
`${props.task.name}/${props.file}`
|
||||
)}`,
|
||||
'Raw link href is correct'
|
||||
);
|
||||
|
||||
assert.equal(rawLink.getAttribute('target'), '_blank', 'Raw link opens in a new tab');
|
||||
assert.equal(
|
||||
rawLink.getAttribute('rel'),
|
||||
'noopener noreferrer',
|
||||
'Raw link rel correctly bars openers and referrers'
|
||||
);
|
||||
});
|
||||
|
||||
test('The head and tail buttons are not shown when the file is small', async function(assert) {
|
||||
const props = makeProps(fileStat('application/json', 5000));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.notOk(find('[data-test-log-action="head"]'), 'No head button');
|
||||
assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button');
|
||||
|
||||
this.set('stat.Size', 100000);
|
||||
|
||||
await settled();
|
||||
|
||||
assert.ok(find('[data-test-log-action="head"]'), 'Head button is shown');
|
||||
assert.ok(find('[data-test-log-action="tail"]'), 'Tail button is shown');
|
||||
});
|
||||
|
||||
test('The head and tail buttons are not shown for an image file', async function(assert) {
|
||||
const props = makeProps(fileStat('image/svg', 5000));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
assert.notOk(find('[data-test-log-action="head"]'), 'No head button');
|
||||
assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button');
|
||||
|
||||
this.set('stat.Size', 100000);
|
||||
|
||||
await settled();
|
||||
|
||||
assert.notOk(find('[data-test-log-action="head"]'), 'Still no head button');
|
||||
assert.notOk(find('[data-test-log-action="tail"]'), 'Still no tail button');
|
||||
});
|
||||
|
||||
test('Yielded content goes in the top-left header area', async function(assert) {
|
||||
const props = makeProps(fileStat('image/svg', 5000));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(hbs`
|
||||
{{#task-file allocation=allocation task=task file=file stat=stat}}
|
||||
<div data-test-yield-spy>Yielded content</div>
|
||||
{{/task-file}}
|
||||
`);
|
||||
|
||||
assert.ok(
|
||||
find('[data-test-header] [data-test-yield-spy]'),
|
||||
'Yielded content shows up in the header'
|
||||
);
|
||||
});
|
||||
|
||||
test('The body is full-bleed and dark when the file is streaming', async function(assert) {
|
||||
const props = makeProps(fileStat('application/json', 5000));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
const classes = Array.from(find('[data-test-file-box]').classList);
|
||||
assert.ok(classes.includes('is-dark'), 'Body is dark');
|
||||
assert.ok(classes.includes('is-full-bleed'), 'Body is full-bleed');
|
||||
});
|
||||
|
||||
test('The body has padding and a light background when the file is not streaming', async function(assert) {
|
||||
const props = makeProps(fileStat('image/jpeg', 5000));
|
||||
this.setProperties(props);
|
||||
|
||||
await render(commonTemplate);
|
||||
|
||||
let classes = Array.from(find('[data-test-file-box]').classList);
|
||||
assert.notOk(classes.includes('is-dark'), 'Body is not dark');
|
||||
assert.notOk(classes.includes('is-full-bleed'), 'Body is not full-bleed');
|
||||
|
||||
this.set('stat.ContentType', 'something/unknown');
|
||||
|
||||
await settled();
|
||||
|
||||
classes = Array.from(find('[data-test-file-box]').classList);
|
||||
assert.notOk(classes.includes('is-dark'), 'Body is still not dark');
|
||||
assert.notOk(classes.includes('is-full-bleed'), 'Body is still not full-bleed');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
67
ui/tests/pages/allocations/task/fs.js
Normal file
67
ui/tests/pages/allocations/task/fs.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
attribute,
|
||||
collection,
|
||||
clickable,
|
||||
create,
|
||||
hasClass,
|
||||
isPresent,
|
||||
text,
|
||||
visitable,
|
||||
} from 'ember-cli-page-object';
|
||||
|
||||
export default create({
|
||||
visit: visitable('/allocations/:id/:name/fs'),
|
||||
visitPath: visitable('/allocations/:id/:name/fs/:path'),
|
||||
|
||||
fileViewer: {
|
||||
scope: '[data-test-file-viewer]',
|
||||
},
|
||||
|
||||
breadcrumbsText: text('[data-test-fs-breadcrumbs]'),
|
||||
|
||||
breadcrumbs: collection('[data-test-fs-breadcrumbs] li', {
|
||||
visit: clickable('a'),
|
||||
path: attribute('href', 'a'),
|
||||
isActive: hasClass('is-active'),
|
||||
}),
|
||||
|
||||
sortOptions: collection('[data-test-sort-by]', {
|
||||
id: attribute('data-test-sort-by'),
|
||||
sort: clickable(),
|
||||
}),
|
||||
|
||||
sortBy(id) {
|
||||
return this.sortOptions
|
||||
.toArray()
|
||||
.findBy('id', id)
|
||||
.sort();
|
||||
},
|
||||
|
||||
directoryEntries: collection('[data-test-entry]', {
|
||||
name: text('[data-test-name]'),
|
||||
|
||||
isFile: isPresent('.icon-is-file-outline'),
|
||||
isDirectory: isPresent('.icon-is-folder-outline'),
|
||||
|
||||
size: text('[data-test-size]'),
|
||||
lastModified: text('[data-test-last-modified]'),
|
||||
|
||||
visit: clickable('a'),
|
||||
path: attribute('href', 'a'),
|
||||
}),
|
||||
|
||||
isEmptyDirectory: isPresent('[data-test-empty-directory]'),
|
||||
|
||||
directoryEntryNames() {
|
||||
return this.directoryEntries.toArray().mapBy('name');
|
||||
},
|
||||
|
||||
hasEmptyState: isPresent('[data-test-not-running]'),
|
||||
emptyState: {
|
||||
headline: text('[data-test-not-running-headline]'),
|
||||
},
|
||||
|
||||
error: {
|
||||
title: text('[data-test-error-title]'),
|
||||
},
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
41
ui/tests/unit/utils/stream-frames-test.js
Normal file
41
ui/tests/unit/utils/stream-frames-test.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { decode } from 'nomad-ui/utils/stream-frames';
|
||||
|
||||
module('Unit | Util | stream-frames', function() {
|
||||
const { btoa } = window;
|
||||
const decodeTestCases = [
|
||||
{
|
||||
name: 'Single frame',
|
||||
in: `{"Offset":100,"Data":"${btoa('Hello World')}"}`,
|
||||
out: {
|
||||
offset: 100,
|
||||
message: 'Hello World',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Multiple frames',
|
||||
// prettier-ignore
|
||||
in: `{"Offset":1,"Data":"${btoa('One fish,')}"}{"Offset":2,"Data":"${btoa( ' Two fish.')}"}{"Offset":3,"Data":"${btoa(' Red fish, ')}"}{"Offset":4,"Data":"${btoa('Blue fish.')}"}`,
|
||||
out: {
|
||||
offset: 4,
|
||||
message: 'One fish, Two fish. Red fish, Blue fish.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Empty frames',
|
||||
in: '{}{}{}',
|
||||
out: {},
|
||||
},
|
||||
{
|
||||
name: 'Empty string',
|
||||
in: '',
|
||||
out: {},
|
||||
},
|
||||
];
|
||||
|
||||
decodeTestCases.forEach(testCase => {
|
||||
test(`decode: ${testCase.name}`, function(assert) {
|
||||
assert.deepEqual(decode(testCase.in), testCase.out);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user