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 @@
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 @@ +
+ + + + + + + + + +
+
+
{{logger.output}}
+
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"