Skip to content

Commit c29d836

Browse files
Merge pull request #5871 from hashicorp/f-ui/alloc-fs
UI: Allocation file system explorer
2 parents b8ebfb8 + 7b038ac commit c29d836

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+2017
-134
lines changed

ui/app/adapters/task-state.js

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import ApplicationAdapter from './application';
2+
import { inject as service } from '@ember/service';
3+
4+
export default ApplicationAdapter.extend({
5+
token: service(),
6+
7+
ls(model, path) {
8+
return this.token
9+
.authorizedRequest(`/v1/client/fs/ls/${model.allocation.id}?path=${encodeURIComponent(path)}`)
10+
.then(handleFSResponse);
11+
},
12+
13+
stat(model, path) {
14+
return this.token
15+
.authorizedRequest(
16+
`/v1/client/fs/stat/${model.allocation.id}?path=${encodeURIComponent(path)}`
17+
)
18+
.then(handleFSResponse);
19+
},
20+
});
21+
22+
async function handleFSResponse(response) {
23+
if (response.ok) {
24+
return response.json();
25+
} else {
26+
const body = await response.text();
27+
28+
// TODO update this if/when endpoint returns 404 as expected
29+
const statusIs500 = response.status === 500;
30+
const bodyIncludes404Text = body.includes('no such file or directory');
31+
32+
const translatedCode = statusIs500 && bodyIncludes404Text ? 404 : response.status;
33+
34+
throw {
35+
code: translatedCode,
36+
toString: () => body,
37+
};
38+
}
39+
}

