Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Scaling observability #8551

Merged
merged 15 commits into from
Jul 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
FEATURES:

* **Multiple Vault Namespaces (Enterprise)**: Support for multiple Vault Namespaces [[GH-8453](https://github.com/hashicorp/nomad/issues/8453)]
* **Scaling Observability UI**: View changes in task group scale (both manual and automatic) over time. [[GH-8551](https://github.com/hashicorp/nomad/issues/8551)]

BUG FIXES:

Expand Down
7 changes: 7 additions & 0 deletions ui/app/adapters/job-scale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import WatchableNamespaceIDs from './watchable-namespace-ids';

export default class JobScaleAdapter extends WatchableNamespaceIDs {
urlForFindRecord(id, type, hash) {
return super.urlForFindRecord(id, 'job', hash, 'scale');
}
}
11 changes: 3 additions & 8 deletions ui/app/adapters/job-summary.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import Watchable from './watchable';
import WatchableNamespaceIDs from './watchable-namespace-ids';

export default class JobSummaryAdapter extends Watchable {
export default class JobSummaryAdapter extends WatchableNamespaceIDs {
urlForFindRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = super.urlForFindRecord(name, 'job', hash) + '/summary';
if (namespace && namespace !== 'default') {
url += `?namespace=${namespace}`;
}
return url;
return super.urlForFindRecord(id, 'job', hash, 'summary');
}
}
3 changes: 2 additions & 1 deletion ui/app/adapters/watchable-namespace-ids.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ export default class WatchableNamespaceIDs extends Watchable {
return associateNamespace(url, namespace);
}

urlForFindRecord(id, type, hash) {
urlForFindRecord(id, type, hash, pathSuffix) {
const [name, namespace] = JSON.parse(id);
let url = super.urlForFindRecord(name, type, hash);
if (pathSuffix) url += `/${pathSuffix}`;
return associateNamespace(url, namespace);
}

Expand Down
3 changes: 2 additions & 1 deletion ui/app/components/json-viewer.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { classNames } from '@ember-decorators/component';
import { classNames, classNameBindings } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';

@classic
@classNames('json-viewer')
@classNameBindings('fluidHeight:has-fluid-height')
export default class JsonViewer extends Component {
json = null;

Expand Down
11 changes: 10 additions & 1 deletion ui/app/controllers/jobs/job/task-group.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { inject as service } from '@ember/service';
import { alias, readOnly } from '@ember/object/computed';
import Controller from '@ember/controller';
import { action, computed } from '@ember/object';
import { action, computed, get } from '@ember/object';
import Sortable from 'nomad-ui/mixins/sortable';
import Searchable from 'nomad-ui/mixins/searchable';
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
Expand Down Expand Up @@ -51,6 +51,15 @@ export default class TaskGroupController extends Controller.extend(
@alias('listSorted') listToSearch;
@alias('listSearched') sortedAllocations;

@computed('model.scaleState.events.@each.time', function() {
const events = get(this, 'model.scaleState.events');
if (events) {
return events.sortBy('time').reverse();
}
return [];
})
sortedScaleEvents;

@computed('model.job.runningDeployment')
get tooltipText() {
if (this.can.cannot('scale job')) return "You aren't allowed to scale task groups";
Expand Down
11 changes: 11 additions & 0 deletions ui/app/models/job-scale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Model from 'ember-data/model';
import { belongsTo } from 'ember-data/relationships';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
import classic from 'ember-classic-decorator';

@classic
export default class JobSummary extends Model {
@belongsTo('job') job;

@fragmentArray('task-group-scale') taskGroupScales;
}
1 change: 1 addition & 0 deletions ui/app/models/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export default class Job extends Model {
@hasMany('deployments') deployments;
@hasMany('evaluations') evaluations;
@belongsTo('namespace') namespace;
@belongsTo('job-scale') scaleState;

@computed('taskGroups.@each.drivers')
get drivers() {
Expand Down
34 changes: 34 additions & 0 deletions ui/app/models/scale-event.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { computed } from '@ember/object';
import Fragment from 'ember-data-model-fragments/fragment';
import attr from 'ember-data/attr';
import { fragmentOwner } from 'ember-data-model-fragments/attributes';

export default class ScaleEvent extends Fragment {
@fragmentOwner() taskGroupScale;

@attr('number') count;
@attr('number') previousCount;
@attr('boolean') error;
@attr('string') evalId;

@computed('count', function() {
return this.count != null;
})
hasCount;

@computed('count', 'previousCount', function() {
return this.count > this.previousCount;
})
increased;

@attr('date') time;
@attr('number') timeNanos;

@attr('string') message;
@attr() meta;

@computed('meta', function() {
return Object.keys(this.meta).length > 0;
})
hasMeta;
}
23 changes: 23 additions & 0 deletions ui/app/models/task-group-scale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { computed } from '@ember/object';
import Fragment from 'ember-data-model-fragments/fragment';
import attr from 'ember-data/attr';
import { fragmentOwner, fragmentArray } from 'ember-data-model-fragments/attributes';

export default class TaskGroupScale extends Fragment {
@fragmentOwner() jobScale;

@attr('string') name;

@attr('number') desired;
@attr('number') placed;
@attr('number') running;
@attr('number') healthy;
@attr('number') unhealthy;

@fragmentArray('scale-event') events;

@computed('events.length', function() {
return this.events.length;
})
isVisible;
}
5 changes: 5 additions & 0 deletions ui/app/models/task-group.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export default class TaskGroup extends Fragment {
return maybe(this.get('job.taskGroupSummaries')).findBy('name', this.name);
}

@computed('job.scaleState.taskGroupScales.[]')
get scaleState() {
return maybe(this.get('job.scaleState.taskGroupScales')).findBy('name', this.name);
}

scale(count, reason) {
return this.job.scale(this.name, count, reason);
}
Expand Down
14 changes: 8 additions & 6 deletions ui/app/routes/jobs/job/task-group.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import EmberError from '@ember/error';
import { resolve } from 'rsvp';
import { resolve, all } from 'rsvp';
import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
Expand Down Expand Up @@ -43,10 +43,9 @@ export default class TaskGroupRoute extends Route.extend(WithWatchers) {
}

// Refresh job allocations before-hand (so page sort works on load)
return job
.hasMany('allocations')
.reload()
.then(() => taskGroup);
return all([job.hasMany('allocations').reload(), job.get('scaleState')]).then(
() => taskGroup
);
})
.catch(notifyError(this));
}
Expand All @@ -57,6 +56,7 @@ export default class TaskGroupRoute extends Route.extend(WithWatchers) {
controller.set('watchers', {
job: this.watchJob.perform(job),
summary: this.watchSummary.perform(job.get('summary')),
scale: this.watchScale.perform(job.get('scaleState')),
allocations: this.watchAllocations.perform(job),
latestDeployment: job.get('supportsDeployments') && this.watchLatestDeployment.perform(job),
});
Expand All @@ -65,8 +65,10 @@ export default class TaskGroupRoute extends Route.extend(WithWatchers) {

@watchRecord('job') watchJob;
@watchRecord('job-summary') watchSummary;
@watchRecord('job-scale') watchScale;
@watchRelationship('allocations') watchAllocations;
@watchRelationship('latestDeployment') watchLatestDeployment;

@collect('watchJob', 'watchSummary', 'watchAllocations', 'watchLatestDeployment') watchers;
@collect('watchJob', 'watchSummary', 'watchScale', 'watchAllocations', 'watchLatestDeployment')
watchers;
}
19 changes: 19 additions & 0 deletions ui/app/serializers/job-scale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { assign } from '@ember/polyfills';
import ApplicationSerializer from './application';

export default class JobScale extends ApplicationSerializer {
normalize(modelClass, hash) {
// Transform the map-based TaskGroups object into an array-based
// TaskGroupScale fragment list
hash.PlainJobId = hash.JobID;
hash.ID = JSON.stringify([hash.JobID, hash.Namespace || 'default']);
hash.JobID = hash.ID;

const taskGroups = hash.TaskGroups || {};
hash.TaskGroupScales = Object.keys(taskGroups).map(key => {
return assign(taskGroups[key], { Name: key });
});

return super.normalize(modelClass, hash);
}
}
5 changes: 5 additions & 0 deletions ui/app/serializers/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export default class JobSerializer extends ApplicationSerializer {
related: buildURL(`${jobURL}/evaluations`, { namespace }),
},
},
scaleState: {
links: {
related: buildURL(`${jobURL}/scale`, { namespace }),
},
},
});
}
}
Expand Down
10 changes: 10 additions & 0 deletions ui/app/serializers/scale-event.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import ApplicationSerializer from './application';

export default class ScaleEventSerializer extends ApplicationSerializer {
normalize(typeHash, hash) {
hash.TimeNanos = hash.Time % 1000000;
hash.Time = Math.floor(hash.Time / 1000000);

return super.normalize(typeHash, hash);
}
}
1 change: 1 addition & 0 deletions ui/app/styles/components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
@import './components/image-file.scss';
@import './components/inline-definitions';
@import './components/job-diff';
@import './components/json-viewer';
@import './components/lifecycle-chart';
@import './components/loading-spinner';
@import './components/metrics';
Expand Down
8 changes: 4 additions & 4 deletions ui/app/styles/components/accordion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
}

&.is-full-bleed {
padding: 0;
}
}

.accordion-head {
Expand All @@ -26,10 +30,6 @@
background: $white;
}

&.is-inactive {
color: $grey-light;
}

.accordion-head-content {
width: 100%;
margin-right: 1.5em;
Expand Down
12 changes: 12 additions & 0 deletions ui/app/styles/components/inline-definitions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
font-weight: $weight-semibold;
}

&.is-faded {
color: darken($grey-blue, 20%);
}

.pair {
margin-right: 2em;
white-space: nowrap;
Expand All @@ -27,6 +31,14 @@
}
}

