Skip to content

Commit

Permalink
Merge pull request #8551 from hashicorp/f-ui/scaling-observability
Browse files Browse the repository at this point in the history
UI: Scaling observability
  • Loading branch information
DingoEatingFuzz authored Jul 30, 2020
2 parents a85ab50 + 75aa9ce commit de817be
Show file tree
Hide file tree
Showing 37 changed files with 535 additions and 23 deletions.
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('[email protected]', 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('[email protected]')
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">
{{#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

0 comments on commit de817be

Please sign in to comment.