ui/app/components/fs-breadcrumbs.js

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Component from '@ember/component';
2+
import { computed } from '@ember/object';
3+
import { isEmpty } from '@ember/utils';
4+
5+
export default Component.extend({
6+
tagName: 'nav',
7+
classNames: ['breadcrumb'],
8+
9+
'data-test-fs-breadcrumbs': true,
10+
11+
task: null,
12+
path: null,
13+
14+
breadcrumbs: computed('path', function() {
15+
const breadcrumbs = this.path
16+
.split('/')
17+
.reject(isEmpty)
18+
.reduce((breadcrumbs, pathSegment, index) => {
19+
let breadcrumbPath;
20+
21+
if (index > 0) {
22+
const lastBreadcrumb = breadcrumbs[index - 1];
23+
breadcrumbPath = `${lastBreadcrumb.path}/${pathSegment}`;
24+
} else {
25+
breadcrumbPath = pathSegment;
26+
}
27+
28+
breadcrumbs.push({
29+
name: pathSegment,
30+
path: breadcrumbPath,
31+
});
32+
33+
return breadcrumbs;
34+
}, []);
35+
36+
if (breadcrumbs.length) {
37+
breadcrumbs[breadcrumbs.length - 1].isLast = true;
38+
}
39+
40+
return breadcrumbs;
41+
}),
42+
});
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Component from '@ember/component';
2+
import { computed } from '@ember/object';
3+
import { isEmpty } from '@ember/utils';
4+
5+
export default Component.extend({
6+
tagName: '',
7+
8+
pathToEntry: computed('path', 'entry.Name', function() {
9+
const pathWithNoLeadingSlash = this.get('path').replace(/^\//, '');
10+
const name = encodeURIComponent(this.get('entry.Name'));
11+
12+
if (isEmpty(pathWithNoLeadingSlash)) {
13+
return name;
14+
} else {
15+
return `${pathWithNoLeadingSlash}/${name}`;
16+
}
17+
}),
18+
});

ui/app/components/image-file.js

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Component from '@ember/component';
2+
import { computed } from '@ember/object';
3+
4+
export default Component.extend({
5+
tagName: 'figure',
6+
classNames: 'image-file',
7+
'data-test-image-file': true,
8+
9+
src: null,
10+
alt: null,
11+
size: null,
12+
13+
// Set by updateImageMeta
14+
width: 0,
15+
height: 0,
16+
17+
fileName: computed('src', function() {
18+
if (!this.src) return;
19+
return this.src.includes('/') ? this.src.match(/^.*\/(.*)$/)[1] : this.src;
20+
}),
21+
22+
updateImageMeta(event) {
23+
const img = event.target;
24+
this.setProperties({
25+
width: img.naturalWidth,
26+
height: img.naturalHeight,
27+
});
28+
},
29+
});

ui/app/components/streaming-file.js

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import Component from '@ember/component';
2+
import { run } from '@ember/runloop';
3+
import { task } from 'ember-concurrency';
4+
import WindowResizable from 'nomad-ui/mixins/window-resizable';
5+
6+
export default Component.extend(WindowResizable, {
7+
tagName: 'pre',
8+
classNames: ['cli-window'],
9+
'data-test-log-cli': true,
10+
11+
mode: 'streaming', // head, tail, streaming
12+
isStreaming: true,
13+
logger: null,
14+
15+
didReceiveAttrs() {
16+
if (!this.logger) {
17+
return;
18+
}
19+
20+
run.scheduleOnce('actions', () => {
21+
switch (this.mode) {
22+
case 'head':
23+
this.head.perform();
24+
break;
25+
case 'tail':
26+
this.tail.perform();
27+
break;
28+
case 'streaming':
29+
if (this.isStreaming) {
30+
this.stream.perform();
31+
} else {
32+
this.logger.stop();
33+
}
34+
break;
35+
}
36+
});
37+
},
38+
39+
didInsertElement() {
40+
this.fillAvailableHeight();
41+
},
42+
43+
windowResizeHandler() {
44+
run.once(this, this.fillAvailableHeight);
45+
},
46+
47+
fillAvailableHeight() {
48+
// This math is arbitrary and far from bulletproof, but the UX
49+
// of having the log window fill available height is worth the hack.
50+
const margins = 30; // Account for padding and margin on either side of the CLI
51+
const cliWindow = this.element;
52+
cliWindow.style.height = `${window.innerHeight - cliWindow.offsetTop - margins}px`;
53+
},
54+
55+
head: task(function*() {
56+
yield this.get('logger.gotoHead').perform();
57+
run.scheduleOnce('afterRender', () => {
58+
this.element.scrollTop = 0;
59+
});
60+
}),
61+
62+
tail: task(function*() {
63+
yield this.get('logger.gotoTail').perform();
64+
run.scheduleOnce('afterRender', () => {
65+
const cliWindow = this.element;
66+
cliWindow.scrollTop = cliWindow.scrollHeight;
67+
});
68+
}),
69+
70+
synchronizeScrollPosition(force = false) {
71+
const cliWindow = this.element;
72+
if (cliWindow.scrollHeight - cliWindow.scrollTop < 10 || force) {
73+
// If the window is approximately scrolled to the bottom, follow the log
74+
cliWindow.scrollTop = cliWindow.scrollHeight;
75+
}
76+
},
77+
78+
stream: task(function*() {
79+
// Force the scroll position to the bottom of the window when starting streaming
80+
this.logger.one('tick', () => {
81+
run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition(true));
82+
});
83+
84+
// Follow the log if the scroll position is near the bottom of the cli window
85+
this.logger.on('tick', () => {
86+
run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition());
87+
});
88+
89+
yield this.logger.startStreaming();
90+
this.logger.off('tick');
91+
}),
92+
93+
willDestroy() {
94+
this.logger.stop();
95+
},
96+
});

