diff --git a/awx/ui/static/js/shared/format-epoch/format-epoch.filter.js b/awx/ui/static/js/shared/format-epoch/format-epoch.filter.js new file mode 100644 index 0000000000..2216249732 --- /dev/null +++ b/awx/ui/static/js/shared/format-epoch/format-epoch.filter.js @@ -0,0 +1,16 @@ +export default +[ 'moment', + function(moment) { + return function(seconds, formatStr) { + if (!formatStr) { + formatStr = 'll LT'; + } + + var millis = seconds * 1000; + + return moment(millis).format(formatStr); + }; + } +]; + + diff --git a/awx/ui/static/js/shared/format-epoch/main.js b/awx/ui/static/js/shared/format-epoch/main.js new file mode 100644 index 0000000000..916a81c7ab --- /dev/null +++ b/awx/ui/static/js/shared/format-epoch/main.js @@ -0,0 +1,9 @@ +import formatEpoch from './format-epoch.filter'; +import moment from 'tower/shared/moment/main'; + +export default + angular.module('formatEpoch', + [ moment.name + ]) + .filter('formatEpoch', formatEpoch); + diff --git a/awx/ui/static/js/system-tracking/compare-facts.js b/awx/ui/static/js/system-tracking/compare-facts.js index 04d1ee3a6d..b8e466f00a 100644 --- a/awx/ui/static/js/system-tracking/compare-facts.js +++ b/awx/ui/static/js/system-tracking/compare-facts.js @@ -6,27 +6,48 @@ import compareNestedFacts from './compare-facts/nested'; import compareFlatFacts from './compare-facts/flat'; +import FactTemplate from './compare-facts/fact-template'; export function compareFacts(module, facts) { - if (module.displayType === 'nested') { - return { factData: compareNestedFacts(facts), - isNestedDisplay: true - }; - } else { + + + var renderOptions = _.merge({}, module); + + // If the module has a template or includes a list of keys to display, + // then perform a flat comparison, otherwise assume nested + // + if (renderOptions.factTemplate || renderOptions.nameKey) { // For flat structures we compare left-to-right, then right-to-left to // make sure we get a good comparison between both hosts - var compare = _.partialRight(compareFlatFacts, module.nameKey, module.compareKey, module.factTemplate); - var leftToRight = compare(facts[0], facts[1]); - var rightToLeft = compare(facts[1], facts[0]); + + if (_.isPlainObject(renderOptions.factTemplate)) { + renderOptions.factTemplate = + _.mapValues(renderOptions.factTemplate, function(template) { + if (typeof template === 'string' || typeof template === 'function') { + return new FactTemplate(template); + } else { + return template; + } + }); + } else { + renderOptions.factTemplate = new FactTemplate(renderOptions.factTemplate); + } + + var leftToRight = compareFlatFacts(facts[0], facts[1], renderOptions); + var rightToLeft = compareFlatFacts(facts[1], facts[0], renderOptions); return _(leftToRight) .concat(rightToLeft) .unique('displayKeyPath') .thru(function(result) { return { factData: result, - isNestedDisplay: _.isUndefined(module.factTemplate) + isNestedDisplay: _.isPlainObject(renderOptions.factTemplate) }; }) .value(); + } else { + return { factData: compareNestedFacts(facts), + isNestedDisplay: true + }; } } diff --git a/awx/ui/static/js/system-tracking/compare-facts/fact-template.js b/awx/ui/static/js/system-tracking/compare-facts/fact-template.js new file mode 100644 index 0000000000..d4c0e2d5d8 --- /dev/null +++ b/awx/ui/static/js/system-tracking/compare-facts/fact-template.js @@ -0,0 +1,33 @@ +import stringFilters from 'tower/shared/string-filters/main'; +import formatEpoch from 'tower/shared/format-epoch/main'; + +var $injector = angular.injector(['ng', stringFilters.name, formatEpoch.name]); +var $interpolate = $injector.get('$interpolate'); + +function FactTemplate(templateString) { + this.templateString = templateString; +} + +function loadFactTemplate(factTemplate, fact) { + if (_.isFunction(factTemplate)) { + return factTemplate(fact); + } else { + return factTemplate; + } +} + +FactTemplate.prototype.render = function(factData) { + + if (_.isUndefined(factData) || _.isEmpty(factData)) { + return 'absent'; + } + + var template = loadFactTemplate(this.templateString, factData); + return $interpolate(template)(factData); +}; + +FactTemplate.prototype.hasTemplate = function() { + return !_.isUndefined(this.templateString); +}; + +export default FactTemplate; diff --git a/awx/ui/static/js/system-tracking/compare-facts/flat.js b/awx/ui/static/js/system-tracking/compare-facts/flat.js index 1995caa33f..2c2615158f 100644 --- a/awx/ui/static/js/system-tracking/compare-facts/flat.js +++ b/awx/ui/static/js/system-tracking/compare-facts/flat.js @@ -4,23 +4,6 @@ * All Rights Reserved *************************************************/ -import stringFilters from 'tower/shared/string-filters/main'; - -var $injector = angular.injector(['ng', stringFilters.name]); -var $interpolate = $injector.get('$interpolate'); - -function getFactTemplate(factTemplate, fact) { - if (_.isFunction(factTemplate)) { - return factTemplate(fact); - } else { - return factTemplate; - } -} - -function renderFactTemplate(template, fact) { - return $interpolate(template)(fact); -} - function slotFactValues(basisPosition, basisValue, comparatorValue) { var leftValue, rightValue; @@ -38,92 +21,122 @@ function slotFactValues(basisPosition, basisValue, comparatorValue) { } export default - function flatCompare(basisFacts, comparatorFacts, nameKey, compareKeys, factTemplate) { + function flatCompare(basisFacts, + comparatorFacts, renderOptions) { + + var nameKey = renderOptions.nameKey; + var compareKeys = renderOptions.compareKey; + var keyNameMap = renderOptions.keyNameMap; + var factTemplate = renderOptions.factTemplate; return basisFacts.facts.reduce(function(arr, basisFact) { var searcher = {}; searcher[nameKey] = basisFact[nameKey]; - var basisTemplate, comparatorTemplate, slottedValues, basisValue, comparatorValue; - var matchingFact = _.where(comparatorFacts.facts, searcher); var diffs; - if (_.isEmpty(matchingFact)) { + // Perform comparison and get back comparisonResults; like: + // { 'value': + // { leftValue: 'blah', + // rightValue: 'doo' + // } + // }; + // + var comparisonResults = + _.reduce(compareKeys, function(result, compareKey) { - if (!_.isUndefined(factTemplate)) { + var comparatorFact = matchingFact[0] || {}; + var isNestedDisplay = false; - basisTemplate = getFactTemplate(factTemplate, basisFact); - - basisValue = renderFactTemplate(basisTemplate, basisFact); - slottedValues = slotFactValues(basisFacts.position, basisValue, 'absent'); - - diffs = - { keyName: basisFact[nameKey], - value1: slottedValues.left, - value2: slottedValues.right - }; - - } else { - - diffs = - _.map(basisFact, function(value, key) { - var slottedValues = slotFactValues(basisFacts.position, value, 'absent'); - - return { keyName: key, - value1: slottedValues.left, - value1IsAbsent: slottedValues.left === 'absent', - value2: slottedValues.right, - value2IsAbsent: slotFactValues.right === 'absent' - }; - }); - } - } else { - - matchingFact = matchingFact[0]; - - if (!_.isUndefined(factTemplate)) { - - basisTemplate = getFactTemplate(factTemplate, basisFact); - comparatorTemplate = getFactTemplate(factTemplate, matchingFact); - - basisValue = renderFactTemplate(basisTemplate, basisFact); - comparatorValue = renderFactTemplate(comparatorTemplate, matchingFact); - - slottedValues = slotFactValues(basisFacts.position, basisValue, comparatorValue); - - if (basisValue !== comparatorValue) { - - diffs = - { keyName: basisFact[nameKey], - value1: slottedValues.left, - value2: slottedValues.right - }; + var slottedValues = slotFactValues(basisFacts.position, + basisFact[compareKey], + comparatorFact[compareKey]); + if (_.isUndefined(slottedValues.left) && _.isUndefined(slottedValues.right)) { + return result; } - } else { + var template = factTemplate; - diffs = _(compareKeys) - .map(function(key) { - var slottedValues = slotFactValues(basisFacts.position, - basisFact[key], - matchingFact[key]); + if (_.isObject(template) && template.hasOwnProperty(compareKey)) { + template = template[compareKey]; - if (slottedValues.left !== slottedValues.right) { - return { - keyName: key, - value1: slottedValues.left, - value2: slottedValues.right + // 'true' means render the key without formatting + if (template === true) { + template = + { render: function(fact) { return fact[compareKey]; } }; + } + + isNestedDisplay = true; + } else if (typeof template.hasTemplate === 'function' && !template.hasTemplate()) { + template = + { render: function(fact) { return fact[compareKey]; } + }; + isNestedDisplay = true; + } else if (typeof factTemplate.render === 'function') { + template = factTemplate; + } else if (!template.hasOwnProperty(compareKey)) { + return result; + } + + if (basisFacts.position === 'left') { + slottedValues.left = template.render(basisFact); + slottedValues.right = template.render(comparatorFact); + } else { + slottedValues.left = template.render(comparatorFact); + slottedValues.right = template.render(basisFact); + } + + if (slottedValues.left !== slottedValues.right) { + slottedValues.isDivergent = true; + } else { + slottedValues.isDivergent = false; + } + + if (isNestedDisplay) { + result[compareKey] = slottedValues; + } else { + result = slottedValues; + } + + return result; + }, {}); + + var hasDiffs = + _.any(comparisonResults, { isDivergent: true }) || + comparisonResults.isDivergent === true; + + if (hasDiffs && typeof factTemplate.render === 'function') { + + diffs = + { keyName: basisFact[nameKey], + value1: comparisonResults.left, + value2: comparisonResults.right + }; + + } else if (hasDiffs) { + + diffs = + _(comparisonResults).map(function(slottedValues, key) { + + var keyName = key; + + if (keyNameMap && keyNameMap[key]) { + keyName = keyNameMap[key]; } + + return { keyName: keyName, + value1: slottedValues.left, + value2: slottedValues.right, + isDivergent: slottedValues.isDivergent + }; }).compact() .value(); } - } - var descriptor = { displayKeyPath: basisFact[nameKey], nestingLevel: 0, diff --git a/awx/ui/static/js/system-tracking/data-services/get-module-options.factory.js b/awx/ui/static/js/system-tracking/data-services/get-module-options.factory.js index c82848015d..f780cb12f7 100644 --- a/awx/ui/static/js/system-tracking/data-services/get-module-options.factory.js +++ b/awx/ui/static/js/system-tracking/data-services/get-module-options.factory.js @@ -2,29 +2,34 @@ var moduleConfig = { 'packages': { compareKey: ['release', 'version'], nameKey: 'name', - displayType: 'flat', sortKey: 1, factTemplate: "{{epoch|append:':'}}{{version}}-{{release}}{{arch|prepend:'.'}}" }, 'services': { compareKey: ['state', 'source'], nameKey: 'name', - displayType: 'flat', factTemplate: '{{state}} ({{source}})', sortKey: 2 }, 'files': { compareKey: ['size', 'mode', 'md5', 'mtime', 'gid', 'uid'], + keyNameMap: + { 'uid': 'ownership' + }, + factTemplate: + { 'uid': 'user id: {{uid}}, group id: {{gid}}', + 'mode': true, + 'md5': true, + 'mtime': '{{mtime|formatEpoch}}' + }, nameKey: 'path', - displayType: 'flat', sortKey: 3 }, 'ansible': - { displayType: 'nested', - sortKey: 4 + { sortKey: 4 }, 'custom': - { displayType: 'nested' + { } }; diff --git a/awx/ui/tests/unit/system-tracking/compare-facts/flat-test.js b/awx/ui/tests/unit/system-tracking/compare-facts/flat-test.js new file mode 100644 index 0000000000..2995be4caa --- /dev/null +++ b/awx/ui/tests/unit/system-tracking/compare-facts/flat-test.js @@ -0,0 +1,450 @@ +import compareFacts from 'tower/system-tracking/compare-facts/flat'; + +/* jshint node: true */ +/* globals -expect, -_ */ + +var _, expect; + +// This makes this test runnable in node OR karma. The sheer +// number of times I had to run this test made the karma +// workflow just too dang slow for me. Maybe this can +// be a pattern going forward? Not sure... +// +(function(global) { + var chai = global.chai || require('chai'); + + if (typeof window === 'undefined') { + var chaiThings = global.chaiThings || require('chai-things'); + chai.use(chaiThings); + } + + _ = global._ || require('lodash'); + expect = global.expect || chai.expect; + + global.expect = expect; + + + + global._ = _; + +})(typeof window === 'undefined' ? global : window); + +describe('CompareFacts.Flat', function() { + + function options(overrides) { + return _.merge({}, defaultOptions, overrides); + } + + var defaultTemplate = + { hasTemplate: function() { return false; } + }; + + var defaultOptions = + { factTemplate: defaultTemplate, + nameKey: 'name' + }; + + it('returns empty array with empty basis facts', function() { + var result = compareFacts({ facts: [] }, { facts: [] }, defaultOptions); + + expect(result).to.deep.equal([]); + }); + + it('returns empty array when no differences', function() { + var result = compareFacts( + { facts: + [{ 'name': 'foo', + 'value': 'bar' + }] + }, + { facts: + [{ 'name': 'foo', + 'value': 'bar' + }] + }, options({ nameKey: 'name', + compareKey: ['value'], + })); + + expect(result).to.deep.equal([]); + }); + + it('returns empty array with multiple compare keys and no differences', function() { + var result = compareFacts( + { facts: + [{ 'name': 'foo', + 'value': 'bar' + }] + }, + { facts: + [{ 'name': 'foo', + 'value': 'bar' + }] + }, options({ compareKey: ['name', 'value'] + })); + + expect(result).to.deep.equal([]); + }); + + context('when both collections contain facts', function() { + it('includes each compare key value when a compareKey differs', function() { + var result = compareFacts( + { position: 'left', + facts: + [{ 'name': 'foo', + 'value': 'bar', + 'extra': 'doo' + }] + }, + { position: 'right', + facts: + [{ 'name': 'foo', + 'value': 'baz', + 'extra': 'doo' + }] + }, options({ compareKey: ['value', 'extra'] })); + + expect(result).to.deep.equal( + [{ displayKeyPath: 'foo', + nestingLevel: 0, + facts: + [{ keyName: 'value', + value1: 'bar', + value2: 'baz', + isDivergent: true + }, + { keyName: 'extra', + value1: 'doo', + value2: 'doo', + isDivergent: false + }] + }]); + }); + + it('ignores compare keys with no values in fact', function() { + var result = compareFacts( + { position: 'left', + facts: + [{ 'name': 'foo', + 'value': 'bar', + 'extra': 'doo' + }] + }, + { position: 'right', + facts: + [{ 'name': 'foo', + 'value': 'baz', + 'extra': 'doo' + }] + }, options({ compareKey: ['value', 'extra', 'blah'] })); + + expect(result).to.deep.equal( + [{ displayKeyPath: 'foo', + nestingLevel: 0, + facts: + [{ keyName: 'value', + value1: 'bar', + value2: 'baz', + isDivergent: true + }, + { keyName: 'extra', + value1: 'doo', + value2: 'doo', + isDivergent: false + }] + }]); + + }); + + it('allows mapping key names with keyNameMap parameter', function() { + var keyNameMap = + { 'extra': 'blah' + }; + + var result = compareFacts( + { position: 'left', + facts: + [{ 'name': 'foo', + 'value': 'bar', + 'extra': 'doo' + }] + }, + { position: 'right', + facts: + [{ 'name': 'foo', + 'value': 'baz', + 'extra': 'doo' + }] + }, options({ compareKey: ['value', 'extra', 'blah'], + keyNameMap: keyNameMap + })); + + expect(result[0].facts).to.include.something.that.deep.equals( + { keyName: 'blah', + value1: 'doo', + value2: 'doo', + isDivergent: false + }); + + }); + + it('allows flattening values with factTemplate parameter', function() { + var factTemplate = + { hasTemplate: + function() { + return true; + }, + render: function(fact) { + return 'value: ' + fact.value; + } + }; + + var result = compareFacts( + { position: 'left', + facts: + [{ 'name': 'foo', + 'value': 'bar', + 'extra': 'doo' + }] + }, + { position: 'right', + facts: + [{ 'name': 'foo', + 'value': 'baz', + 'extra': 'doo' + }] + }, options({ compareKey: ['value'], + factTemplate: factTemplate + })); + + expect(result[0].facts).to.deep.equal( + { keyName: 'foo', + value1: 'value: bar', + value2: 'value: baz' + }); + }); + + it('allows formatting values with factTemplate parameter', function() { + var values = ['value1', 'value2']; + var factTemplate = + { 'value': + { hasTemplate: function() { + return true; + }, + render: function() { + return values.shift(); + } + }, + 'extra': true + }; + + var result = compareFacts( + { position: 'left', + facts: + [{ 'name': 'foo', + 'value': 'bar', + 'extra': 'doo' + }] + }, + { position: 'right', + facts: + [{ 'name': 'foo', + 'value': 'baz', + 'extra': 'doo' + }] + }, options({ compareKey: ['value'], + factTemplate: factTemplate + })); + + expect(result[0].facts).to.include.something.that.deep.equals( + { keyName: 'value', + value1: 'value1', + value2: 'value2', + isDivergent: true + }, + { keyName: 'extra', + value1: 'doo', + value2: 'doo', + isDivergent: false + }); + + }); + + it('compares values using the formatted values, not the raw ones', function() { + var values = ['value1', 'value2']; + var factTemplate = + { 'extra': + { render: function() { + return values.shift(); + } + } + }; + + var result = compareFacts( + { position: 'left', + facts: + [{ 'name': 'foo', + 'value': 'bar', + 'extra': 'doo' + }] + }, + { position: 'right', + facts: + [{ 'name': 'foo', + 'value': 'bar', + 'extra': 'doo' + }] + }, options({ factTemplate: factTemplate, + compareKey: ['extra'] + })); + + expect(result.length).to.be.greaterThan(0); + expect(result[0].facts).to.include.something.that.deep.equals( + { keyName: 'extra', + value1: 'value1', + value2: 'value2', + isDivergent: true + }); + + }); + + }); + + context('when value for nameKey is present in one collection but not the other', function() { + + function factData(leftFacts) { + var facts = [{ position: 'left', + facts: leftFacts + }, + { position: 'right', + facts: [] + }]; + + return facts; + } + + it('keeps missing values as undefined', function() { + + var facts = factData([{ 'name': 'foo', + 'value': 'bar' + }]); + + var result = compareFacts(facts[0], facts[1], + options({ compareKey: ['value'] + })); + + expect(result).to.deep.equal( + [{ displayKeyPath: 'foo', + nestingLevel: 0, + facts: + [{ keyName: 'value', + value1: 'bar', + value2: undefined, + isDivergent: true + }] + }]); + }); + + it('still keeps missing values as undefined when using a template', function() { + + var factTemplate = + { hasTemplate: + function() { + return true; + }, + render: + function(fact) { + return fact.value; + } + }; + + var facts = factData([{ 'name': 'foo', + 'value': 'bar' + }]); + + var result = compareFacts(facts[0], facts[1], + options({ compareKey: ['value'], + factTemplate: factTemplate + })); + + expect(result).to.deep.equal( + [{ displayKeyPath: 'foo', + nestingLevel: 0, + facts: + { keyName: 'foo', + value1: 'bar', + value2: undefined + } + }]); + }); + + it('includes given compare keys from basisFacts', function() { + var facts = factData([{ 'name': 'foo', + 'value': 'bar', + 'extra': 'doo' + }]); + + var result = compareFacts(facts[0], facts[1], + options({ compareKey: ['value', 'extra'] + })); + + expect(result).to.deep.equal( + [{ displayKeyPath: 'foo', + nestingLevel: 0, + facts: + [{ keyName: 'value', + value1: 'bar', + value2: undefined, + isDivergent: true + }, + { keyName: 'extra', + value1: 'doo', + value2: undefined, + isDivergent: true + }] + }]); + + }); + + }); + + context('with factTemplate', function() { + var factData; + + beforeEach(function() { + factData = [{ position: 'left', + facts: + [{ 'name': 'foo', + 'value': 'bar' + }] + }, + { position: 'right', + facts: + [{ 'name': 'foo', + 'value': 'baz' + }] + }]; + }); + + it('renders the template with each provided fact', function() { + + var renderCalledWith = []; + var factTemplate = + { render: function(fact) { + renderCalledWith.push(fact); + }, + hasTemplate: function() { return true; }, + template: "" + }; + + compareFacts(factData[0], factData[1], + options({ compareKey: ['value'], + factTemplate: factTemplate + })); + + expect(renderCalledWith).to.include(factData[0].facts[0]); + expect(renderCalledWith).to.include(factData[1].facts[0]); + }); + + + }); +});