diff --git a/ui/app/serializers/allocation.js b/ui/app/serializers/allocation.js index 77b8132aa..3e7be2e79 100644 --- a/ui/app/serializers/allocation.js +++ b/ui/app/serializers/allocation.js @@ -18,6 +18,8 @@ export default class AllocationSerializer extends ApplicationSerializer { states: 'TaskStates', }; + separateNanos = ['CreateTime', 'ModifyTime']; + normalize(typeHash, hash) { // Transform the map-based TaskStates object into an array-based // TaskState fragment list @@ -40,12 +42,6 @@ export default class AllocationSerializer extends ApplicationSerializer { 'default'; hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]); - hash.ModifyTimeNanos = hash.ModifyTime % 1000000; - hash.ModifyTime = Math.floor(hash.ModifyTime / 1000000); - - hash.CreateTimeNanos = hash.CreateTime % 1000000; - hash.CreateTime = Math.floor(hash.CreateTime / 1000000); - hash.RescheduleEvents = (hash.RescheduleTracker || {}).Events; hash.IsMigrating = (hash.DesiredTransition || {}).Migrate; diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js index 43247a3a7..c53321406 100644 --- a/ui/app/serializers/application.js +++ b/ui/app/serializers/application.js @@ -4,10 +4,48 @@ import { makeArray } from '@ember/array'; import JSONSerializer from 'ember-data/serializers/json'; import { pluralize, singularize } from 'ember-inflector'; import removeRecord from '../utils/remove-record'; +import { assign } from '@ember/polyfills'; export default class Application extends JSONSerializer { primaryKey = 'ID'; + /** + A list of keys that are converted to empty arrays if their value is null. + + arrayNullOverrides = ['Array']; + { Array: null } => { Array: [] } + + @property arrayNullOverrides + @type String[] + */ + arrayNullOverrides = null; + + /** + A list of keys or objects to convert a map into an array of maps with the original map keys as Name properties. + + mapToArray = ['Map']; + { Map: { a: { x: 1 } } } => { Map: [ { Name: 'a', x: 1 }] } + + mapToArray = [{ beforeName: 'M', afterName: 'Map' }]; + { M: { a: { x: 1 } } } => { Map: [ { Name: 'a', x: 1 }] } + + @property mapToArray + @type (String|Object)[] + */ + mapToArray = null; + + /** + A list of keys for nanosecond timestamps that will be split into two properties: `separateNanos = ['Time']` will + produce a `Time` property with a millisecond timestamp and `TimeNanos` with the nanoseconds alone. + + separateNanos = ['Time']; + { Time: 1607839992000100000 } => { Time: 1607839992000, TimeNanos: 100096 } + + @property separateNanos + @type String[] + */ + separateNanos = null; + keyForAttribute(attr) { return attr.camelize().capitalize(); } @@ -43,6 +81,53 @@ export default class Application extends JSONSerializer { store.push(documentHash); } + normalize(modelClass, hash) { + if (hash) { + if (this.arrayNullOverrides) { + this.arrayNullOverrides.forEach(key => { + if (!hash[key]) { + hash[key] = []; + } + }); + } + + if (this.mapToArray) { + this.mapToArray.forEach(conversion => { + let apiKey, uiKey; + + if (conversion.beforeName) { + apiKey = conversion.beforeName; + uiKey = conversion.afterName; + } else { + apiKey = conversion; + uiKey = conversion; + } + + const map = hash[apiKey] || {}; + + hash[uiKey] = Object.keys(map).map(mapKey => { + const propertiesForKey = map[mapKey] || {}; + const convertedMap = { Name: mapKey }; + + assign(convertedMap, propertiesForKey); + + return convertedMap; + }); + }); + } + + if (this.separateNanos) { + this.separateNanos.forEach(key => { + const timeWithNanos = hash[key]; + hash[`${key}Nanos`] = timeWithNanos % 1000000; + hash[key] = Math.floor(timeWithNanos / 1000000); + }); + } + } + + return super.normalize(modelClass, hash); + } + normalizeFindAllResponse(store, modelClass) { const result = super.normalizeFindAllResponse(...arguments); this.cullStore(store, modelClass.modelName, result.data); diff --git a/ui/app/serializers/deployment.js b/ui/app/serializers/deployment.js index 1c57286f3..b6dbd0c8f 100644 --- a/ui/app/serializers/deployment.js +++ b/ui/app/serializers/deployment.js @@ -9,14 +9,10 @@ export default class DeploymentSerializer extends ApplicationSerializer { versionNumber: 'JobVersion', }; + mapToArray = [{ beforeName: 'TaskGroups', afterName: 'TaskGroupSummaries' }]; + normalize(typeHash, hash) { if (hash) { - const taskGroups = hash.TaskGroups || {}; - hash.TaskGroupSummaries = Object.keys(taskGroups).map(key => { - const deploymentStats = taskGroups[key]; - return assign({ Name: key }, deploymentStats); - }); - hash.PlainJobId = hash.JobID; hash.Namespace = hash.Namespace || diff --git a/ui/app/serializers/evaluation.js b/ui/app/serializers/evaluation.js index e72a6eb47..6b291ab92 100644 --- a/ui/app/serializers/evaluation.js +++ b/ui/app/serializers/evaluation.js @@ -1,6 +1,5 @@ import { inject as service } from '@ember/service'; import { get } from '@ember/object'; -import { assign } from '@ember/polyfills'; import ApplicationSerializer from './application'; import classic from 'ember-classic-decorator'; @@ -8,13 +7,10 @@ import classic from 'ember-classic-decorator'; export default class Evaluation extends ApplicationSerializer { @service system; - normalize(typeHash, hash) { - const failures = hash.FailedTGAllocs || {}; - hash.FailedTGAllocs = Object.keys(failures).map(key => { - const propertiesForKey = failures[key] || {}; - return assign({ Name: key }, propertiesForKey); - }); + mapToArray = ['FailedTGAllocs']; + separateNanos = ['CreateTime', 'ModifyTime']; + normalize(typeHash, hash) { hash.PlainJobId = hash.JobID; hash.Namespace = hash.Namespace || @@ -23,12 +19,6 @@ export default class Evaluation extends ApplicationSerializer { 'default'; hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]); - hash.ModifyTimeNanos = hash.ModifyTime % 1000000; - hash.ModifyTime = Math.floor(hash.ModifyTime / 1000000); - - hash.CreateTimeNanos = hash.CreateTime % 1000000; - hash.CreateTime = Math.floor(hash.CreateTime / 1000000); - return super.normalize(typeHash, hash); } } diff --git a/ui/app/serializers/job-plan.js b/ui/app/serializers/job-plan.js index 13bd4feb3..1893f8c9d 100644 --- a/ui/app/serializers/job-plan.js +++ b/ui/app/serializers/job-plan.js @@ -1,13 +1,10 @@ -import { assign } from '@ember/polyfills'; import ApplicationSerializer from './application'; import { get } from '@ember/object'; export default class JobPlan extends ApplicationSerializer { + mapToArray = ['FailedTGAllocs']; + normalize(typeHash, hash) { - const failures = hash.FailedTGAllocs || {}; - hash.FailedTGAllocs = Object.keys(failures).map(key => { - return assign({ Name: key }, failures[key] || {}); - }); hash.PreemptionIDs = (get(hash, 'Annotations.PreemptedAllocs') || []).mapBy('ID'); return super.normalize(...arguments); } diff --git a/ui/app/serializers/job-scale.js b/ui/app/serializers/job-scale.js index b0ab04d87..45bc98d7f 100644 --- a/ui/app/serializers/job-scale.js +++ b/ui/app/serializers/job-scale.js @@ -1,19 +1,13 @@ -import { assign } from '@ember/polyfills'; import ApplicationSerializer from './application'; export default class JobScale extends ApplicationSerializer { + mapToArray = [{ beforeName: 'TaskGroups', afterName: 'TaskGroupScales' }]; + 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); } } diff --git a/ui/app/serializers/job-summary.js b/ui/app/serializers/job-summary.js index da9c93bb0..50b35a072 100644 --- a/ui/app/serializers/job-summary.js +++ b/ui/app/serializers/job-summary.js @@ -3,12 +3,13 @@ import ApplicationSerializer from './application'; export default class JobSummary extends ApplicationSerializer { normalize(modelClass, hash) { - // Transform the map-based Summary object into an array-based - // TaskGroupSummary fragment list hash.PlainJobId = hash.JobID; hash.ID = JSON.stringify([hash.JobID, hash.Namespace || 'default']); hash.JobID = hash.ID; + // Transform the map-based Summary object into an array-based + // TaskGroupSummary fragment list + const fullSummary = hash.Summary || {}; hash.TaskGroupSummaries = Object.keys(fullSummary).map(key => { const allocStats = fullSummary[key] || {}; diff --git a/ui/app/serializers/node.js b/ui/app/serializers/node.js index 4eb05b976..6a07ba0ac 100644 --- a/ui/app/serializers/node.js +++ b/ui/app/serializers/node.js @@ -1,4 +1,3 @@ -import { assign } from '@ember/polyfills'; import { inject as service } from '@ember/service'; import ApplicationSerializer from './application'; @@ -10,18 +9,7 @@ export default class NodeSerializer extends ApplicationSerializer { httpAddr: 'HTTPAddr', }; - normalize(modelClass, hash) { - // Transform map-based objects into array-based fragment lists - const drivers = hash.Drivers || {}; - hash.Drivers = Object.keys(drivers).map(key => { - return assign({}, drivers[key], { Name: key }); - }); - - const hostVolumes = hash.HostVolumes || {}; - hash.HostVolumes = Object.keys(hostVolumes).map(key => hostVolumes[key]); - - return super.normalize(modelClass, hash); - } + mapToArray = ['Drivers', 'HostVolumes']; extractRelationships(modelClass, hash) { const { modelName } = modelClass; diff --git a/ui/app/serializers/reschedule-event.js b/ui/app/serializers/reschedule-event.js index 3bbe12904..0f843a09e 100644 --- a/ui/app/serializers/reschedule-event.js +++ b/ui/app/serializers/reschedule-event.js @@ -1,14 +1,9 @@ import ApplicationSerializer from './application'; export default class RescheduleEvent extends ApplicationSerializer { - normalize(typeHash, hash) { - // Time is in the form of nanoseconds since epoch, but JS dates - // only understand time to the millisecond precision. So store - // the time (precise to ms) as a date, and store the remaining ns - // as a number to deal with when it comes up. - hash.TimeNanos = hash.RescheduleTime % 1000000; - hash.Time = Math.floor(hash.RescheduleTime / 1000000); + separateNanos = ['Time']; + normalize(typeHash, hash) { hash.PreviousAllocationId = hash.PrevAllocID ? hash.PrevAllocID : null; hash.PreviousNodeId = hash.PrevNodeID ? hash.PrevNodeID : null; diff --git a/ui/app/serializers/resources.js b/ui/app/serializers/resources.js index 4239a354b..cd642d643 100644 --- a/ui/app/serializers/resources.js +++ b/ui/app/serializers/resources.js @@ -8,8 +8,5 @@ export default class ResourcesSerializer extends ApplicationSerializer { iops: 'IOPS', }; - normalize(typeHash, hash) { - hash.Ports = hash.Ports || []; - return super.normalize(typeHash, hash); - } + arrayNullOverrides = ['Ports']; } diff --git a/ui/app/serializers/scale-event.js b/ui/app/serializers/scale-event.js index 758ec3040..a8dea78b6 100644 --- a/ui/app/serializers/scale-event.js +++ b/ui/app/serializers/scale-event.js @@ -1,10 +1,5 @@ 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); - } + separateNanos = ['Time']; } diff --git a/ui/app/serializers/service.js b/ui/app/serializers/service.js index 107c79f28..7b7fb898a 100644 --- a/ui/app/serializers/service.js +++ b/ui/app/serializers/service.js @@ -5,11 +5,5 @@ export default class ServiceSerializer extends ApplicationSerializer { connect: 'Connect', }; - normalize(typeHash, hash) { - if (!hash.Tags) { - hash.Tags = []; - } - - return super.normalize(typeHash, hash); - } + arrayNullOverrides = ['Tags']; } diff --git a/ui/app/serializers/task-event.js b/ui/app/serializers/task-event.js index dfa43f4b6..108ff4a45 100644 --- a/ui/app/serializers/task-event.js +++ b/ui/app/serializers/task-event.js @@ -5,14 +5,5 @@ export default class TaskEventSerializer extends ApplicationSerializer { message: 'DisplayMessage', }; - normalize(typeHash, hash) { - // Time is in the form of nanoseconds since epoch, but JS dates - // only understand time to the millisecond precision. So store - // the time (precise to ms) as a date, and store the remaining ns - // as a number to deal with when it comes up. - hash.TimeNanos = hash.Time % 1000000; - hash.Time = Math.floor(hash.Time / 1000000); - - return super.normalize(typeHash, hash); - } + separateNanos = ['Time']; } diff --git a/ui/app/serializers/task-group-scale.js b/ui/app/serializers/task-group-scale.js index 057fa86d0..86d25d894 100644 --- a/ui/app/serializers/task-group-scale.js +++ b/ui/app/serializers/task-group-scale.js @@ -1,11 +1,5 @@ import ApplicationSerializer from './application'; export default class TaskGroupScaleSerializer extends ApplicationSerializer { - normalize(typeHash, hash) { - if (!hash.Events) { - hash.Events = []; - } - - return super.normalize(typeHash, hash); - } + arrayNullOverrides = ['Events']; } diff --git a/ui/app/serializers/task-group.js b/ui/app/serializers/task-group.js index d7b0d7c4d..6c5034d28 100644 --- a/ui/app/serializers/task-group.js +++ b/ui/app/serializers/task-group.js @@ -2,6 +2,9 @@ import { copy } from 'ember-copy'; import ApplicationSerializer from './application'; export default class TaskGroup extends ApplicationSerializer { + arrayNullOverrides = ['Services']; + mapToArray = ['Volumes']; + normalize(typeHash, hash) { // Provide EphemeralDisk to each task hash.Tasks.forEach(task => { @@ -9,10 +12,6 @@ export default class TaskGroup extends ApplicationSerializer { }); hash.ReservedEphemeralDisk = hash.EphemeralDisk.SizeMB; - hash.Services = hash.Services || []; - - const volumes = hash.Volumes || {}; - hash.Volumes = Object.keys(volumes).map(key => volumes[key]); return super.normalize(typeHash, hash); } diff --git a/ui/tests/unit/serializers/application-test.js b/ui/tests/unit/serializers/application-test.js new file mode 100644 index 000000000..0990ece9e --- /dev/null +++ b/ui/tests/unit/serializers/application-test.js @@ -0,0 +1,107 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import ApplicationSerializer from 'nomad-ui/serializers/application'; + +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; + +class TestSerializer extends ApplicationSerializer { + arrayNullOverrides = ['Things']; + + mapToArray = [ + 'ArrayableMap', + { beforeName: 'OriginalNameArrayableMap', afterName: 'RenamedArrayableMap' }, + ]; + + separateNanos = ['Time']; +} + +class TestModel extends Model { + @attr() things; + + @attr() arrayableMap; + @attr() renamedArrayableMap; + + @attr() time; + @attr() timeNanos; +} + +module('Unit | Serializer | Application', function(hooks) { + setupTest(hooks); + + hooks.beforeEach(function() { + this.store = this.owner.lookup('service:store'); + this.owner.register('model:test', TestModel); + this.owner.register('serializer:test', TestSerializer); + + this.subject = () => this.store.serializerFor('test'); + }); + + const normalizationTestCases = [ + { + name: 'Null array and maps', + in: { + ID: 'test-test', + Things: null, + ArrayableMap: null, + OriginalNameArrayableMap: null, + Time: 1607839992000100000, + }, + out: { + data: { + id: 'test-test', + attributes: { + things: [], + arrayableMap: [], + renamedArrayableMap: [], + time: 1607839992000, + timeNanos: 100096, + }, + relationships: {}, + type: 'test', + }, + }, + }, + { + name: 'Non-null array and maps', + in: { + ID: 'test-test', + Things: [1, 2, 3], + ArrayableMap: { + a: { Order: 1 }, + b: { Order: 2 }, + 'c.d': { Order: 3 }, + }, + OriginalNameArrayableMap: { + a: { X: 1 }, + }, + Time: 1607839992000100000, + SomethingExtra: 'xyz', + }, + out: { + data: { + id: 'test-test', + attributes: { + things: [1, 2, 3], + arrayableMap: [ + { Name: 'a', Order: 1 }, + { Name: 'b', Order: 2 }, + { Name: 'c.d', Order: 3 }, + ], + renamedArrayableMap: [{ Name: 'a', X: 1 }], + time: 1607839992000, + timeNanos: 100096, + }, + relationships: {}, + type: 'test', + }, + }, + }, + ]; + + normalizationTestCases.forEach(testCase => { + test(`normalization: ${testCase.name}`, async function(assert) { + assert.deepEqual(this.subject().normalize(TestModel, testCase.in), testCase.out); + }); + }); +});