mirror of
https://github.com/kemko/nomad.git
synced 2026-01-10 12:25:42 +03:00
Merge pull request #3564 from hashicorp/f-ui-log-streaming
UI: Log streaming
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -16,5 +16,11 @@ module.exports = {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single', 'avoid-escape'],
|
||||
semi: ['error', 'always'],
|
||||
'no-constant-condition': [
|
||||
'error',
|
||||
{
|
||||
checkLoops: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
103
ui/app/components/task-log.js
Normal file
103
ui/app/components/task-log.js
Normal 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();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
10
ui/app/routes/allocations/allocation/task/logs.js
Normal file
10
ui/app/routes/allocations/allocation/task/logs.js
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import Ember from 'ember';
|
||||
import fetch from 'fetch';
|
||||
import fetch from 'nomad-ui/utils/fetch';
|
||||
|
||||
const { Service, computed, assign } = Ember;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
ui/app/styles/components/cli-window.scss
Normal file
11
ui/app/styles/components/cli-window.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.cli-window {
|
||||
background: transparent;
|
||||
color: $white;
|
||||
|
||||
height: 500px;
|
||||
overflow: auto;
|
||||
|
||||
.is-light {
|
||||
color: $text;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
15
ui/app/templates/allocations/allocation/task/logs.hbs
Normal file
15
ui/app/templates/allocations/allocation/task/logs.hbs
Normal 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}}
|
||||
@@ -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>
|
||||
|
||||
16
ui/app/templates/components/task-log.hbs
Normal file
16
ui/app/templates/components/task-log.hbs
Normal 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>
|
||||
33
ui/app/utils/classes/abstract-logger.js
Normal file
33
ui/app/utils/classes/abstract-logger.js
Normal 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
125
ui/app/utils/classes/log.js
Normal 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),
|
||||
});
|
||||
});
|
||||
}
|
||||
35
ui/app/utils/classes/poll-logger.js
Normal file
35
ui/app/utils/classes/poll-logger.js
Normal 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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
74
ui/app/utils/classes/stream-logger.js
Normal file
74
ui/app/utils/classes/stream-logger.js
Normal 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,
|
||||
});
|
||||
16
ui/app/utils/classes/text-decoder.js
Normal file
16
ui/app/utils/classes/text-decoder.js
Normal 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
8
ui/app/utils/fetch.js
Normal 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;
|
||||
@@ -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
16
ui/mirage/data/logs.js
Normal 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('');
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3
ui/public/images/icons/media-pause.svg
Normal file
3
ui/public/images/icons/media-pause.svg
Normal 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 |
3
ui/public/images/icons/media-play.svg
Normal file
3
ui/public/images/icons/media-play.svg
Normal 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 |
@@ -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);
|
||||
|
||||
37
ui/tests/acceptance/task-logs-test.js
Normal file
37
ui/tests/acceptance/task-logs-test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
174
ui/tests/integration/task-log-test.js
Normal file
174
ui/tests/integration/task-log-test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
155
ui/tests/unit/utils/log-test.js
Normal file
155
ui/tests/unit/utils/log-test.js
Normal 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');
|
||||
});
|
||||
43
ui/yarn.lock
43
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"
|
||||
|
||||
Reference in New Issue
Block a user