.icon-field {
display: flex;
margin-left: -1em;
.icon-container {
width: 1.5em;
}
}

&.is-small {
font-size: $size-7;
}
Expand Down
5 changes: 5 additions & 0 deletions ui/app/styles/components/json-viewer.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.json-viewer {
&.has-fluid-height .CodeMirror-scroll {
min-height: 0;
}
}
1 change: 1 addition & 0 deletions ui/app/templates/components/json-viewer.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<IvyCodemirror
data-test-json-viewer
@value={{this.jsonStr}}
@options={{hash
mode="javascript"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{{#if this.isOpen}}
<div data-test-accordion-body class="accordion-body">
<div data-test-accordion-body class="accordion-body {{if this.fullBleed "is-full-bleed"}}">
{{yield}}
</div>
{{/if}}
35 changes: 35 additions & 0 deletions ui/app/templates/components/scale-events-accordion.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<ListAccordion data-test-scale-events @source={{@events}} @key="time" as |a|>
<a.head @buttonLabel="details" @isExpandable={{a.item.hasMeta}} class="with-columns">
<div class="columns inline-definitions">
<div class="column is-3">
<span class="icon-field">
<span class="icon-container" title="{{if a.item.error "Error event"}}" data-test-error={{a.item.error}}>
{{#if a.item.error}}{{x-icon "cancel-circle-fill" class="is-danger"}}{{/if}}
</span>
<span data-test-time title="{{format-ts a.item.time}}">{{format-month-ts a.item.time}}</span>
</span>
</div>
<div class="column is-2">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it’s overkill but I hovered over this just to see, a tooltip/title like “Scaled up/down to X” could be nice?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that could be nice. The autoscaler folks are also looking into improving the messages they emit, so that should help too.

{{#if a.item.hasCount}}
<span data-test-count-icon
class="tooltip"
aria-label="Count {{if a.item.increased "increased" "decreased"}} to {{a.item.count}}"
>
{{#if a.item.increased}}
{{x-icon "arrow-up" class="is-danger"}}
{{else}}
{{x-icon "arrow-down" class="is-primary"}}
{{/if}}
</span>
<span data-test-count>{{a.item.count}}</span>
{{/if}}
</div>
<div class="column" data-test-message>
{{a.item.message}}
</div>
</div>
</a.head>
<a.body @fullBleed={{true}}>
<JsonViewer @json={{a.item.meta}} @fluidHeight={{true}} />
</a.body>
</ListAccordion>
11 changes: 11 additions & 0 deletions ui/app/templates/jobs/job/task-group.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@

<LifecycleChart @tasks={{this.model.tasks}} />

{{#if this.model.scaleState.isVisible}}
<div data-test-scaling-events class="boxed-section">
<div class="boxed-section-head">
Recent Scaling Events
</div>
<div class="boxed-section-body">
<ScaleEventsAccordion @events={{this.sortedScaleEvents}} />
</div>
</div>
{{/if}}

{{#if this.model.volumes.length}}
<div data-test-volumes class="boxed-section">
<div class="boxed-section-head">
Expand Down
Loading