Merge pull request #3564 from hashicorp/f-ui-log-streaming

UI: Log streaming
This commit is contained in:
Michael Lange
2017-11-29 09:36:41 -08:00
committed by GitHub
34 changed files with 961 additions and 17 deletions

View File

@@ -158,7 +158,7 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/v1/acl/token", s.wrap(s.ACLTokenSpecificRequest))
s.mux.HandleFunc("/v1/acl/token/", s.wrap(s.ACLTokenSpecificRequest))
s.mux.HandleFunc("/v1/client/fs/", s.wrap(s.FsRequest))
s.mux.Handle("/v1/client/fs/", wrapCORS(s.wrap(s.FsRequest)))
s.mux.HandleFunc("/v1/client/gc", s.wrap(s.ClientGCRequest))
s.mux.Handle("/v1/client/stats", wrapCORS(s.wrap(s.ClientStatsRequest)))
s.mux.Handle("/v1/client/allocation/", wrapCORS(s.wrap(s.ClientAllocRequest)))

View File

@@ -16,5 +16,11 @@ module.exports = {
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single', 'avoid-escape'],
semi: ['error', 'always'],
'no-constant-condition': [
'error',
{
checkLoops: false,
},
],
},
};

View File

@@ -21,11 +21,9 @@ export default Component.extend({
meta.showDate = true;
} else {
const previousVersion = versions.objectAt(index - 1);
if (
moment(previousVersion.get('submitTime'))
.startOf('day')
.diff(moment(version.get('submitTime')).startOf('day'), 'days') > 0
) {
const previousStart = moment(previousVersion.get('submitTime')).startOf('day');
const currentStart = moment(version.get('submitTime')).startOf('day');
if (previousStart.diff(currentStart, 'days') > 0) {
meta.showDate = true;
}
}

View File

@@ -0,0 +1,103 @@
import Ember from 'ember';
import { task } from 'ember-concurrency';
import { logger } from 'nomad-ui/utils/classes/log';
import WindowResizable from 'nomad-ui/mixins/window-resizable';
const { Component, computed, inject, run } = Ember;
export default Component.extend(WindowResizable, {
token: inject.service(),
classNames: ['boxed-section', 'task-log'],
allocation: null,
task: null,
didReceiveAttrs() {
if (this.get('allocation') && this.get('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);
},
mode: 'stdout',
logUrl: computed('allocation.id', 'allocation.node.httpAddr', function() {
const address = this.get('allocation.node.httpAddr');
const allocation = this.get('allocation.id');
return `//${address}/v1/client/fs/logs/${allocation}`;
}),
logParams: computed('task', 'mode', function() {
return {
task: this.get('task'),
type: this.get('mode'),
};
}),
logger: logger('logUrl', 'logParams', function() {
const token = this.get('token');
return token.authorizedRequest.bind(token);
}),
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.get('logger').on('tick', () => {
run.scheduleOnce('afterRender', () => {
const cliWindow = this.$('.cli-window');
cliWindow.scrollTop(cliWindow[0].scrollHeight);
});
});
yield this.get('logger').startStreaming();
this.get('logger').off('tick');
}),
willDestroy() {
this.get('logger').stop();
},
actions: {
setMode(mode) {
this.get('logger').stop();
this.set('mode', mode);
this.get('stream').perform();
},
toggleStream() {
if (this.get('logger.isStreaming')) {
this.get('logger').stop();
} else {
this.get('stream').perform();
}
},
},
});

View File

@@ -5,14 +5,14 @@ const { Controller, computed } = Ember;
export default Controller.extend({
network: computed.alias('model.resources.networks.firstObject'),
ports: computed('network.reservedPorts.[]', 'network.dynamicPorts.[]', function() {
return this.get('network.reservedPorts')
return (this.get('network.reservedPorts') || [])
.map(port => ({
name: port.Label,
port: port.Value,
isDynamic: false,
}))
.concat(
this.get('network.dynamicPorts').map(port => ({
(this.get('network.dynamicPorts') || []).map(port => ({
name: port.Label,
port: port.Value,
isDynamic: true,

View File

@@ -0,0 +1,10 @@
import Ember from 'ember';
const { Route } = Ember;
export default Route.extend({
model() {
const task = this._super(...arguments);
return task.get('allocation.node').then(() => task);
},
});

View File

@@ -1,5 +1,5 @@
import Ember from 'ember';
import fetch from 'fetch';
import fetch from 'nomad-ui/utils/fetch';
const { Service, computed, assign } = Ember;

View File

@@ -1,6 +1,7 @@
@import "./components/badge";
@import "./components/boxed-section";
@import "./components/breadcrumbs";
@import "./components/cli-window";
@import "./components/ember-power-select";
@import "./components/empty-message";
@import "./components/error-container";

View File

@@ -1,6 +1,10 @@
.boxed-section {
margin-bottom: 1.5em;
&:last-child {
margin-bottom: 0;
}
.boxed-section-head,
.boxed-section-foot {
padding: 0.75em 1.5em;

View File

@@ -0,0 +1,11 @@
.cli-window {
background: transparent;
color: $white;
height: 500px;
overflow: auto;
.is-light {
color: $text;
}
}

View File

@@ -4,16 +4,24 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
font-weight: $weight-bold;
box-shadow: $button-box-shadow-standard;
border: 1px solid transparent;
text-decoration: none;
&:hover,
&.is-hovered {
text-decoration: none;
}
&:active,
&.is-active,
&:focus,
&.is-focused {
box-shadow: $button-box-shadow-standard;
text-decoration: none;
}
&.is-inverted.is-outlined {
box-shadow: none;
background: transparent;
}
&.is-compact {
@@ -90,7 +98,7 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
&:active,
&.is-active {
background-color: rgba($color-invert, 0.2);
background-color: rgba($color-invert, 0.1);
border-color: $color-invert;
color: $color-invert;
box-shadow: none;
@@ -98,4 +106,21 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2);
}
}
}
// When an icon in a button should be treated like text,
// override the default Bulma behavior
.icon.is-text {
&:first-child:not(:last-child) {
margin-left: 0;
margin-right: 0;
}
&:last-child:not(:first-child) {
margin-left: 0;
margin-right: 0;
}
&:first-child:last-child {
margin-left: 0;
margin-right: 0;
}
}
}

View File

@@ -10,9 +10,10 @@ $icon-dimensions-large: 2rem;
vertical-align: text-top;
height: $icon-dimensions;
width: $icon-dimensions;
fill: lighten($text, 25%);
fill: $text;
&.is-small {
&.is-small,
&.is-text {
height: $icon-dimensions-small;
width: $icon-dimensions-small;
}

View File

@@ -0,0 +1,15 @@
{{#global-header class="page-header"}}
<span class="breadcrumb">Allocations</span>
{{#link-to "allocations.allocation" model.allocation class="breadcrumb"}}
{{model.allocation.shortId}}
{{/link-to}}
{{#link-to "allocations.allocation.task" model.allocation model class="breadcrumb"}}
{{model.name}}
{{/link-to}}
{{/global-header}}
{{#gutter-menu class="page-body"}}
{{partial "allocations/allocation/task/subnav"}}
<section class="section">
{{task-log allocation=model.allocation task=model.name}}
</section>
{{/gutter-menu}}

View File

@@ -1,5 +1,6 @@
<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>

View File

@@ -0,0 +1,16 @@
<div class="boxed-section-head">
<span>
<button class="button {{if (eq mode "stdout") "is-info"}} action-stdout" {{action "setMode" "stdout"}}>stdout</button>
<button class="button {{if (eq mode "stderr") "is-danger"}} action-stderr" {{action "setMode" "stderr"}}>stderr</button>
</span>
<span class="pull-right">
<button class="button is-white action-head" onclick={{perform head}}>Head</button>
<button class="button is-white action-tail" onclick={{perform tail}}>Tail</button>
<button class="button is-white action-toggle-stream" onclick={{action "toggleStream"}}>
{{x-icon (if logger.isStreaming "media-pause" "media-play") class="is-text"}}
</button>
</span>
</div>
<div class="boxed-section-body is-dark is-full-bleed">
<pre class="cli-window"><code>{{logger.output}}</code></pre>
</div>

View File

@@ -0,0 +1,33 @@
import Ember from 'ember';
import queryString from 'npm:query-string';
const { Mixin, computed, assign } = Ember;
const MAX_OUTPUT_LENGTH = 50000;
export default Mixin.create({
url: '',
params: computed(() => ({})),
logFetch() {
Ember.assert(
'Loggers need a logFetch method, which should have an interface like window.fetch'
);
},
endOffset: null,
offsetParams: computed('endOffset', function() {
const endOffset = this.get('endOffset');
return endOffset
? { origin: 'start', offset: endOffset }
: { origin: 'end', offset: MAX_OUTPUT_LENGTH };
}),
additionalParams: computed(() => ({})),
fullUrl: computed('url', 'params', 'offsetParams', 'additionalParams', function() {
const queryParams = queryString.stringify(
assign({}, this.get('params'), this.get('offsetParams'), this.get('additionalParams'))
);
return `${this.get('url')}?${queryParams}`;
}),
});

125
ui/app/utils/classes/log.js Normal file
View File

@@ -0,0 +1,125 @@
import Ember from 'ember';
import queryString from 'npm: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';
const { Object: EmberObject, Evented, computed, assign } = Ember;
const MAX_OUTPUT_LENGTH = 50000;
const Log = EmberObject.extend(Evented, {
// Parameters
url: '',
params: computed(() => ({})),
logFetch() {
Ember.assert(
'Log objects need a logFetch method, which should have an interface like window.fetch'
);
},
// Read-only state
isStreaming: computed.alias('logStreamer.poll.isRunning'),
logPointer: null,
logStreamer: null,
// The top of the log
head: '',
// The bottom of the log
tail: '',
// The top or bottom of the log, depending on whether
// the logPointer is pointed at head or tail
output: computed('logPointer', 'head', 'tail', function() {
return this.get('logPointer') === 'head' ? this.get('head') : this.get('tail');
}),
init() {
this._super();
const args = this.getProperties('url', 'params', 'logFetch');
args.write = chunk => {
let newTail = this.get('tail') + chunk;
if (newTail.length > MAX_OUTPUT_LENGTH) {
newTail = newTail.substr(newTail.length - MAX_OUTPUT_LENGTH);
}
this.set('tail', newTail);
this.trigger('tick', chunk);
};
if (StreamLogger.isSupported) {
this.set('logStreamer', StreamLogger.create(args));
} else {
this.set('logStreamer', PollLogger.create(args));
}
},
destroy() {
this.stop();
this._super();
},
gotoHead: task(function*() {
const logFetch = this.get('logFetch');
const queryParams = queryString.stringify(
assign(this.get('params'), {
plain: true,
origin: 'start',
offset: 0,
})
);
const url = `${this.get('url')}?${queryParams}`;
this.stop();
let text = yield logFetch(url).then(res => res.text());
if (text.length > MAX_OUTPUT_LENGTH) {
text = text.substr(0, MAX_OUTPUT_LENGTH);
text += '\n\n---------- TRUNCATED: Click "tail" to view the bottom of the log ----------';
}
this.set('head', text);
this.set('logPointer', 'head');
}),
gotoTail: task(function*() {
const logFetch = this.get('logFetch');
const queryParams = queryString.stringify(
assign(this.get('params'), {
plain: true,
origin: 'end',
offset: MAX_OUTPUT_LENGTH,
})
);
const url = `${this.get('url')}?${queryParams}`;
this.stop();
let text = yield logFetch(url).then(res => res.text());
this.set('tail', text);
this.set('logPointer', 'tail');
}),
startStreaming() {
this.set('logPointer', 'tail');
return this.get('logStreamer').start();
},
stop() {
this.get('logStreamer').stop();
},
});
export default Log;
export function logger(urlProp, params, logFetch) {
return computed(urlProp, params, function() {
return Log.create({
logFetch: logFetch.call(this),
params: this.get(params),
url: this.get(urlProp),
});
});
}

View File

@@ -0,0 +1,35 @@
import Ember from 'ember';
import { task, timeout } from 'ember-concurrency';
import AbstractLogger from './abstract-logger';
const { Object: EmberObject } = Ember;
export default EmberObject.extend(AbstractLogger, {
interval: 1000,
start() {
return this.get('poll').perform();
},
stop() {
return this.get('poll').cancelAll();
},
poll: task(function*() {
const { interval, logFetch } = this.getProperties('interval', 'logFetch');
while (true) {
let text = yield logFetch(this.get('fullUrl')).then(res => res.text());
if (text) {
const lines = text.replace(/\}\{/g, '}\n{').split('\n');
const frames = lines.map(line => JSON.parse(line));
frames.forEach(frame => (frame.Data = window.atob(frame.Data)));
this.set('endOffset', frames[frames.length - 1].Offset);
this.get('write')(frames.mapBy('Data').join(''));
}
yield timeout(interval);
}
}),
});

View File

@@ -0,0 +1,74 @@
import Ember from 'ember';
import { task } from 'ember-concurrency';
import TextDecoder from 'nomad-ui/utils/classes/text-decoder';
import AbstractLogger from './abstract-logger';
const { Object: EmberObject, computed } = Ember;
export default EmberObject.extend(AbstractLogger, {
reader: null,
additionalParams: computed(() => ({
follow: true,
})),
start() {
return this.get('poll').perform();
},
stop() {
const reader = this.get('reader');
if (reader) {
reader.cancel();
}
return this.get('poll').cancelAll();
},
poll: task(function*() {
const url = this.get('fullUrl');
const logFetch = this.get('logFetch');
let streamClosed = false;
let buffer = '';
const decoder = new TextDecoder();
const reader = yield logFetch(url).then(res => res.body.getReader());
this.set('reader', reader);
while (!streamClosed) {
yield reader.read().then(({ value, done }) => {
streamClosed = done;
// There is no guarantee that value will be a complete JSON object,
// so it needs to be buffered.
buffer += decoder.decode(value, { stream: true });
// Only when the buffer contains a close bracket can we be sure the buffer
// is in a complete state
if (buffer.indexOf('}') !== -1) {
// The buffer can be one or more complete frames with additional text for the
// next frame
const [, chunk, newBuffer] = buffer.match(/(.*\})(.*)$/);
// Peel chunk off the front of the buffer (since it represents complete frames)
// and set the buffer to be the remainder
buffer = newBuffer;
// 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.get('write')(frames.mapBy('Data').join(''));
}
}
});
}
}),
}).reopenClass({
isSupported: !!window.ReadableStream,
});

View File

@@ -0,0 +1,16 @@
// This is a very incomplete polyfill for TextDecoder used only
// by browsers that don't provide one but still provide a ReadableStream
// interface for fetch.
// A complete polyfill exists if this becomes problematic:
// https://github.com/inexorabletash/text-encoding
export default window.TextDecoder ||
function() {
this.decode = function(value) {
let text = '';
for (let i = 3; i < value.byteLength; i++) {
text += String.fromCharCode(value[i]);
}
return text;
};
};

8
ui/app/utils/fetch.js Normal file
View File

@@ -0,0 +1,8 @@
import Ember from 'ember';
import fetch from 'fetch';
// The ember-fetch polyfill does not provide streaming
// Additionally, Mirage/Pretender does not support fetch
const fetchToUse = Ember.testing ? fetch : window.fetch || fetch;
export default fetchToUse;

View File

@@ -1,6 +1,7 @@
import Ember from 'ember';
import Response from 'ember-cli-mirage/response';
import { HOSTS } from './common';
import { logFrames, logEncode } from './data/logs';
const { copy } = Ember;
@@ -166,6 +167,24 @@ export default function() {
this.get(`http://${host}/v1/client/stats`, function({ clientStats }) {
return this.serialize(clientStats.find(host));
});
this.get(`http://${host}/v1/client/fs/logs/:allocation_id`, function(
server,
{ params, queryParams }
) {
const allocation = server.allocations.find(params.allocation_id);
const tasks = allocation.taskStateIds.map(id => server.taskStates.find(id));
if (!tasks.mapBy('name').includes(queryParams.task)) {
return new Response(400, {}, 'must include task name');
}
if (queryParams.plain) {
return logFrames.join('');
}
return logEncode(logFrames, logFrames.length - 1);
});
});
}

16
ui/mirage/data/logs.js Normal file
View File

@@ -0,0 +1,16 @@
export const logFrames = [
'hello world\n',
'some more output\ngoes here\n\n--> potentially helpful',
' hopefully, at least.\n',
];
export const logEncode = (frames, index) => {
return frames
.slice(0, index + 1)
.map(frame => window.btoa(frame))
.map((frame, innerIndex) => {
const offset = frames.slice(0, innerIndex).reduce((sum, frame) => sum + frame.length, 0);
return JSON.stringify({ Offset: offset, Data: frame });
})
.join('');
};

View File

@@ -62,7 +62,7 @@ export default Factory.extend({
// Each node has a corresponding client stats resource that's queried via node IP.
// Create that record, even though it's not a relationship.
server.create('client-stats', {
id: node.http_addr,
id: node.httpAddr,
});
},
});

View File

@@ -20,7 +20,10 @@
"prettier --single-quote --trailing-comma es5 --print-width 100 --write",
"git add"
],
"ui/app/styles/**/*.*": ["prettier --write", "git add"]
"ui/app/styles/**/*.*": [
"prettier --write",
"git add"
]
}
},
"devDependencies": {
@@ -48,6 +51,7 @@
"ember-cli-string-helpers": "^1.4.0",
"ember-cli-uglify": "^1.2.0",
"ember-composable-helpers": "^2.0.3",
"ember-concurrency": "^0.8.12",
"ember-data": "^2.14.0",
"ember-data-model-fragments": "^2.14.0",
"ember-export-application-global": "^2.0.0",
@@ -78,6 +82,9 @@
},
"private": true,
"ember-addon": {
"paths": ["lib/bulma", "lib/calendar"]
"paths": [
"lib/bulma",
"lib/calendar"
]
}
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
<path d="M0 0v6h2v-6h-2zm4 0v6h2v-6h-2z" transform="translate(1 1)" />
</svg>

After

Width:  |  Height:  |  Size: 160 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
<path d="M0 0v6l6-3-6-3z" transform="translate(1 1)" />
</svg>

After

Width:  |  Height:  |  Size: 145 B

View File

@@ -69,7 +69,7 @@ test('breadcrumbs includes allocations and link to the allocation detail page',
test('the addresses table lists all reserved and dynamic ports', function(assert) {
const taskResources = allocation.taskResourcesIds
.map(id => server.db.taskResources.find(id))
.sortBy('name')[0];
.find(resources => resources.name === task.name);
const reservedPorts = taskResources.resources.Networks[0].ReservedPorts;
const dynamicPorts = taskResources.resources.Networks[0].DynamicPorts;
const addresses = reservedPorts.concat(dynamicPorts);

View File

@@ -0,0 +1,37 @@
import Ember from 'ember';
import { find } from 'ember-native-dom-helpers';
import { test } from 'qunit';
import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
const { run } = Ember;
let allocation;
let task;
moduleForAcceptance('Acceptance | task logs', {
beforeEach() {
server.create('agent');
server.create('node', 'forceIPv4');
const job = server.create('job');
allocation = server.db.allocations.where({ jobId: job.id })[0];
task = server.db.taskStates.where({ allocationId: allocation.id })[0];
run.later(run, run.cancelTimers, 1000);
visit(`/allocations/${allocation.id}/${task.name}/logs`);
},
});
test('/allocation/:id/:task_name/logs should have a log component', function(assert) {
assert.equal(currentURL(), `/allocations/${allocation.id}/${task.name}/logs`, 'No redirect');
assert.ok(find('.task-log'), 'Task log component found');
});
test('the stdout log immediately starts streaming', function(assert) {
const node = server.db.nodes.find(allocation.nodeId);
const logUrlRegex = new RegExp(`${node.httpAddr}/v1/client/fs/logs/${allocation.id}`);
assert.ok(
server.pretender.handledRequests.filter(req => logUrlRegex.test(req.url)).length,
'Log requests were made'
);
});

View File

@@ -21,6 +21,11 @@
{{content-for "body"}}
{{content-for "test-body"}}
<script>
// Spoof a lack of ReadableStream support to use XHRs instead of Fetch
window.__ReadableStream = window.ReadableStream;
window.ReadableStream = undefined;
</script>
<script src="/testem.js" integrity=""></script>
<script src="{{rootURL}}assets/vendor.js"></script>
<script src="{{rootURL}}assets/test-support.js"></script>

View File

@@ -0,0 +1,174 @@
import Ember from 'ember';
import { test, moduleForComponent } from 'ember-qunit';
import wait from 'ember-test-helpers/wait';
import { find, click } from 'ember-native-dom-helpers';
import hbs from 'htmlbars-inline-precompile';
import Pretender from 'pretender';
import { logEncode } from '../../mirage/data/logs';
const { run } = Ember;
const HOST = '1.1.1.1:1111';
const commonProps = {
interval: 50,
allocation: {
id: 'alloc-1',
node: {
httpAddr: HOST,
},
},
task: 'task-name',
};
const logHead = ['HEAD'];
const logTail = ['TAIL'];
const streamFrames = ['one\n', 'two\n', 'three\n', 'four\n', 'five\n'];
let streamPointer = 0;
moduleForComponent('task-log', 'Integration | Component | task log', {
integration: true,
beforeEach() {
this.server = new Pretender(function() {
this.get(`http://${HOST}/v1/client/fs/logs/:allocation_id`, ({ queryParams }) => {
const { origin, offset, plain, follow } = queryParams;
let frames;
let data;
if (origin === 'start' && offset === '0' && plain && !follow) {
frames = logHead;
} else if (origin === 'end' && plain && !follow) {
frames = logTail;
} else {
frames = streamFrames;
}
if (frames === streamFrames) {
data = queryParams.plain ? frames[streamPointer] : logEncode(frames, streamPointer);
streamPointer++;
} else {
data = queryParams.plain ? frames.join('') : logEncode(frames, frames.length - 1);
}
return [200, {}, data];
});
});
},
afterEach() {
this.server.shutdown();
streamPointer = 0;
},
});
test('Basic appearance', function(assert) {
this.setProperties(commonProps);
this.render(hbs`{{task-log allocation=allocation task=task}}`);
assert.ok(find('.action-stdout'), 'Stdout button');
assert.ok(find('.action-stderr'), 'Stderr button');
assert.ok(find('.action-head'), 'Head button');
assert.ok(find('.action-tail'), 'Tail button');
assert.ok(find('.action-toggle-stream'), 'Stream toggle button');
assert.ok(find('.boxed-section-body.is-full-bleed.is-dark'), 'Body is full-bleed and dark');
assert.ok(find('pre.cli-window'), 'Cli is preformatted and using the cli-window component class');
});
test('Streaming starts on creation', function(assert) {
run.later(run, run.cancelTimers, commonProps.interval);
this.setProperties(commonProps);
this.render(hbs`{{task-log allocation=allocation task=task}}`);
const logUrlRegex = new RegExp(`${HOST}/v1/client/fs/logs/${commonProps.allocation.id}`);
assert.ok(
this.server.handledRequests.filter(req => logUrlRegex.test(req.url)).length,
'Log requests were made'
);
return wait().then(() => {
assert.equal(
find('.cli-window').textContent,
streamFrames[0],
'First chunk of streaming log is shown'
);
});
});
test('Clicking Head loads the log head', function(assert) {
this.setProperties(commonProps);
this.render(hbs`{{task-log allocation=allocation task=task}}`);
click('.action-head');
return wait().then(() => {
assert.ok(
this.server.handledRequests.find(
({ queryParams: qp }) => qp.origin === 'start' && qp.plain === 'true' && qp.offset === '0'
),
'Log head request was made'
);
assert.equal(find('.cli-window').textContent, logHead[0], 'Head of the log is shown');
});
});
test('Clicking Tail loads the log tail', function(assert) {
this.setProperties(commonProps);
this.render(hbs`{{task-log allocation=allocation task=task}}`);
click('.action-tail');
return wait().then(() => {
assert.ok(
this.server.handledRequests.find(
({ queryParams: qp }) => qp.origin === 'end' && qp.plain === 'true'
),
'Log tail request was made'
);
assert.equal(find('.cli-window').textContent, logTail[0], 'Tail of the log is shown');
});
});
test('Clicking toggleStream starts and stops the log stream', function(assert) {
const { interval } = commonProps;
this.setProperties(commonProps);
this.render(hbs`{{task-log allocation=allocation task=task interval=interval}}`);
run.later(() => {
click('.action-toggle-stream');
}, interval);
return wait().then(() => {
assert.equal(find('.cli-window').textContent, streamFrames[0], 'First frame loaded');
run.later(() => {
assert.equal(find('.cli-window').textContent, streamFrames[0], 'Still only first frame');
click('.action-toggle-stream');
run.later(run, run.cancelTimers, interval * 2);
}, interval * 2);
return wait().then(() => {
assert.equal(
find('.cli-window').textContent,
streamFrames[0] + streamFrames[0] + streamFrames[1],
'Now includes second frame'
);
});
});
});
test('Clicking stderr switches the log to standard error', function(assert) {
this.setProperties(commonProps);
this.render(hbs`{{task-log allocation=allocation task=task}}`);
click('.action-stderr');
run.later(run, run.cancelTimers, commonProps.interval);
return wait().then(() => {
assert.ok(
this.server.handledRequests.filter(req => req.queryParams.type === 'stderr').length,
'stderr log requests were made'
);
});
});

View File

@@ -1,7 +1,7 @@
import { module, test } from 'ember-qunit';
import { formatBytes } from 'nomad-ui/helpers/format-bytes';
module('format-bytes', 'Unit | Helper | format-bytes');
module('Unit | Helper | format-bytes');
test('formats null/undefined as 0 bytes', function(assert) {
assert.equal(formatBytes([undefined]), '0 Bytes');

View File

@@ -0,0 +1,155 @@
import Ember from 'ember';
import sinon from 'sinon';
import wait from 'ember-test-helpers/wait';
import { module, test } from 'ember-qunit';
import _Log from 'nomad-ui/utils/classes/log';
const { Object: EmberObject, RSVP, run } = Ember;
let startSpy, stopSpy, initSpy, fetchSpy;
const MockStreamer = EmberObject.extend({
poll: {
isRunning: false,
},
init() {
initSpy(...arguments);
},
start() {
this.get('poll').isRunning = true;
startSpy(...arguments);
},
stop() {
this.get('poll').isRunning = true;
stopSpy(...arguments);
},
step(chunk) {
if (this.get('poll').isRunning) {
this.get('write')(chunk);
}
},
});
const Log = _Log.extend({
init() {
this._super();
const props = this.get('logStreamer').getProperties('url', 'params', 'logFetch', 'write');
this.set('logStreamer', MockStreamer.create(props));
},
});
module('Unit | Util | Log', {
beforeEach() {
initSpy = sinon.spy();
startSpy = sinon.spy();
stopSpy = sinon.spy();
fetchSpy = sinon.spy();
},
});
const makeMocks = output => ({
url: '/test-url/',
params: {
a: 'param',
another: 'one',
},
logFetch: function() {
fetchSpy(...arguments);
return RSVP.Promise.resolve({
text() {
return output;
},
});
},
});
test('logStreamer is created on init', function(assert) {
const log = Log.create(makeMocks(''));
assert.ok(log.get('logStreamer'), 'logStreamer property is defined');
assert.ok(initSpy.calledOnce, 'logStreamer init was called');
});
test('gotoHead builds the correct URL', function(assert) {
const mocks = makeMocks('');
const expectedUrl = `${mocks.url}?a=param&another=one&offset=0&origin=start&plain=true`;
const log = Log.create(mocks);
run(() => {
log.get('gotoHead').perform();
assert.ok(fetchSpy.calledWith(expectedUrl), `gotoHead URL was ${expectedUrl}`);
});
});
test('When gotoHead returns too large of a log, the log is truncated', function(assert) {
const longLog = Array(50001)
.fill('a')
.join('');
const truncationMessage =
'\n\n---------- TRUNCATED: Click "tail" to view the bottom of the log ----------';
const mocks = makeMocks(longLog);
const log = Log.create(mocks);
run(() => {
log.get('gotoHead').perform();
});
return wait().then(() => {
assert.ok(log.get('output').endsWith(truncationMessage), 'Truncation message is shown');
assert.equal(
log.get('output').length,
50000 + truncationMessage.length,
'Output is truncated the appropriate amount'
);
});
});
test('gotoTail builds the correct URL', function(assert) {
const mocks = makeMocks('');
const expectedUrl = `${mocks.url}?a=param&another=one&offset=50000&origin=end&plain=true`;
const log = Log.create(mocks);
run(() => {
log.get('gotoTail').perform();
assert.ok(fetchSpy.calledWith(expectedUrl), `gotoTail URL was ${expectedUrl}`);
});
});
test('startStreaming starts the log streamer', function(assert) {
const log = Log.create(makeMocks(''));
log.startStreaming();
assert.ok(startSpy.calledOnce, 'Streaming started');
assert.equal(log.get('logPointer'), 'tail', 'Streaming points the log to the tail');
});
test('When the log streamer calls `write`, the output is appended', function(assert) {
const log = Log.create(makeMocks(''));
const chunk1 = 'Hello';
const chunk2 = ' World';
const chunk3 = '\n\nEOF';
log.startStreaming();
assert.equal(log.get('output'), '', 'No output yet');
log.get('logStreamer').step(chunk1);
assert.equal(log.get('output'), chunk1, 'First chunk written');
log.get('logStreamer').step(chunk2);
assert.equal(log.get('output'), chunk1 + chunk2, 'Second chunk written');
log.get('logStreamer').step(chunk3);
assert.equal(log.get('output'), chunk1 + chunk2 + chunk3, 'Third chunk written');
});
test('stop stops the log streamer', function(assert) {
const log = Log.create(makeMocks(''));
log.stop();
assert.ok(stopSpy.calledOnce, 'Streaming stopped');
});

View File

@@ -643,6 +643,12 @@ babel-plugin-ember-modules-api-polyfill@^1.5.1:
dependencies:
ember-rfc176-data "^0.2.0"
babel-plugin-ember-modules-api-polyfill@^2.0.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/babel-plugin-ember-modules-api-polyfill/-/babel-plugin-ember-modules-api-polyfill-2.2.1.tgz#e63f90cc3c71cc6b3b69fb51b4f60312d6cf734c"
dependencies:
ember-rfc176-data "^0.3.0"
babel-plugin-eval@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/babel-plugin-eval/-/babel-plugin-eval-1.0.1.tgz#a2faed25ce6be69ade4bfec263f70169195950da"
@@ -2615,6 +2621,23 @@ ember-cli-babel@^6.0.0, ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.0.0-be
clone "^2.0.0"
ember-cli-version-checker "^2.0.0"
ember-cli-babel@^6.8.2:
version "6.9.0"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-6.9.0.tgz#5147391389bdbb7091d15f81ae1dff1eb49d71aa"
dependencies:
amd-name-resolver "0.0.7"
babel-plugin-debug-macros "^0.1.11"
babel-plugin-ember-modules-api-polyfill "^2.0.1"
babel-plugin-transform-es2015-modules-amd "^6.24.0"
babel-polyfill "^6.16.0"
babel-preset-env "^1.5.1"
broccoli-babel-transpiler "^6.1.2"
broccoli-debug "^0.6.2"
broccoli-funnel "^1.0.0"
broccoli-source "^1.1.0"
clone "^2.0.0"
ember-cli-version-checker "^2.1.0"
ember-cli-bourbon@2.0.0-beta.1:
version "2.0.0-beta.1"
resolved "https://registry.yarnpkg.com/ember-cli-bourbon/-/ember-cli-bourbon-2.0.0-beta.1.tgz#9d9b07bd4c7da7b2806ea18fc5cb9b37dd15ad25"
@@ -2894,6 +2917,13 @@ ember-cli-version-checker@^2.0.0:
resolve "^1.3.3"
semver "^5.3.0"
ember-cli-version-checker@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/ember-cli-version-checker/-/ember-cli-version-checker-2.1.0.tgz#fc79a56032f3717cf844ada7cbdec1a06fedb604"
dependencies:
resolve "^1.3.3"
semver "^5.3.0"
ember-cli@2.13.2:
version "2.13.2"
resolved "https://registry.yarnpkg.com/ember-cli/-/ember-cli-2.13.2.tgz#a561f08e69b184fa3175f706cced299c0d1684e5"
@@ -2999,6 +3029,15 @@ ember-concurrency@^0.8.1:
ember-getowner-polyfill "^2.0.0"
ember-maybe-import-regenerator "^0.1.5"
ember-concurrency@^0.8.12:
version "0.8.12"
resolved "https://registry.yarnpkg.com/ember-concurrency/-/ember-concurrency-0.8.12.tgz#fb91180e5efeb1024cfa2cfb99d2fe6721930c91"
dependencies:
babel-core "^6.24.1"
ember-cli-babel "^6.8.2"
ember-getowner-polyfill "^2.0.0"
ember-maybe-import-regenerator "^0.1.5"
ember-data-model-fragments@^2.14.0:
version "2.14.0"
resolved "https://registry.yarnpkg.com/ember-data-model-fragments/-/ember-data-model-fragments-2.14.0.tgz#f31a03cdcf2449eaaaf84e0996324bf6af6c7b8e"
@@ -3218,6 +3257,10 @@ ember-rfc176-data@^0.2.0:
version "0.2.7"
resolved "https://registry.yarnpkg.com/ember-rfc176-data/-/ember-rfc176-data-0.2.7.tgz#bd355bc9b473e08096b518784170a23388bc973b"
ember-rfc176-data@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/ember-rfc176-data/-/ember-rfc176-data-0.3.1.tgz#6a5a4b8b82ec3af34f3010965fa96b936ca94519"
ember-router-generator@^1.0.0:
version "1.2.3"
resolved "https://registry.yarnpkg.com/ember-router-generator/-/ember-router-generator-1.2.3.tgz#8ed2ca86ff323363120fc14278191e9e8f1315ee"