ui/app/components/task-file.js

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { inject as service } from '@ember/service';
2+
import Component from '@ember/component';
3+
import { computed } from '@ember/object';
4+
import { gt } from '@ember/object/computed';
5+
import { equal } from '@ember/object/computed';
6+
import RSVP from 'rsvp';
7+
import Log from 'nomad-ui/utils/classes/log';
8+
import timeout from 'nomad-ui/utils/timeout';
9+
10+
export default Component.extend({
11+
token: service(),
12+
13+
classNames: ['boxed-section', 'task-log'],
14+
15+
'data-test-file-viewer': true,
16+
17+
allocation: null,
18+
task: null,
19+
file: null,
20+
stat: null, // { Name, IsDir, Size, FileMode, ModTime, ContentType }
21+
22+
// When true, request logs from the server agent
23+
useServer: false,
24+
25+
// When true, logs cannot be fetched from either the client or the server
26+
noConnection: false,
27+
28+
clientTimeout: 1000,
29+
serverTimeout: 5000,
30+
31+
mode: 'head',
32+
33+
fileComponent: computed('stat.ContentType', function() {
34+
const contentType = this.stat.ContentType || '';
35+
36+
if (contentType.startsWith('image/')) {
37+
return 'image';
38+
} else if (contentType.startsWith('text/') || contentType.startsWith('application/json')) {
39+
return 'stream';
40+
} else {
41+
return 'unknown';
42+
}
43+
}),
44+
45+
isLarge: gt('stat.Size', 50000),
46+
47+
fileTypeIsUnknown: equal('fileComponent', 'unknown'),
48+
isStreamable: equal('fileComponent', 'stream'),
49+
isStreaming: false,
50+
51+
catUrl: computed('allocation.id', 'task.name', 'file', function() {
52+
const encodedPath = encodeURIComponent(`${this.task.name}/${this.file}`);
53+
return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`;
54+
}),
55+
56+
fetchMode: computed('isLarge', 'mode', function() {
57+
if (this.mode === 'streaming') {
58+
return 'stream';
59+
}
60+
61+
if (!this.isLarge) {
62+
return 'cat';
63+
} else if (this.mode === 'head' || this.mode === 'tail') {
64+
return 'readat';
65+
}
66+
}),
67+
68+
fileUrl: computed(
69+
'allocation.id',
70+
'allocation.node.httpAddr',
71+
'fetchMode',
72+
'useServer',
73+
function() {
74+
const address = this.get('allocation.node.httpAddr');
75+
const url = `/v1/client/fs/${this.fetchMode}/${this.allocation.id}`;
76+
return this.useServer ? url : `//${address}${url}`;
77+
}
78+
),
79+
80+
fileParams: computed('task.name', 'file', 'mode', function() {
81+
// The Log class handles encoding query params
82+
const path = `${this.task.name}/${this.file}`;
83+
84+
switch (this.mode) {
85+
case 'head':
86+
return { path, offset: 0, limit: 50000 };
87+
case 'tail':
88+
return { path, offset: this.stat.Size - 50000, limit: 50000 };
89+
case 'streaming':
90+
return { path, offset: 50000, origin: 'end' };
91+
default:
92+
return { path };
93+
}
94+
}),
95+
96+
logger: computed('fileUrl', 'fileParams', 'mode', function() {
97+
// The cat and readat APIs are in plainText while the stream API is always encoded.
98+
const plainText = this.mode === 'head' || this.mode === 'tail';
99+
100+
// If the file request can't settle in one second, the client
101+
// must be unavailable and the server should be used instead
102+
const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
103+
const logFetch = url =>
104+
RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then(
105+
response => {
106+
if (!response || !response.ok) {
107+
this.nextErrorState(response);
108+
}
109+
return response;
110+
},
111+
error => this.nextErrorState(error)
112+
);
113+
114+
return Log.create({
115+
logFetch,
116+
plainText,
117+
params: this.fileParams,
118+
url: this.fileUrl,
119+
});
120+
}),
121+
122+
nextErrorState(error) {
123+
if (this.useServer) {
124+
this.set('noConnection', true);
125+
} else {
126+
this.send('failoverToServer');
127+
}
128+
throw error;
129+
},
130+
131+
actions: {
132+
toggleStream() {
133+
this.set('mode', 'streaming');
134+
this.toggleProperty('isStreaming');
135+
},
136+
gotoHead() {
137+
this.set('mode', 'head');
138+
this.set('isStreaming', false);
139+
},
140+
gotoTail() {
141+
this.set('mode', 'tail');
142+
this.set('isStreaming', false);
143+
},
144+
failoverToServer() {
145+
this.set('useServer', true);
146+
},
147+
},
148+
});

0 commit comments

Comments
 (0)