Skip to content

Commit

Permalink
Merge pull request #5871 from hashicorp/f-ui/alloc-fs
Browse files Browse the repository at this point in the history
UI: Allocation file system explorer
  • Loading branch information
DingoEatingFuzz committed Aug 20, 2019
2 parents b8ebfb8 + 7b038ac commit c29d836
Show file tree
Hide file tree
Showing 51 changed files with 2,017 additions and 134 deletions.
39 changes: 39 additions & 0 deletions ui/app/adapters/task-state.js
Original file line number Diff line number Diff line change
@@ -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 changes: 42 additions & 0 deletions ui/app/components/fs-breadcrumbs.js
Original file line number Diff line number Diff line change
@@ -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 changes: 18 additions & 0 deletions ui/app/components/fs-directory-entry.js
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions ui/app/components/image-file.js
Original file line number Diff line number Diff line change
@@ -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 changes: 96 additions & 0 deletions ui/app/components/streaming-file.js
Original file line number Diff line number Diff line change
@@ -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 changes: 148 additions & 0 deletions ui/app/components/task-file.js
Original file line number Diff line number Diff line change
@@ -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);
},
},
});
Loading

0 comments on commit c29d836

Please sign in to comment.