1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-27 09:25:10 +03:00

Display comparisons with data from API

See https://gist.github.com/joefiorini/3a8c36bcedf7ad954952 for an
explanation of the comparison logic.
This commit is contained in:
Joe Fiorini 2015-05-18 15:36:13 -04:00
parent 8acc73c833
commit df771d5c0d
34 changed files with 855 additions and 2955 deletions

View File

@ -11,6 +11,9 @@
"maxerr": 10000,
"notypeof": true,
"globals": {
"beforeEach": false,
"inject": false,
"module": false,
"angular":false,
"alert":false,
"$AnsibleConfig":true,
@ -21,6 +24,7 @@
"Donut3D":false,
"nv":false,
"it": false,
"xit": false,
"expect": false,
"context": false,
"describe": false,

View File

@ -802,7 +802,7 @@ export function InventoriesManage ($log, $scope, $rootScope, $location,
ViewUpdateStatus, GroupsDelete, Store, HostsEdit, HostsDelete,
EditInventoryProperties, ToggleHostEnabled, Stream, ShowJobSummary,
InventoryGroupsHelp, HelpDialog, ViewJob,
GroupsCopy, HostsCopy) {
GroupsCopy, HostsCopy, transitionTo) {
var PreviousSearchParams,
url,
@ -864,9 +864,10 @@ export function InventoriesManage ($log, $scope, $rootScope, $location,
});
$scope.systemTracking = function() {
$location.path('/inventories/' + $scope.inventory.id +
'/system-tracking/' +
_.pluck($scope.hostsSelectedItems, "id").join(","));
transitionTo('systemTracking',
{ inventory: $scope.inventory,
hosts: $scope.hostsSelectedItems
});
};
// populates host patterns based on selected hosts/groups
@ -1411,5 +1412,5 @@ InventoriesManage.$inject = ['$log', '$scope', '$rootScope', '$location',
'GroupsDelete', 'Store', 'HostsEdit', 'HostsDelete',
'EditInventoryProperties', 'ToggleHostEnabled', 'Stream', 'ShowJobSummary',
'InventoryGroupsHelp', 'HelpDialog', 'ViewJob', 'GroupsCopy',
'HostsCopy'
'HostsCopy', 'transitionTo'
];

View File

@ -0,0 +1,16 @@
export function wrapDelegate($delegate) {
$delegate.hasModelKey = function hasModelKey(key) {
return $delegate.hasOwnProperty('model') &&
$delegate.model.hasOwnProperty(key);
};
return $delegate;
}
export default
[ '$provide',
function($provide) {
$provide.decorator('$routeParams', wrapDelegate);
}
];

View File

@ -91,7 +91,7 @@ export default
function($location, $rootScope, $route, $q) {
return function(routeName, model) {
var deferred = $q.defer();
var url = lookupRouteUrl(routeName, $route.routes, model);
var url = lookupRouteUrl(routeName, $route.routes, model, true);
var offRouteChangeStart =
$rootScope.$on('$routeChangeStart', function(e, newRoute) {

View File

@ -0,0 +1,20 @@
/* oops */
.include-text-label(@background-color; @color; @content) {
display: inline-block;
content: @content;
border-radius: 3px;
background-color: @background-color;
color: @color;
text-transform: uppercase;
font-size: .7em;
font-weight: bold;
font-style: normal;
margin-left: 0.5em;
padding: 0.35em;
padding-bottom: 0.2em;
line-height: 1.1;
}

View File

@ -0,0 +1,10 @@
import compareNestedFacts from './compare-facts/nested';
import compareFlatFacts from './compare-facts/flat';
export function compareFacts(module, facts) {
if (module.displayType === 'nested') {
return compareNestedFacts(facts);
} else {
return compareFlatFacts(facts, module.nameKey, module.compareKey);
}
}

View File

@ -0,0 +1,57 @@
export default
function flatCompare(facts, nameKey, compareKeys) {
var leftFacts = facts[0];
var rightFacts = facts[1];
return rightFacts.reduce(function(arr, rightFact) {
var searcher = {};
searcher[nameKey] = rightFact[nameKey];
var isNewFactValue = false;
var matchingFact = _.where(leftFacts, searcher);
var diffs;
if (_.isEmpty(matchingFact)) {
isNewFactValue = true;
diffs =
_.map(rightFact, function(value, key) {
return { keyName: key,
value1: value,
value2: ''
};
});
} else {
matchingFact = matchingFact[0];
diffs = _(compareKeys)
.map(function(key) {
var leftValue = rightFact[key];
var rightValue = matchingFact[key];
if (leftValue !== rightValue) {
return {
keyName: key,
value1: leftValue,
value2: rightValue
};
}
}).compact()
.value();
}
var descriptor =
{ displayKeyPath: rightFact[nameKey],
isNew: isNewFactValue,
nestingLevel: 0,
facts: diffs
};
return arr.concat(descriptor);
}, []).filter(function(diff) {
return !_.isEmpty(diff.facts);
});
}

View File

@ -0,0 +1,169 @@
export function formatFacts(diffedResults) {
var loggingEnabled = false;
function log(msg, obj) {
if (loggingEnabled) {
/* jshint ignore:start */
console.log(msg, obj);
/* jshint ignore:end */
}
return obj;
}
function isFlatFactArray(fact) {
// Flat arrays will have the index as their
// keyName
return !_.isNaN(Number(fact.keyName));
}
function isNestedFactArray(fact) {
// Nested arrays will have the index as the last element
// in the keypath
return !_.isNaN(Number(_.last(fact.keyPath)));
}
function isFactArray(fact) {
return isNestedFactArray(fact) || isFlatFactArray(fact);
}
// Explode flat results into groups based matching
// parent keypaths
var grouped = _.groupBy(diffedResults, function(obj) {
var leftKeyPathStr = obj.keyPath.join('.');
log('obj.keyPath', obj.keyPath);
return log(' reduced key', _.reduce(diffedResults, function(result, obj2) {
log(' obj2.keyPath', obj2.keyPath);
var rightKeyPathStr = obj2.keyPath.join('.');
if (isFactArray(obj)) {
log(' number hit!', Number(_.last(obj.keyPath)));
return obj.keyPath.slice(0,-1);
} else if (rightKeyPathStr && leftKeyPathStr !== rightKeyPathStr && log(' intersection', _.intersection(obj.keyPath, obj2.keyPath).join('.')) === rightKeyPathStr) {
log(' hit!');
return obj2.keyPath;
} else {
log(' else hit!');
return result;
}
}, obj.keyPath)).join('.');
});
var normalized = _.mapValues(grouped, function(arr, rootKey) {
log('processing', rootKey);
var nestingLevel = 0;
var trailingLength;
return _(arr).sortBy('keyPath.length').tap(function(arr) {
// Initialize trailing length to the shortest keyPath length
// in the array (first item because we know it's sorted now)
trailingLength = arr[0].keyPath.length;
}).map(function(obj) {
var keyPathStr = obj.keyPath.join('.');
log(' calculating displayKeyPath for', keyPathStr);
var rootKeyPath = rootKey.split('.');
var displayKeyPath;
// var factArrayIndex;
var isFactArrayProp = isFactArray(obj);
if (obj.keyPath.length > trailingLength) {
nestingLevel++;
trailingLength = obj.keyPath.length;
}
if (isNestedFactArray(obj)) {
// factArrayIndex = obj.keyPath.length > 1 ? Number(_.last(obj.keyPath)) : obj.keyName;
displayKeyPath = _.initial(obj.keyPath).join('.');
} else if (keyPathStr !== rootKey) {
displayKeyPath = _.difference(obj.keyPath, rootKeyPath).join('.');
} else {
displayKeyPath = rootKeyPath.join('.');
}
obj.displayKeyPath = displayKeyPath;
obj.nestingLevel = nestingLevel;
// obj.arrayPosition = factArrayIndex;
obj.isArrayMember = isFactArrayProp;
return obj;
}).value();
});
var flattened = _.reduce(normalized, function(flat, value) {
var groupedValues = _.groupBy(value, 'displayKeyPath');
var groupArr =
_.reduce(groupedValues, function(groupArr, facts, key) {
var isArray = facts[0].isArrayMember;
var nestingLevel = facts[0].nestingLevel;
if (isArray) {
facts = _(facts)
.groupBy('arrayPosition')
.values()
.value();
}
var displayObj =
{ keyPath: key.split('.'),
displayKeyPath: key,
facts: facts,
isFactArray: isArray,
nestingLevel: nestingLevel
};
return groupArr.concat(displayObj);
}, []);
return flat.concat(groupArr);
}, []);
return flattened;
}
export function findFacts(factData) {
var rightData = factData[0];
var leftData = factData[1];
function factObject(keyPath, key, leftValue, rightValue) {
var obj =
{ keyPath: keyPath,
keyName: key,
value1: leftValue,
value2: rightValue
};
return obj;
}
function descend(parentValue, parentKey, parentKeys) {
if (_.isObject(parentValue)) {
return _.reduce(parentValue, function(all, value, key) {
var merged = descend(value, key, parentKeys.concat(key));
return all.concat(merged);
}, []);
} else {
var rightValue =
_.get(rightData,
parentKeys,
'absent');
return factObject(
// TODO: Currently parentKeys is getting passed with the final key
// as the last element. Figure out how to have it passed
// in correctly, so that it's all the keys leading up to
// the value, but not the value's key itself
// In the meantime, slicing the last element off the array
parentKeys.slice(0,-1),
parentKey,
parentValue,
rightValue);
}
}
return _.reduce(leftData, function(mergedFacts, parentValue, parentKey) {
var merged = descend(parentValue, parentKey, [parentKey]);
return _.flatten(mergedFacts.concat(merged));
}, []);
}

View File

@ -0,0 +1,63 @@
import {formatFacts, findFacts} from './nested-helpers';
export default function nestedCompare(factsList) {
factsList = findFacts(factsList);
factsList = compareFacts(factsList);
return formatFacts(factsList);
function compareFacts(factsList) {
function serializedFactKey(fact) {
return fact.keyPath.join('.');
}
var groupedByParent =
_.groupBy(factsList, function(fact) {
return serializedFactKey(fact);
});
var diffed = _.mapValues(groupedByParent, function(facts) {
return facts.filter(function(fact) {
return fact.value1 !== fact.value2;
}).map(function(fact) {
// TODO: Can we determine a "compare order" and be able say
// which property is actually divergent?
return _.merge({}, fact, { isDivergent: true });
});
});
var itemsWithDiffs =
_.filter(factsList, function(facts) {
var groupedData = diffed[serializedFactKey(facts)];
return !_.isEmpty(groupedData);
});
var keysWithDiffs =
_.reduce(diffed, function(diffs, facts, key) {
diffs[key] =
facts.reduce(function(diffKeys, fact) {
if (fact.isDivergent) {
return diffKeys.concat(fact.keyName);
}
return diffKeys;
}, []);
return diffs;
}, {});
var factsWithDivergence =
_.mapValues(itemsWithDiffs, function(fact) {
var divergentKeys = keysWithDiffs[serializedFactKey(fact)];
if (divergentKeys) {
var isDivergent = _.include(divergentKeys, fact.keyName);
return _.merge({}, fact, { isDivergent: isDivergent });
} else {
return _.merge({}, fact, { isDivergent: false });
}
});
return factsWithDivergence;
}
}

View File

@ -1,7 +0,0 @@
export default
['xorObjects', 'formatResults', function(xorObjects, formatResults) {
return function compareHosts(module, factData1, factData2) {
var diffed = xorObjects('name', factData1, factData2);
return formatResults('name', 'version', diffed);
};
}];

View File

@ -1,7 +1,22 @@
export default ['Rest', 'GetBasePath', 'ProcessErrors',
function (Rest, GetBasePath, ProcessErrors) {
export default ['Rest', 'GetBasePath', 'ProcessErrors', 'lodashAsPromised',
function (Rest, GetBasePath, ProcessErrors, _) {
return {
getFacts: function(version){
getHostFacts: function(host, moduleName, date, fetchScanNumber) {
var version =this.getVersion(host, moduleName, date.from, date.to, fetchScanNumber);
var getFacts = this.getFacts;
return version
.then(function(versionData) {
if (_.isEmpty(versionData)) {
return [];
} else {
return getFacts(versionData);
}
});
},
getFacts: function(version) {
var promise;
Rest.setUrl(version.related.fact_view);
promise = Rest.get();
@ -16,15 +31,18 @@ function (Rest, GetBasePath, ProcessErrors) {
});
},
getVersion: function(host_id, module, startDate, endDate){
getVersion: function(host_id, module, startDate, endDate, fetchScanNumber){
//move the build url into getVersion and have the
// parameters passed into this
var promise,
url = this.buildUrl(host_id, module, startDate, endDate);
fetchScanNumber = fetchScanNumber || 0;
Rest.setUrl(url);
promise = Rest.get();
return promise.then(function(data) {
return data.data.results[0];
return data.data.results[fetchScanNumber];
}).catch(function (response) {
ProcessErrors(null, response.data, response.status, null, {
hdr: 'Error!',
@ -36,7 +54,7 @@ function (Rest, GetBasePath, ProcessErrors) {
buildUrl: function(host_id, module, startDate, endDate){
var url = GetBasePath('hosts') + host_id + '/fact_versions/',
params= [["module", module] , ['startDate', startDate.format()], ['endDate', endDate.format()]];
params= [["module", module] , ['from', startDate.format()], ['to', endDate.format()]];
params = params.filter(function(p){
return !_.isEmpty(p[1]);

View File

@ -0,0 +1,42 @@
export default
[ 'factScanDataService',
'lodashAsPromised',
function(factScanDataService, _) {
return function(hostIds, moduleName, leftDate, rightDate) {
if (hostIds.length === 1) {
hostIds = hostIds.concat(hostIds[0]);
}
return _(hostIds)
.promise()
.thenMap(function(hostId, index) {
var date = leftDate;
var fetchScanNumber;
if (index === 1) {
date = rightDate;
} else {
if (rightDate.from.isSame(leftDate.from, 'day')) {
fetchScanNumber = 1;
}
}
var params =
[ hostId,
moduleName,
date,
fetchScanNumber
];
return params;
}).thenMap(function(params) {
var getHostFacts =
_.spread(factScanDataService.getHostFacts)
.bind(factScanDataService);
return getHostFacts(params);
});
};
}
];

View File

@ -0,0 +1,38 @@
/** @define DatePicker */
.DatePicker {
flex: 1;
display: flex;
&-icon {
flex: initial;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px 0 0 4px;
border: 1px solid #ccc;
border-right: 0;
background-color: #fff;
}
&-icon:hover {
background-color: #e8e8e8;
}
&-icon:focus,
&-icon:active {
background-color: #ccc;
}
&-input {
flex: 1;
border-radius: 0 4px 4px 0;
border: 1px solid #ccc;
padding: 6px 12px;
}
&-input:focus,
&-input:active {
outline-offset: 0;
outline: 0;
}
}

View File

@ -0,0 +1,44 @@
/* jshint unused: vars */
export default
[ '$rootScope',
function() {
return {
restrict: 'E',
scope: {
date: '='
},
templateUrl: '/static/js/system-tracking/date-picker/date-picker.partial.html',
link: function(scope, element, attrs) {
// We need to make sure this _never_ recurses, which sometimes happens
// with two-way binding.
var mustUpdateValue = true;
scope.$watch('date', function(newValue) {
if (newValue) {
mustUpdateValue = false;
scope.dateValue = newValue.format('L');
}
}, true);
scope.$watch('dateValue', function(newValue) {
var newDate = moment(newValue);
if (newValue && !newDate.isValid()) {
scope.error = "That is not a valid date.";
} else if (newValue) {
scope.date = newDate;
}
mustUpdateValue = true;
});
element.find(".DatePicker").addClass("input-prepend date");
element.find(".DatePicker").find(".DatePicker-icon").addClass("add-on");
$(".date").systemTrackingDP({
autoclose: true
});
}
};
}
];

View File

@ -0,0 +1,9 @@
<div class="DatePicker">
<button class="DatePicker-icon"><i class="fa fa-calendar"></i></button>
<input
class="DatePicker-input"
type="text"
readonly
ng-model="dateValue">
<p class="error" ng-if="error">{{error}}</p>
</div>

View File

@ -0,0 +1,7 @@
import datePicker from './date-picker.directive';
export default
angular.module('systemTracking.datePicker',
[])
.directive('datePicker', datePicker);

View File

@ -0,0 +1,16 @@
/** @define FactDataGroup */
@import 'shared/text-label.less';
.FactDataGroup {
&-header {
display: flex;
&--new {
&:after {
.include-text-label(#676767; white; "new");
align-self: center;
font-size: 1rem;
}
}
}
}

View File

@ -1,63 +0,0 @@
import fakeData from './fake-data';
function getComparisonData(module) {
var valueName, leftValue, rightValue;
switch(module) {
case 'packages':
valueName = 'bash';
leftValue = '5.2.3';
rightValue = '5.2.4';
break;
case 'services':
valueName = 'httpd';
leftValue = 'started';
rightValue = 'absent';
break;
case 'files':
valueName = '/etc/sudoers';
leftValue = 'some string';
rightValue = 'some other string';
break;
case 'ansible':
valueName = 'ansible_ipv4_address';
leftValue = '192.168.0.1';
rightValue = '192.168.0.2';
break;
}
return [{ module: module,
valueName: valueName,
leftValue: leftValue,
rightValue: rightValue
}];
}
export default
['$q', 'compareHosts', function factDataServiceFactory($q, compareHosts) {
return function(type) {
if (type === 'multiHost') {
return { get: function(inventoryId, module, host1, host2) {
var result = {};
result.leftFilterValue = host1;
result.rightFilterValue = host2;
result.factComparisonData = compareHosts(module, fakeData.host1, fakeData.host2);
return result;
}
};
} else if (type === 'singleHost') {
return { get: function(inventoryId, module, startDate, endDate) {
var result = {};
result.leftFilterValue = startDate;
result.rightFilterValue = endDate;
result.factComparisonData = getComparisonData(module);
return result;
}
};
}
};
}]

View File

@ -0,0 +1,13 @@
/** @define FactDataTable */
.FactDataTable {
&-row {
display: flex;
}
&-column {
flex: 1;
&--offsetLeft {
padding-left: 33%;
}
}
}

View File

@ -0,0 +1,38 @@
<table class="table table-condensed">
<thead>
<tr>
<th></th>
<th>{{comparisonLeftHeader|stringOrDate:'L'}}</th>
<th>{{comparisonRightHeader|stringOrDate:'L'}}</th>
</tr>
</thead>
<tbody ng-repeat="group in factData">
<tr ng-switch="group.nestingLevel" ng-if="group.displayKeyPath">
<td colspan="3" ng-switch-when="0">
<h2>{{group.displayKeyPath}}</h2>
</td>
<td colspan="3" ng-switch-when="1">
<h3>{{group.displayKeyPath}}</h3>
</td>
<td colspan="3" ng-switch-when="2">
<h4>{{group.displayKeyPath}}</h4>
</td>
<td colspan="3" ng-switch-when="3">
<h5>{{group.displayKeyPath}}</h5>
</td>
</tr>
<tr ng-repeat="fact in group.facts" data-fact-array-position="{{fact.arrayPosition}}">
<td>{{fact.keyName}}</td>
<td>
<p style="word-break: break-all;">
{{fact.value1}}
</p>
</td>
<td>
<p style="word-break: break-all;">
{{fact.value2}}
</p>
</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,7 @@
/** @define FactDatum */
.FactDatum {
&--divergent {
font-style: italic;
}
}

View File

@ -5,65 +5,23 @@
display: flex;
margin-bottom: 15px;
&-dateSpacer {
flex: initial;
width: 0px;
}
&-dateContainer {
flex: 1;
display: flex;
flex-wrap: wrap;
flex-direction: column
}
&-date {
flex: 1;
// flex-wrap: wrap;
display: flex;
}
&-date--right,
&-label--right {
margin-left: 7px;
}
&-date--left {
&-dateContainer--left {
margin-right: 7px;
}
&-dateContainer--right {
margin-left: 7px;
}
&-label {
flex: initial;
width: 100%;
flex: 1;
font-weight: 700;
padding-bottom: 5px;
}
&-dateIcon {
flex: initial;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px 0 0 4px;
border: 1px solid #ccc;
border-right: 0;
background-color: #fff;
}
&-dateIcon:hover,
&-dateIcon:focus,
&-dateIcon:active {
background-color: #ccc;
}
&-dateInput {
flex: 1;
border-radius: 0 4px 4px 0;
border: 1px solid #ccc;
padding: 6px 12px;
}
&-dateInput:focus,
&-dateInput:active {
outline-offset: 0;
outline: 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +0,0 @@
export default
function() {
return function formatResults(compareKey, displayKey, results) {
return results.reduce(function(arr, value) {
var obj =
{ keyName: value[compareKey],
value1: value.position === 0 ? value[displayKey] : 'absent',
value2: value.position === 1 ? value[displayKey] : 'absent'
};
return arr.concat(obj);
}, []);
};
}

View File

@ -1,21 +1,21 @@
import route from './system-tracking.route';
import singleHostDataService from './single-host-data.service';
import factDataServiceFactory from './fact-data-service.factory';
import factScanDataService from './data-services/fact-scan-data.service';
import getDataForComparison from './data-services/get-data-for-comparison.factory';
import controller from './system-tracking.controller';
import stringOrDateFilter from './string-or-date.filter';
import xorObjects from './xor-objects.factory';
import formatResults from './format-results.factory';
import compareHosts from './compare-hosts.factory';
import shared from 'tower/shared/main';
import utilities from 'tower/shared/Utilities';
import datePicker from './date-picker/main';
export default
angular.module('systemTracking',
[ 'angularMoment'
[ 'angularMoment',
utilities.name,
shared.name,
datePicker.name
])
.factory('factDataServiceFactory', factDataServiceFactory)
.service('singleHostDataService', singleHostDataService)
.factory('xorObjects', xorObjects)
.factory('formatResults', formatResults)
.factory('compareHosts', compareHosts)
.service('factScanDataService', factScanDataService)
.factory('getDataForComparison', getDataForComparison)
.filter('stringOrDate', stringOrDateFilter)
.controller('systemTracking', controller)
.config(['$routeProvider', function($routeProvider) {
@ -23,6 +23,3 @@ export default
delete route.route;
$routeProvider.when(url, route);
}]);

View File

@ -1,168 +0,0 @@
## How I will do it
1. Find all facts from results
2. Filter out all "same facts"
3. Transform for display
### Finding facts from results
Iterate over fact collection. Check if a thing is a fact or not (it is a fact when all of its key's values are comparable (not an object or array). If it's a fact, then transform it to an object that contains the nested key and value from each candidate. If it's not a fact, then recurse passing in the parent keys & value until we find a fact.
To accomplish this we'll reduce over the values in the fact collection to create a new array. For each key, we'll check the type of its value. If it's an object or an array, we'll append the key to an array of parent keys and pass that and the value into a recursive call. If it's not an object or array, we'll record the parent key path as an array & both left & right values. We'll return the accumulator array with that object concatenated to it.
End result example (FactComparison):
[{ keyPath: ['sda', 'partitions', 'sda1'],
key: 'sectors',
leftValue: '39843840',
rightValue: '13254121'
},
{ keyPath: ['sda', partitions', 'sda1'],
key: 'model',
leftValue: 'VMware Virtual S",
rightValue: ''
}];
### Filtering out "same" facts
This needs to look at all of the facts by parent key and remove any of those that have one or more differences. This will leave plenty of context for the user to determine exactly what is different here. For example, given the facts:
#### Left Host
```json
{ "ansible_mounts":
[{
"device": "/dev/sda1",
"fstype": "ext4",
"mount": "/",
"options": "rw,errors=remount-ro",
"size_available": 15032406016,
"size_total": 20079898624
}]
}
```
#### Right Host
```json
{ "ansible_mounts":
[{
"device": "/dev/sda1",
"fstype": "btrfs",
"mount": "/",
"options": "rw,errors=remount-ro",
"size_available": 153985231054,
"size_total": 53056978564321
}]
}
```
If all the user could see was that the `fstype` fields were different, this would leave them wondering "what device is that on? where did that come from?" We are solving this problem by displaying all sibling properties of a fact regardless of whether they are different when at least one of those properties contains a difference.
Therefore, to compare facts we need to first group them by their keys. Once we do that, we'll have a structure like:
```json
{ 'sda.partitions.sda1':
[{ keyPath: ['sda', 'partitions', 'sda1'],
key: 'sectors',
leftValue: '39843840',
rightValue: '13254121'
},
{ keyPath: ['sda', partitions', 'sda1'],
key: 'model',
leftValue: 'VMware Virtual S",
rightValue: ''
}]
}
```
The simplest way to handle this would be to map over each key in this grouped object and return a filtered array of only objects with differences. Then we could iterate over the resulting object and filter out any keys whose value is an empty array, leaving us with only the keys that contain at least a single difference. Finally, we iterate over the original collection keeping only those values whose `keyPath` is in the previous collection of keys and return that result.
### Transforming for display
Given fact comparisons of:
[{ keyPath: ['ansible_devices', 'sda'],
key: 'host',
leftValue: 'SCSI storage controller: LSI Logic / Symbios Logic 53c1030 PCI-X Fusion-MPT Dual Ultra320 SCSI (rev 01)',
rightValue: ''
},
{ keyPath: ['ansible_devices', 'sda'],
key: 'model',
leftValue: 'VMWare Virtual S',
rightValue: 'APPLE SSD SM256C'
},
{ keyPath: ['ansible_devices', 'sda', 'partitions', 'sda1'],
key: 'sectors',
leftValue: '39843840',
rightValue: '13254121'
},
{ keyPath: ['ansible_devices', 'sda', partitions', 'sda1'],
key: 'sectorsize',
leftValue: '512',
rightValue: '512'
},
{ keyPath: ['ansible_mounts', '0'],
key: 'device',
leftValue: '/dev/sda5',
rightValue: '/dev/sda1'
},
{ keyPath: ['ansible_mounts', '0'],
key: 'fstype',
leftValue: 'ext4',
rightValue: 'btrfs'
},
{ keyPath: ['ansible_mounts', '1'],
key: 'device',
leftValue: 'absent',
rightValue: '/dev/sda5'
}];
We need to transform that to something like:
[{ keyPath: ['ansible_devices', 'sda'],
displayKeyPath: 'ansible_devices.sda',
nestingLevel: 1,
facts:
[{ keyPath: ['ansible_devices', 'sda'],
key: 'host',
value1: 'SCSI storage controller: LSI Logic / Symbios Logic 53c1030 PCI-X Fusion-MPT Dual Ultra320 SCSI (rev 01)',
value2: ''
},
{ keyPath: ['ansible_devices', 'sda'],
keyName: 'model',
value1: 'VMWare Virtual S',
value2: 'APPLE SSD SM256C'
}],
},
{ keyPath: ['ansible_devices', 'sda', 'partitions', 'sda1'],
displayKeyPath: 'partitions.sda1',
nestingLevel: 2,
facts:
// ...
},
{ keyPath: ['ansible_mounts'],
displayKeyPath: 'ansible_mounts',
nestingLevel: 1,
isArray: true,
facts:
[ [{ keyPath: ['ansible_mounts', '0'],
key: 'device',
leftValue: '/dev/sda5',
rightValue: '/dev/sda1'
},
{ keyPath: ['ansible_mounts', '0'],
key: 'fstype',
leftValue: 'ext4',
rightValue: 'btrfs'
}],
[{ keyPath: ['ansible_mounts', '1'],
key: 'device',
leftValue: 'absent',
rightValue: '/dev/sda5'
}]
]
}]
```

View File

@ -0,0 +1,6 @@
export function searchDateRange(date) {
return {
from: moment(date).startOf('day'),
to: moment(date).endOf('day')
};
}

View File

@ -9,4 +9,4 @@ export default
}
};
}
]
];

View File

@ -1,37 +1,41 @@
function controller($rootScope, $scope, $routeParams, $location, $q, factDataServiceFactory, moment) {
import {searchDateRange} from './search-date-range';
import {compareFacts} from './compare-facts';
var service;
var inventoryId = $routeParams.id;
function controller($rootScope,
$scope,
$routeParams,
$location,
$q,
initialFactData,
getDataForComparison,
waitIndicator,
_) {
// var inventoryId = $routeParams.id;
var hostIds = $routeParams.hosts.split(',');
var moduleParam = $location.search().module || 'packages';
var configReadyOff =
$rootScope.$on('ConfigReady', function() {
$(".date").systemTrackingDP({
autoclose: true
});
configReadyOff();
});
$scope.leftFilterValue = hostIds[0];
$scope.rightFilterValue = hostIds[1];
$scope.factModulePickersLabelLeft = "Fact collection date for host " + $scope.leftFilterValue;
$scope.factModulePickersLabelRight = "Fact collection date for host " + $scope.rightFilterValue;
$scope.factModulePickersLabelLeft = "Compare facts collected on";
$scope.factModulePickersLabelRight = "To facts collected on";
$scope.modules =
[{ name: 'packages',
displayName: 'Packages',
compareKey: ['release', 'version'],
nameKey: 'name',
isActive: true,
displayType: 'flat'
},
{ name: 'services',
compareKey: ['state', 'source'],
nameKey: 'name',
displayName: 'Services',
isActive: false,
displayType: 'flat'
},
{ name: 'files',
displayName: 'Files',
nameKey: 'path',
compareKey: ['size', 'mode', 'md5', 'mtime', 'gid', 'uid'],
isActive: false,
displayType: 'flat'
},
@ -45,25 +49,57 @@ function controller($rootScope, $scope, $routeParams, $location, $q, factDataSer
// Use this to determine how to orchestrate the services
var viewType = hostIds.length > 1 ? 'multiHost' : 'singleHost';
if (viewType === 'singleHost') {
var startDate = moment();
$scope.leftFilterValue = startDate;
$scope.rightFilterValue = startDate.clone().subtract(1, 'days');
var searchConfig =
{ leftDate: initialFactData.leftDate,
rightDate: initialFactData.rightDate
};
$scope.leftDate = initialFactData.leftDate.from;
$scope.rightDate = initialFactData.rightDate.from;
function setHeaderValues(viewType) {
if (viewType === 'singleHost') {
$scope.comparisonLeftHeader = $scope.leftDate;
$scope.comparisonRightHeader = $scope.rightDate;
} else {
$scope.comparisonLeftHeader = hostIds[0];
$scope.comparisonRightHeader = hostIds[1];
}
}
service = factDataServiceFactory(viewType);
function reloadData(params, initialData) {
searchConfig = _.merge({}, searchConfig, params);
function reloadData(activeModule) {
activeModule.then(function(module) {
$scope.factData =
service.get(inventoryId,
module.name,
$scope.leftFilterValue,
$scope.rightFilterValue);
});
var factData = initialData;
var leftDate = searchConfig.leftDate;
var rightDate = searchConfig.rightDate;
var activeModule = searchConfig.module;
if (!factData) {
factData = getDataForComparison(
hostIds,
activeModule.name,
leftDate,
rightDate);
}
waitIndicator('start');
_(factData)
.thenAll(_.partial(compareFacts, activeModule))
.then(function(info) {
$scope.factData = info;
setHeaderValues(viewType);
}).finally(function() {
waitIndicator('stop');
})
.value();
}
$scope.setActiveModule = function(newModuleName) {
$scope.setActiveModule = function(newModuleName, initialData) {
var newModule = _.find($scope.modules, function(module) {
return module.name === newModuleName;
@ -74,14 +110,40 @@ function controller($rootScope, $scope, $routeParams, $location, $q, factDataSer
});
newModule.isActive = true;
$location.replace();
$location.search('module', newModuleName);
reloadData($q.when(newModule));
reloadData(
{ module: newModule
}, initialData);
};
function dateWatcher(dateProperty) {
return function(newValue, oldValue) {
// passing in `true` for the 3rd param to $watch should make
// angular use `angular.equals` for comparing these values;
// the watcher should not fire, but it still is. Therefore,
// using `moment.isSame` to keep from reloading data when the
// dates did not actually change
if (newValue.isSame(oldValue)) {
return;
}
$scope.setActiveModule(moduleParam);
var newDate = searchDateRange(newValue);
var params = {};
params[dateProperty] = newDate;
reloadData(params);
};
}
$scope.$watch('leftDate', dateWatcher('leftDate'), true);
$scope.$watch('rightDate', dateWatcher('rightDate'), true);
$scope.setActiveModule(initialFactData.moduleName, initialFactData);
}
export default
@ -90,7 +152,9 @@ export default
'$routeParams',
'$location',
'$q',
'factDataServiceFactory',
'moment',
'factScanData',
'getDataForComparison',
'Wait',
'lodashAsPromised',
controller
];

View File

@ -1,21 +1,11 @@
<div class="FactModulePickers">
<div class="FactModulePickers-dateSpacer">
</div>
<div class="FactModulePickers-dateContainer">
<div class="FactModulePickers-dateContainer FactModulePickers-dateContainer--left">
<span class="FactModulePickers-label">{{ factModulePickersLabelLeft }}</span>
<div class="input-prepend date FactModulePickers-date FactModulePickers-date--left">
<button class="add-on FactModulePickers-dateIcon"><i class="fa fa-calendar"></i></button>
<input class="FactModulePickers-dateInput" type="text" value="12-02-2012">
</div>
<date-picker date="leftDate"></date-picker>
</div>
<div class="FactModulePickers-dateContainer">
<span class="FactModulePickers-label FactModulePickers-label--right">
{{ factModulePickersLabelRight }}
</span>
<div class="input-prepend date FactModulePickers-date FactModulePickers-date--right">
<button class="add-on FactModulePickers-dateIcon"><i class="fa fa-calendar"></i></button>
<input class="FactModulePickers-dateInput" type="text" value="12-02-2012">
</div>
<div class="FactModulePickers-dateContainer FactModulePickers-dateContainer--right">
<span class="FactModulePickers-label">{{ factModulePickersLabelRight }}</span>
<date-picker date="rightDate"></date-picker>
</div>
</div>
@ -33,19 +23,57 @@
</button>
</nav>
<table class="table table-condensed">
<thead>
<tr>
<th></th>
<th>{{factData.leftFilterValue|stringOrDate:'L'}}</th>
<th>{{factData.rightFilterValue|stringOrDate:'L'}}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="fact in factData.factComparisonData">
<td>{{fact.keyName}}</td>
<td>{{fact.value1}}</td>
<td>{{fact.value2}}</td>
</tr>
</tbody>
</table>
<section class="FactDataError" ng-if="error" ng-switch="error">
<p class="FactDataError-message" ng-switch-when="NoFactsForModule">
There were no facts collected for that module in the selected date range. Please pick a different range or module and try again.
</p>
</section>
<section class="FactDataTable" ng-unless="error">
<div class="FactDataTable-row">
<h3 class="FactDataTable-column FactDataTable-column--offsetLeft">{{comparisonLeftHeader|stringOrDate:'L'}}</h3>
<h3 class="FactDataTable-column">{{comparisonRightHeader|stringOrDate:'L'}}</h3>
</div>
<div class="FactDataTable-factGroup FactDataGroup" ng-repeat="group in factData | orderBy: 'displayKeyPath'">
<div class="FactDataTable-row FactDataGroup-headings" ng-switch="group.nestingLevel" ng-if="group.displayKeyPath">
<h2 class="FactDataTable-column FactDataTable-column--full FactDataGroup-header" ng-class="{ 'FactDataGroup-header--new': group.isNew }" ng-switch-when="0">
{{group.displayKeyPath}}
</h2>
<h3 class="FactDataTable-column FactDataTable-column--full" ng-switch-when="1">
{{group.displayKeyPath}}
</h3>
<h4 class="FactDataTable-column FactDataTable-column--full" ng-switch-when="2">
{{group.displayKeyPath}}
</h4>
<h5 class="FactDataTable-column FactDataTable-column--full" ng-switch-when="3">
{{group.displayKeyPath}}
</h5>
</div>
<div class="FactDataGroup-facts" data-facts="{{group.facts}}">
<div class="FactDataTable-arrayGroup" ng-if="group.isFactArray" ng-repeat="arrayGroup in group.facts" data-array-group="{{arrayGroup}}">
<div class="FactDataTable-row FactDatum" ng-class="{'FactDatum--divergent': fact.isDivergent }" ng-repeat="fact in arrayGroup" data-fact="{{fact}}">
<p class="FactDatum-keyName FactDataTable-column">
{{fact.keyName}}
</p>
<p class="FactDatum-value FactDataTable-column" style="word-break: break-all">
{{fact.value1}}
</p>
<p class="FactDatum-value FactDataTable-column" style="word-break: break-all">
{{fact.value2}}
</p>
</div>
</div>
<div class="FactDataTable-row FactDatum" ng-class="{'FactDatum--divergent': fact.isDivergent }" ng-repeat="fact in group.facts" ng-unless="group.isFactArray" data-fact="{{fact}}">
<p class="FactDataTable-column FactDatum-keyName">
{{fact.keyName}}
</p>
<p class="FactDataTable-column FactDatum-value" style="word-break: break-all">
{{fact.value1}}
</p>
<p class="FactDataTable-column FactDatum-value" style="word-break: break-all">
{{fact.value2}}
</p>
</div>
</div>
</div>
</section>

View File

@ -1,5 +1,88 @@
import {searchDateRange} from './search-date-range';
export default {
route: '/inventories/:id/system-tracking/:hosts',
name: 'systemTracking',
route: '/inventories/:inventory/system-tracking/:hosts',
controller: 'systemTracking',
templateUrl: '/static/js/system-tracking/system-tracking.partial.html'
templateUrl: '/static/js/system-tracking/system-tracking.partial.html',
resolve: {
factScanData:
[ 'getDataForComparison',
'lodashAsPromised',
'$route',
'$location',
function(getDataForComparison, _, $route, $location) {
var hostIds = $route.current.params.hosts.split(',');
var moduleParam = $location.search().module || 'packages';
var leftDate = searchDateRange('2015-05-26');
var rightDate = searchDateRange('2015-05-26');
if (hostIds.length === 1) {
hostIds = hostIds.concat(hostIds[0]);
}
var data =
getDataForComparison(hostIds, moduleParam, leftDate, rightDate).
thenThru(function(factData) {
factData.leftDate = leftDate;
factData.rightDate = rightDate;
factData.moduleName = moduleParam;
return factData;
})
.value();
return data;
}
],
inventory:
[ '$route',
'$q',
'Rest',
'GetBasePath',
function($route, $q, rest, getBasePath) {
if ($route.current.params.inventory) {
return $q.when(true);
}
var inventoryId = $route.current.params.inventory;
var url = getBasePath('inventory') + inventoryId + '/';
rest.setUrl(url);
return rest.get()
.then(function(data) {
return data.data;
});
}
],
filters:
[ '$route',
'$q',
'Rest',
'GetBasePath',
function($route, $q, rest, getBasePath) {
if ($route.current.params.hosts) {
return $q.when(true);
}
var hostIds = $route.current.params.filters.split(',');
var hosts =
hostIds.map(function(hostId) {
var url = getBasePath('hosts') +
hostId + '/';
rest.setUrl(url);
return rest.get()
.then(function(data) {
return data.data;
});
});
return $q.all(hosts);
}
]
}
};

View File

@ -1,28 +0,0 @@
export default
function() {
return function xorObjects(key, thing1, thing2) {
var values1 = _.pluck(thing1, key);
var values2 = _.pluck(thing2, key);
var valuesDiff = _.xor(values1, values2);
return valuesDiff.reduce(function(arr, value) {
var searcher = {};
searcher[key] = value;
var valuePosition1 = _.find(thing1, searcher);
if (valuePosition1) {
valuePosition1.position = 0;
}
var valuePosition2 = _.find(thing2, searcher);
if (valuePosition2) {
valuePosition2.position = 1;
}
return _.compact(arr.concat(valuePosition1).concat(valuePosition2));
}, []);
};
}

View File

@ -1,19 +1,4 @@
.include-text-label(@background-color; @color; @content) {
display: inline-block;
content: @content;
border-radius: 3px;
background-color: @background-color;
color: @color;
text-transform: uppercase;
font-size: .7em;
font-weight: bold;
font-style: normal;
margin-left: 0.5em;
padding: 0.35em;
padding-bottom: 0.2em;
line-height: 1.1;
}
@import "../js/shared/text-label.less";
.host-disabled-label {
&:after {

View File

@ -2,7 +2,7 @@ import systemTracking from 'tower/system-tracking/main';
import {describeModule} from '../describe-module';
describeModule(systemTracking.name)
.testService('singleHostDataService', function(test, restStub) {
.testService('factScanDataService', function(test, restStub) {
var service;