diff --git a/command/agent/http.go b/command/agent/http.go
index 1499eab6d4e2..4146cffd4f62 100644
--- a/command/agent/http.go
+++ b/command/agent/http.go
@@ -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)))
diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js
index 7c2ebd1c4175..5d19a7da5399 100644
--- a/ui/.eslintrc.js
+++ b/ui/.eslintrc.js
@@ -16,5 +16,11 @@ module.exports = {
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single', 'avoid-escape'],
semi: ['error', 'always'],
+ 'no-constant-condition': [
+ 'error',
+ {
+ checkLoops: false,
+ },
+ ],
},
};
diff --git a/ui/app/components/job-versions-stream.js b/ui/app/components/job-versions-stream.js
index 4a285de2bb30..8ac0113769da 100644
--- a/ui/app/components/job-versions-stream.js
+++ b/ui/app/components/job-versions-stream.js
@@ -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;
}
}
diff --git a/ui/app/components/task-log.js b/ui/app/components/task-log.js
new file mode 100644
index 000000000000..1b066db73ce9
--- /dev/null
+++ b/ui/app/components/task-log.js
@@ -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();
+ }
+ },
+ },
+});
diff --git a/ui/app/controllers/allocations/allocation/task/index.js b/ui/app/controllers/allocations/allocation/task/index.js
index 669884d9d777..7374af8fd95b 100644
--- a/ui/app/controllers/allocations/allocation/task/index.js
+++ b/ui/app/controllers/allocations/allocation/task/index.js
@@ -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,
diff --git a/ui/app/routes/allocations/allocation/task/logs.js b/ui/app/routes/allocations/allocation/task/logs.js
new file mode 100644
index 000000000000..5e4767eb5f4f
--- /dev/null
+++ b/ui/app/routes/allocations/allocation/task/logs.js
@@ -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);
+ },
+});
diff --git a/ui/app/services/token.js b/ui/app/services/token.js
index cd2b66723130..be1da0c904ab 100644
--- a/ui/app/services/token.js
+++ b/ui/app/services/token.js
@@ -1,5 +1,5 @@
import Ember from 'ember';
-import fetch from 'fetch';
+import fetch from 'nomad-ui/utils/fetch';
const { Service, computed, assign } = Ember;
diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss
index f6e19590b2c5..0b4545c213ba 100644
--- a/ui/app/styles/components.scss
+++ b/ui/app/styles/components.scss
@@ -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";
diff --git a/ui/app/styles/components/boxed-section.scss b/ui/app/styles/components/boxed-section.scss
index 51de363691df..cc22c03a61cd 100644
--- a/ui/app/styles/components/boxed-section.scss
+++ b/ui/app/styles/components/boxed-section.scss
@@ -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;
diff --git a/ui/app/styles/components/cli-window.scss b/ui/app/styles/components/cli-window.scss
new file mode 100644
index 000000000000..3fa85688a5c2
--- /dev/null
+++ b/ui/app/styles/components/cli-window.scss
@@ -0,0 +1,11 @@
+.cli-window {
+ background: transparent;
+ color: $white;
+
+ height: 500px;
+ overflow: auto;
+
+ .is-light {
+ color: $text;
+ }
+}
diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss
index 0a0c69c623b1..27d44f0e9e09 100644
--- a/ui/app/styles/core/buttons.scss
+++ b/ui/app/styles/core/buttons.scss
@@ -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;
+ }
+ }
}
diff --git a/ui/app/styles/core/icon.scss b/ui/app/styles/core/icon.scss
index d100678c3b25..fef6cafe83be 100644
--- a/ui/app/styles/core/icon.scss
+++ b/ui/app/styles/core/icon.scss
@@ -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;
}
diff --git a/ui/app/templates/allocations/allocation/task/logs.hbs b/ui/app/templates/allocations/allocation/task/logs.hbs
new file mode 100644
index 000000000000..887d2a7d1013
--- /dev/null
+++ b/ui/app/templates/allocations/allocation/task/logs.hbs
@@ -0,0 +1,15 @@
+{{#global-header class="page-header"}}
+ Allocations
+ {{#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"}}
+
+ {{task-log allocation=model.allocation task=model.name}}
+
+{{/gutter-menu}}
diff --git a/ui/app/templates/allocations/allocation/task/subnav.hbs b/ui/app/templates/allocations/allocation/task/subnav.hbs
index fec8ff349fe9..5bacd9940713 100644
--- a/ui/app/templates/allocations/allocation/task/subnav.hbs
+++ b/ui/app/templates/allocations/allocation/task/subnav.hbs
@@ -1,5 +1,6 @@
- {{#link-to "allocations.allocation.task.index" model.allocation model activeClass="is-active"}}Overview{{/link-to}}
+ - {{#link-to "allocations.allocation.task.logs" model.allocation model activeClass="is-active"}}Logs{{/link-to}}
diff --git a/ui/app/templates/components/task-log.hbs b/ui/app/templates/components/task-log.hbs
new file mode 100644
index 000000000000..de1fef860ea2
--- /dev/null
+++ b/ui/app/templates/components/task-log.hbs
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/app/utils/classes/abstract-logger.js b/ui/app/utils/classes/abstract-logger.js
new file mode 100644
index 000000000000..37ab2578b1e5
--- /dev/null
+++ b/ui/app/utils/classes/abstract-logger.js
@@ -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}`;
+ }),
+});
diff --git a/ui/app/utils/classes/log.js b/ui/app/utils/classes/log.js
new file mode 100644
index 000000000000..219be0b4e711
--- /dev/null
+++ b/ui/app/utils/classes/log.js
@@ -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),
+ });
+ });
+}
diff --git a/ui/app/utils/classes/poll-logger.js b/ui/app/utils/classes/poll-logger.js
new file mode 100644
index 000000000000..3077e80a037c
--- /dev/null
+++ b/ui/app/utils/classes/poll-logger.js
@@ -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);
+ }
+ }),
+});
diff --git a/ui/app/utils/classes/stream-logger.js b/ui/app/utils/classes/stream-logger.js
new file mode 100644
index 000000000000..f19f0c6683ee
--- /dev/null
+++ b/ui/app/utils/classes/stream-logger.js
@@ -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,
+});
diff --git a/ui/app/utils/classes/text-decoder.js b/ui/app/utils/classes/text-decoder.js
new file mode 100644
index 000000000000..f3bb66c819be
--- /dev/null
+++ b/ui/app/utils/classes/text-decoder.js
@@ -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;
+ };
+ };
diff --git a/ui/app/utils/fetch.js b/ui/app/utils/fetch.js
new file mode 100644
index 000000000000..98b81a889b7e
--- /dev/null
+++ b/ui/app/utils/fetch.js
@@ -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;
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index bb80461038d4..64bbcea417ec 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -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);
+ });
});
}
diff --git a/ui/mirage/data/logs.js b/ui/mirage/data/logs.js
new file mode 100644
index 000000000000..b0ea3dcf3b89
--- /dev/null
+++ b/ui/mirage/data/logs.js
@@ -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('');
+};
diff --git a/ui/mirage/factories/node.js b/ui/mirage/factories/node.js
index 3705fa187d58..3693171a6039 100644
--- a/ui/mirage/factories/node.js
+++ b/ui/mirage/factories/node.js
@@ -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,
});
},
});
diff --git a/ui/package.json b/ui/package.json
index 2294bfae4b3f..d56b7749b25e 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -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"
+ ]
}
}
diff --git a/ui/public/images/icons/media-pause.svg b/ui/public/images/icons/media-pause.svg
new file mode 100644
index 000000000000..126ab6c07eb1
--- /dev/null
+++ b/ui/public/images/icons/media-pause.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/public/images/icons/media-play.svg b/ui/public/images/icons/media-play.svg
new file mode 100644
index 000000000000..3b3ba714a747
--- /dev/null
+++ b/ui/public/images/icons/media-play.svg
@@ -0,0 +1,3 @@
+
diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js
index 1d031cbec551..2c90da4d7d96 100644
--- a/ui/tests/acceptance/task-detail-test.js
+++ b/ui/tests/acceptance/task-detail-test.js
@@ -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);
diff --git a/ui/tests/acceptance/task-logs-test.js b/ui/tests/acceptance/task-logs-test.js
new file mode 100644
index 000000000000..77614c1e6aaa
--- /dev/null
+++ b/ui/tests/acceptance/task-logs-test.js
@@ -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'
+ );
+});
diff --git a/ui/tests/index.html b/ui/tests/index.html
index acb2855aa33f..86ae48892dc5 100644
--- a/ui/tests/index.html
+++ b/ui/tests/index.html
@@ -21,6 +21,11 @@
{{content-for "body"}}
{{content-for "test-body"}}
+
diff --git a/ui/tests/integration/task-log-test.js b/ui/tests/integration/task-log-test.js
new file mode 100644
index 000000000000..2d58d6fe4731
--- /dev/null
+++ b/ui/tests/integration/task-log-test.js
@@ -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'
+ );
+ });
+});
diff --git a/ui/tests/unit/helpers/format-bytes-test.js b/ui/tests/unit/helpers/format-bytes-test.js
index f1b1c444edcb..d33eff5ff7ba 100644
--- a/ui/tests/unit/helpers/format-bytes-test.js
+++ b/ui/tests/unit/helpers/format-bytes-test.js
@@ -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');
diff --git a/ui/tests/unit/utils/log-test.js b/ui/tests/unit/utils/log-test.js
new file mode 100644
index 000000000000..e4842301a882
--- /dev/null
+++ b/ui/tests/unit/utils/log-test.js
@@ -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');
+});
diff --git a/ui/yarn.lock b/ui/yarn.lock
index e5c521113870..62c3cdee12e3 100644
--- a/ui/yarn.lock
+++ b/ui/yarn.lock
@@ -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"