Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Allocation file system explorer #5871

Merged
merged 57 commits into from
Aug 20, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
f25817c
Add new routes to the router
DingoEatingFuzz Jun 20, 2019
5e43366
Add new menu item to the task subnav
DingoEatingFuzz Jun 20, 2019
ca7c4d3
Some placeholder templates and routes for new feature
DingoEatingFuzz Jun 20, 2019
d39d0f0
Test support and coverage for fs routing
DingoEatingFuzz Jun 20, 2019
429e89f
Merge pull request #5866 from hashicorp/f-ui/alloc-fs-routing
DingoEatingFuzz Jun 21, 2019
464aeae
Merge branch 'master' into f-ui/alloc-fs
backspace Jun 27, 2019
ee07bab
UI: Add allocation directory rendering (#5873)
backspace Jul 2, 2019
c2e782b
Merge branch 'master' into f-ui/alloc-fs
DingoEatingFuzz Jul 2, 2019
44fc18e
Merge remote-tracking branch 'origin/master' into f-ui/alloc-fs
DingoEatingFuzz Jul 2, 2019
42a53ff
Merge branch 'master' into f-ui/alloc-fs
backspace Jul 4, 2019
fdca5cf
Remove superfluous test attributes (#5927)
backspace Jul 8, 2019
f046ec3
UI: Add allocation directory sorting (#5914)
backspace Jul 23, 2019
c0ba4ee
Merge branch 'master' into f-ui/alloc-fs
backspace Jul 26, 2019
a21ae52
Move common stream frame decoding to a util
DingoEatingFuzz Jun 26, 2019
07f1c89
Use the stream decode util and never opt to use the plain query param
DingoEatingFuzz Jun 26, 2019
871fe10
Tweak log window math
DingoEatingFuzz Jun 26, 2019
a993a30
New task-file component
DingoEatingFuzz Jun 27, 2019
caf48ee
Extract a streaming-file component from the task-log component
DingoEatingFuzz Jun 27, 2019
45c8f37
image-file component for showing an image and image metadata
DingoEatingFuzz Jul 2, 2019
8966bc0
Styles for the image-file component
DingoEatingFuzz Jul 2, 2019
0698e11
Address WindowResizable refactor
DingoEatingFuzz Jul 2, 2019
6ec7faf
Markup for the image-file component
DingoEatingFuzz Jul 2, 2019
8403cba
Refactored and image support of the task-file component
DingoEatingFuzz Jul 2, 2019
a609606
Add a plainText mode
DingoEatingFuzz Jul 2, 2019
c335ace
Custom Log instance to deal with API quirks
DingoEatingFuzz Jul 2, 2019
ccbe31f
Always escape < and > to avoid inadvertently rendering html
DingoEatingFuzz Jul 20, 2019
fea3731
Integrate the task-file component with the fs explorer pages
DingoEatingFuzz Jul 20, 2019
bd9d73b
Fix a bug where tail calls for files weren't getting the correct params
DingoEatingFuzz Jul 21, 2019
c4dd61d
Mirage factory for file system fixtures
DingoEatingFuzz Jul 25, 2019
97c4509
Use the alloc file factory for the fs stat and fs ls end points
DingoEatingFuzz Jul 25, 2019
33f1da9
cat, stream, and readat mocks for alloc fs
DingoEatingFuzz Jul 25, 2019
4b037bd
Add hollow variation to empty-message
DingoEatingFuzz Jul 25, 2019
55349dc
Add unsupported file type state
DingoEatingFuzz Jul 25, 2019
8f496e8
Refactor existing fs tests to use new mirage factories
DingoEatingFuzz Jul 26, 2019
7294f38
Integration tests for the image-file component
DingoEatingFuzz Jul 26, 2019
6bd54f7
Test coverage for streaming file component
DingoEatingFuzz Jul 31, 2019
c451615
Test coverage for task-file component
DingoEatingFuzz Jul 31, 2019
0471f28
Add file mocks to every mirage scenario
DingoEatingFuzz Jul 31, 2019
a465046
fixup-integrate-file-component
DingoEatingFuzz Jul 31, 2019
be5c88f
Remove errant server logging line
DingoEatingFuzz Jul 31, 2019
e277cc5
Update factory-based fs tests to sort properly
DingoEatingFuzz Jul 31, 2019
473ef7a
Add page titles to filesystem routes (#6024)
backspace Aug 1, 2019
34213f4
Remove transition animation from sort arrows (#6067)
backspace Aug 6, 2019
e97c911
Use a data-uri instead of an image for the image-file-test
DingoEatingFuzz Aug 7, 2019
55039b6
Minor fixes from code review
DingoEatingFuzz Aug 7, 2019
26e74fe
Make a dedicated fs-breadcrumbs component
DingoEatingFuzz Aug 7, 2019
55d8ff4
Add additional troublesome characters to the alloc-file name factory
DingoEatingFuzz Aug 7, 2019
186a620
Include all client fs endpoints in the hosts block
DingoEatingFuzz Aug 8, 2019
038fc27
Always preload the alloc node so the client can be dialed first
DingoEatingFuzz Aug 8, 2019
ed55a7b
Test that the client is contacted correctly, and the server is used a…
DingoEatingFuzz Aug 8, 2019
0fad368
Limit the width of the right page layout column
DingoEatingFuzz Aug 8, 2019
a728ed1
Prevent a change in height when switching from a dir to a file
DingoEatingFuzz Aug 8, 2019
a321145
Encode characters in file paths to ensure proper URIs
DingoEatingFuzz Aug 12, 2019
354da0f
Merge pull request #6048 from hashicorp/f-ui/alloc-fs-files
DingoEatingFuzz Aug 19, 2019
65aa475
Use the standard empty state when a dir is empty
DingoEatingFuzz Aug 20, 2019
1783a7a
Merge pull request #6158 from hashicorp/f-ui/alloc-fs-empty-dir-state
DingoEatingFuzz Aug 20, 2019
7b038ac
Remove the temporary allocationFileExplorer mirage scenario
DingoEatingFuzz Aug 20, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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