You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by da...@apache.org on 2019/05/07 21:51:35 UTC

[trafficcontrol] branch master updated: TP: improved snapshot diff performance/experience (#3540)

This is an automated email from the ASF dual-hosted git repository.

dangogh pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new 5cd8c96  TP: improved snapshot diff performance/experience (#3540)
5cd8c96 is described below

commit 5cd8c96a26324e7e245a095019121b8258d1651c
Author: Jeremy Mitchell <mi...@users.noreply.github.com>
AuthorDate: Tue May 7 15:51:29 2019 -0600

    TP: improved snapshot diff performance/experience (#3540)
    
    * better/faster snapshot diffing using fast-json-patch
    
    * adds the old value/current value to the diff
    
    * adds trLocations diff section
    
    * added 'track by $index' to avoid adding extra $$hashKey in the diff objects
    
    * makes the level of json expansion configurable
    
    * adds the ability to view current/pending snapshot in its entirety
    
    * removes the ability to download the raw snapshot. just not performant enough.
    
    * adds MIT license info for fast-json-patch
    
    * adds snapshot diff improvement to changelog
    
    * vendors jsonformatter and adds license info
    
    * adds newline at EOF
    
    * fixes tab indexes
    
    * replaces vars with let / const
    
    * changes button from default type=submit to type=button
    
    * removes extra license which got in here as the result of a bad merge
    
    * fixes license for jsonformatter
    
    * uses documented options rather than undocumented ones
    
    * adds jsonformatter and fast-json-patch
    
    * renamed js file
---
 .dependency_license                                |   3 +
 .rat-excludes                                      |   3 +
 CHANGELOG.md                                       |   1 +
 LICENSE                                            |   9 +
 LICENSE => licenses/AL2-jsonformatter              | 221 +----
 licenses/MIT-fast-json-patch                       |  22 +
 traffic_portal/app/src/app.js                      |   1 +
 .../app/src/assets/css/jsonformatter.min_0.6.0.css |   6 +
 .../app/src/assets/js/fast-json-patch_v2.1.0.js    | 985 +++++++++++++++++++++
 .../app/src/assets/js/jsonformatter.min_0.6.0.js   |   7 +
 traffic_portal/app/src/index.html                  |   3 +
 .../private/cdns/config/ConfigController.js        | 203 ++---
 .../src/modules/private/cdns/config/_config.scss   |   7 +
 .../modules/private/cdns/config/config.tpl.html    | 270 +++++-
 traffic_portal/app/src/styles/main.scss            |   4 +
 .../app/src/traffic_portal_properties.json         |   4 +-
 16 files changed, 1358 insertions(+), 391 deletions(-)

diff --git a/.dependency_license b/.dependency_license
index 187a3e1..da5267c 100644
--- a/.dependency_license
+++ b/.dependency_license
@@ -102,6 +102,9 @@ jquery\.dataTables\..*\.(css|js)$, MIT
 github\.com/basho/backoff/.*, MIT
 github\.com/dchest/siphash/.*, CC0
 traffic_portal/app/src/assets/js/chartjs/angular-chart\..*, BSD
+traffic_portal/app/src/assets/css/jsonformatter\..*, Apache
+traffic_portal/app/src/assets/js/jsonformatter\..*, Apache
+traffic_portal/app/src/assets/js/fast-json-patch\..*, MIT
 
 # Ignored - Do not report.
 \.DS_Store, Ignore # Created automatically OSX.
diff --git a/.rat-excludes b/.rat-excludes
index acdd2fa..9946f24 100644
--- a/.rat-excludes
+++ b/.rat-excludes
@@ -79,3 +79,6 @@ chartjs(?:                           MIT. Properly documented in LICENSE ){0}
 json-iterator(?:                     MIT. Properly documented in LICENSE ){0}
 modern-go(?:                         Apache 2.0. Properly documented in LICENSE ){0}
 angular-moment-picker(?:             MIT. Properly documented in LICENSE ){0}
+fast-json-patch(?:                   MIT. Properly documented in LICENSE ){0}
+jsonformatter(?:                     Apache 2.0. Properly documented in LICENSE ){0}
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c164bc0..f0fc2e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,6 +46,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - Traffic Ops (golang), Traffic Monitor, Traffic Stats are now compiled using Go version 1.11. Grove was already being compiled with this version which improves performance for TLS when RSA certificates are used.
 - Issue 3476: Traffic Router returns partial result for CLIENT_STEERING Delivery Services when Regional Geoblocking or Anonymous Blocking is enabled.
 - Upgraded Traffic Portal to AngularJS 1.7.8
+- Issue 3275: Improved the snapshot diff performance and experience.
 
 
 ## [3.0.0] - 2018-10-30
diff --git a/LICENSE b/LICENSE
index dafd021..e50a46b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -236,6 +236,11 @@ For the sorttable component:
 For the jQuery component:
 ./licenses/MIT-jquery
 
+For the JSON formatter component:
+@traffic_portal/app/src/assets/css/jsonformatter.min_0.6.0.css
+@traffic_portal/app/src/assets/js/jsonformatter.min_0.6.0.js
+./licenses/AL2-jsonformatter
+
 For the DataTables component:
 @traffic_portal/app/src/assets/css/jquery.dataTables.min_1.10.9.css
 @traffic_portal/app/src/assets/js/jquery.dataTables.min_1.10.16.js
@@ -250,6 +255,10 @@ For the jsdiff component:
 @traffic_portal/app/src/assets/js/jsdiff-min_3.5.0.js
 ./licenses/BSD-jsdiff
 
+For the fast-json-patch component:
+@traffic_portal/app/src/assets/js/fast-json-patch_v2.1.0.js
+./licenses/MIT-fast-json-patch
+
 For the angular-moment-picker component:
 @traffic_portal/app/src/assets/css/angular-moment-picker_0.10.2.css
 @traffic_portal/app/src/assets/js/moment-picker/angular-moment-picker.min_0.10.2.js
diff --git a/LICENSE b/licenses/AL2-jsonformatter
similarity index 60%
copy from LICENSE
copy to licenses/AL2-jsonformatter
index dafd021..06d25bb 100644
--- a/LICENSE
+++ b/licenses/AL2-jsonformatter
@@ -1,4 +1,3 @@
-
                                  Apache License
                            Version 2.0, January 2004
                         http://www.apache.org/licenses/
@@ -187,7 +186,7 @@
       same "printed page" as the copyright notice for easier
       identification within third-party archives.
 
-   Copyright [yyyy] [name of copyright owner]
+   Copyright 2014 Mohsen Azimi
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
@@ -200,221 +199,3 @@
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.
-
-
-APACHE TRAFFICCONTROL SUBCOMPONENTS:
-
-Apache TrafficControl includes a number of subcomponents with
-separate copyright notices and license terms. Your use of the source
-code for the these subcomponents is subject to the terms and
-conditions of the following licenses.
-
-For readability, subcomponent licenses have been broken out into their own
-files, largely located in the licenses directory. Each subcomponent enumerated
-below lists the files it comprises and a link to the full text of the license.
-
-For the fontawesome component:
-@traffic_portal/app/src/assets/fonts/[Ff]ont[Aa]wesome*
-
-These binary fontawesome fonts are provided under the SIL Open Font License 1.1:
-./licenses/SIL-1.1
-
-The fontawesome CSS files are provided under an MIT license:
-./licenses/MIT-fontawesome
-
-For the bootstrap component:
-@misc/traffic-control-cdn/*/bootstrap*
-./licenses/MIT-bootstrap
-
-/*! normalize.css v3.0.1 | MIT License | git.io/normalize */
-./licenses/MIT-normalize
-
-For the sorttable component:
-@traffic_monitor/static/sorttable.js
-./licenses/MIT-sorttable
-
-For the jQuery component:
-./licenses/MIT-jquery
-
-For the DataTables component:
-@traffic_portal/app/src/assets/css/jquery.dataTables.min_1.10.9.css
-@traffic_portal/app/src/assets/js/jquery.dataTables.min_1.10.16.js
-@traffic_portal/app/src/assets/images/sort_*
-./licenses/MIT-datatables
-
-For the moment.js component:
-@traffic_portal/app/src/assets/js/moment-min_2.22.1.js
-./licenses/MIT-momentjs
-
-For the jsdiff component:
-@traffic_portal/app/src/assets/js/jsdiff-min_3.5.0.js
-./licenses/BSD-jsdiff
-
-For the angular-moment-picker component:
-@traffic_portal/app/src/assets/css/angular-moment-picker_0.10.2.css
-@traffic_portal/app/src/assets/js/moment-picker/angular-moment-picker.min_0.10.2.js
-./licenses/MIT-angular_moment_picker
-
-For the Chart.js component:
-@traffic_portal/app/src/assets/js/chartjs/Chart.min_2.7.2.js
-./licenses/MIT-chartjs
-
-For the angular-chart.js component:
-@traffic_portal/app/src/assets/js/chartjs/angular-chart.min_1.1.1.js
-./licenses/BSD-angular-chartjs
-
-For the downloadjs component:
-@traffic_portal/app/src/assets/js/downloadjs-min_v4.21.js
-./licenses/MIT-downloadjs
-
-For the angular-loading-bar component:
-@traffic_portal/app/src/styles/loading.scss
-./licenses/MIT-angular_loading_bar
-
-For the gentelella admin theme:
-@traffic_portal/app/src/styles/theme.scss
-./licenses/MIT-gentelella
-
-For the underscore component:
-@traffic_portal/app/src/assets/js/underscore-min_1.8.3.js
-./licenses/MIT-underscore
-
-For the selenium component:
-@infrastructure/test/ui/vendor/github.com/tebeka/selenium/*
-./infrastructure/test/ui/vendor/github.com/tebeka/selenium/LICENSE.txt
-
-For the stoppableListener component:
-@traffic_monitor/vendor/github.com/hydrogen18/stoppableListener/*
-./traffic_monitor/vendor/github.com/hydrogen18/stoppableListener/LICENSE
-
-For the fsnotify component:
-@vendor/gopkg.in/fsnotify.v1/*
-./vendor/gopkg.in/fsnotify.v1/LICENSE
-
-For the yaml component:
-@vendor/gopkg.in/yaml.v2/*
-./vendor/gopkg.in/yaml.v2/LICENSE
-./vendor/gopkg.in/yaml.v2/LICENSE.libyaml
-
-For the jwt-go component:
-@traffic_ops/experimental/traffic_ops_auth/vendor/github.com/dgrijalva/jwt-go/*
-./traffic_ops/experimental/traffic_ops_auth/vendor/github.com/dgrijalva/jwt-go/LICENSE
-
-For the pq component:
-@traffic_ops/experimental/traffic_ops_auth/vendor/github.com/lib/pq/*
-@vendor/github.com/lib/pq/*
-./vendor/github.com/lib/pq/LICENSE.md
-
-For the influxdb component:
-@traffic_stats/vendor/github.com/influxdata/influxdb/*
-./traffic_stats/vendor/github.com/influxdata/influxdb/LICENSE
-./traffic_stats/vendor/github.com/influxdata/influxdb/LICENSE_OF_DEPENDENCIES.md
-
-For the errors component:
-@traffic_stats/vendor/github.com/pkg/errors/*
-./traffic_stats/vendor/github.com/pkg/errors/LICENSE
-
-For the MaxMind DB GeoLite2 Database:
-@traffic_router/core/src/test/resources/geo/GeoLite2-City.mmdb.gz
-./licenses/CC_A_SA_4-maxmind
-
-For the gofmt github hook:
-@misc/git/pre-commit-hooks/01-gofmt
-./licenses/BSD-go
-
-For the IPv6 Perl Module component:
-@traffic_ops/app/bin/checks/NetPacket/IPv6.pm
-./licenses/ISC-netpacket_ipv6
-
-Several subsections of main.css are under the MIT license, as noted in the file:
-@traffic_portal/app/src/styles/main.scss
-@traffic_portal/app/src/styles/main.scss
-
-    For the bootstrap-vertical-tabs component:
-    ./licenses/MIT-bootstrap_vertical_tabs
-
-    For the cropper component:
-    ./licenses/MIT-cropper
-
-    For the bootstrap-progressbar component:
-    ./licenses/MIT-bootstrap_progressbar
-
-For the seelog component:
-@traffic_stats/vendor/github.com/cihub/seelog/*
-./licenses/BSD-seelog
-
-The invalid_passwords.txt file is from the Projects/OWASP SecLists Project provided under a MIT license:
-@traffic_ops/app/conf/invalid_passwords.txt
-./licenses/MIT-SecLists
-
-For the go-sqlmock component:
-@traffic_ops/vendor/gopkg.in/DATA-DOG/go-sqlmock.v1/*
-./traffic_ops/vendor/gopkg.in/DATA-DOG/go-sqlmock.v1/LICENSE
-
-For the go-jwx component:
-@traffic_ops/vendor/github.com/lestrrat/go-jwx/*
-./traffic_ops/vendor/github.com/lestrrat/go-jwx/LICENSE
-
-For the protobuf component:
-@traffic_ops/vendor/github.com/golang/protobuf/*
-./traffic_ops/vendor/github.com/golang/protobuf/LICENSE
-
-For the sqlx component:
-@vendor/github.com/jmoiron/sqlx/*
-./vendor/github.com/jmoiron/sqlx/LICENSE
-
-For the backoff component:
-@traffic_ops/vendor/github.com/basho/backoff/*
-./traffic_ops/vendor/github.com/basho/backoff/README.md
-
-The riak-go-client component is used under the Apache license:
-@traffic_ops/vendor/github.com/basho/riak-go-client/*
-./traffic_ops/vendor/github.com/basho/riak-go-client/LICENSE
-
-The envconfig component is used under the MIT license:
-@traffic_ops/vendor/github.com/kelseyhightower/envconfig/*
-./traffic_ops/vendor/github.com/kelseyhightower/envconfig/LICENSE
-
-The govalidator component is used under the MIT license:
-@vendor/github.com/asaskevich/govalidator/*
-./vendor/github.com/asaskevich/govalidator/LICENSE
-
-The ozzo-validation component is used under the MIT license:
-@vendor/github.com/go-ozzo/ozzo-validation/*
-./vendor/github.com/go-ozzo/ozzo-validation/LICENSE
-
-The bytefmt component is used under the Apache 2.0 license:
-@grove/vendor/code.cloudfoundry.org/bytefmt/*
-./grove/vendor/code.cloudfoundry.org/bytefmt/LICENSE
-
-The bytefmt component is used under the MIT license:
-@grove/vendor/github.com/coreos/bbolt/*
-./grove/vendor/github.com/coreos/bbolt/LICENSE
-
-For the siphash component:
-@grove/vendor/github.com/dchest/siphash/*
-./licenses/CC0
-
-For the miekg/dns component:
-@traffic_ops/traffic_ops_golang/vendor/github.com/miekg/dns/*
-./licenses/BSD-miekg-dns
-
-The asn1-ber.v1 component is used under the MIT license:
-@traffic_ops/vendor/gopkg.in/asn1-ber.v1/*
-./traffic_ops/vendor/gopkg.in/asn1-ber.v1/LICENSE
-
-The ldap.v2 component is used under the MIT license:
-@traffic_ops/vendor/gopkg.in/ldap.v2/*
-./traffic_ops/vendor/gopkg.in/ldap.v2/LICENSE
-
-The json-iterator/go component is used under the MIT license:
-@vendor/github.com/json-iterator/go/*
-./vendor/github.com/json-iterator/go/LICENSE
-
-The modern-go/concurrent component is used under the Apache 2.0 license:
-@vendor/github.com/modern-go/concurrent/*
-./vendor/github.com/modern-go/concurrent/LICENSE
-
-The modern-go/reflect2 component is used under the Apache 2.0 license:
-@vendor/github.com/modern-go/reflect2/*
-./vendor/github.com/modern-go/reflect2/LICENSE
diff --git a/licenses/MIT-fast-json-patch b/licenses/MIT-fast-json-patch
new file mode 100644
index 0000000..b0e1042
--- /dev/null
+++ b/licenses/MIT-fast-json-patch
@@ -0,0 +1,22 @@
+The MIT License
+
+Copyright (c) 2013, 2014 Joachim Wester
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/traffic_portal/app/src/app.js b/traffic_portal/app/src/app.js
index cc6c100..6819bad 100644
--- a/traffic_portal/app/src/app.js
+++ b/traffic_portal/app/src/app.js
@@ -41,6 +41,7 @@ var trafficPortal = angular.module('trafficPortal', [
         'chart.js',
         'angular-loading-bar',
         'moment-picker',
+        'jsonFormatter',
 
         // public modules
         require('./modules/public').name,
diff --git a/traffic_portal/app/src/assets/css/jsonformatter.min_0.6.0.css b/traffic_portal/app/src/assets/css/jsonformatter.min_0.6.0.css
new file mode 100644
index 0000000..4440783
--- /dev/null
+++ b/traffic_portal/app/src/assets/css/jsonformatter.min_0.6.0.css
@@ -0,0 +1,6 @@
+/*!
+ * jsonformatter
+ *
+ * Version: 0.6.0 - 2016-08-27T12:58:03.339Z
+ * License: Apache-2.0
+ */.json-formatter-dark.json-formatter-row,.json-formatter-row{font-family:monospace}.json-formatter-dark.json-formatter-row .toggler.open:after,.json-formatter-row .toggler.open:after{transform:rotate(90deg)}.json-formatter-row,.json-formatter-row a,.json-formatter-row a:hover{color:#000;text-decoration:none}.json-formatter-row .json-formatter-row{margin-left:1em}.json-formatter-row .children.empty{opacity:.5;margin-left:1em}.json-formatter-row .children.empty.object:after{content:"No p [...]
diff --git a/traffic_portal/app/src/assets/js/fast-json-patch_v2.1.0.js b/traffic_portal/app/src/assets/js/fast-json-patch_v2.1.0.js
new file mode 100644
index 0000000..5242acf
--- /dev/null
+++ b/traffic_portal/app/src/assets/js/fast-json-patch_v2.1.0.js
@@ -0,0 +1,985 @@
+/*! fast-json-patch, version: 2.1.0 */
+var jsonpatch =
+	/******/ (function(modules) { // webpackBootstrap
+	/******/ 	// The module cache
+	/******/ 	var installedModules = {};
+	/******/
+	/******/ 	// The require function
+	/******/ 	function __webpack_require__(moduleId) {
+		/******/
+		/******/ 		// Check if module is in cache
+		/******/ 		if(installedModules[moduleId]) {
+			/******/ 			return installedModules[moduleId].exports;
+			/******/ 		}
+		/******/ 		// Create a new module (and put it into the cache)
+		/******/ 		var module = installedModules[moduleId] = {
+			/******/ 			i: moduleId,
+			/******/ 			l: false,
+			/******/ 			exports: {}
+			/******/ 		};
+		/******/
+		/******/ 		// Execute the module function
+		/******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
+		/******/
+		/******/ 		// Flag the module as loaded
+		/******/ 		module.l = true;
+		/******/
+		/******/ 		// Return the exports of the module
+		/******/ 		return module.exports;
+		/******/ 	}
+	/******/
+	/******/
+	/******/ 	// expose the modules object (__webpack_modules__)
+	/******/ 	__webpack_require__.m = modules;
+	/******/
+	/******/ 	// expose the module cache
+	/******/ 	__webpack_require__.c = installedModules;
+	/******/
+	/******/ 	// identity function for calling harmony imports with the correct context
+	/******/ 	__webpack_require__.i = function(value) { return value; };
+	/******/
+	/******/ 	// define getter function for harmony exports
+	/******/ 	__webpack_require__.d = function(exports, name, getter) {
+		/******/ 		if(!__webpack_require__.o(exports, name)) {
+			/******/ 			Object.defineProperty(exports, name, {
+				/******/ 				configurable: false,
+				/******/ 				enumerable: true,
+				/******/ 				get: getter
+				/******/ 			});
+			/******/ 		}
+		/******/ 	};
+	/******/
+	/******/ 	// getDefaultExport function for compatibility with non-harmony modules
+	/******/ 	__webpack_require__.n = function(module) {
+		/******/ 		var getter = module && module.__esModule ?
+			/******/ 			function getDefault() { return module['default']; } :
+			/******/ 			function getModuleExports() { return module; };
+		/******/ 		__webpack_require__.d(getter, 'a', getter);
+		/******/ 		return getter;
+		/******/ 	};
+	/******/
+	/******/ 	// Object.prototype.hasOwnProperty.call
+	/******/ 	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
+	/******/
+	/******/ 	// __webpack_public_path__
+	/******/ 	__webpack_require__.p = "";
+	/******/
+	/******/ 	// Load entry module and return exports
+	/******/ 	return __webpack_require__(__webpack_require__.s = 2);
+	/******/ })
+/************************************************************************/
+/******/ ([
+	/* 0 */
+	/***/ (function(module, exports) {
+
+		/*!
+		 * https://github.com/Starcounter-Jack/JSON-Patch
+		 * (c) 2017 Joachim Wester
+		 * MIT license
+		 */
+		var __extends = (this && this.__extends) || function (d, b) {
+			for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
+			function __() { this.constructor = d; }
+			d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
+		};
+		var _hasOwnProperty = Object.prototype.hasOwnProperty;
+		function hasOwnProperty(obj, key) {
+			return _hasOwnProperty.call(obj, key);
+		}
+		exports.hasOwnProperty = hasOwnProperty;
+		function _objectKeys(obj) {
+			if (Array.isArray(obj)) {
+				var keys = new Array(obj.length);
+				for (var k = 0; k < keys.length; k++) {
+					keys[k] = "" + k;
+				}
+				return keys;
+			}
+			if (Object.keys) {
+				return Object.keys(obj);
+			}
+			var keys = [];
+			for (var i in obj) {
+				if (hasOwnProperty(obj, i)) {
+					keys.push(i);
+				}
+			}
+			return keys;
+		}
+		exports._objectKeys = _objectKeys;
+		;
+		/**
+		 * Deeply clone the object.
+		 * https://jsperf.com/deep-copy-vs-json-stringify-json-parse/25 (recursiveDeepCopy)
+		 * @param  {any} obj value to clone
+		 * @return {any} cloned obj
+		 */
+		function _deepClone(obj) {
+			switch (typeof obj) {
+				case "object":
+					return JSON.parse(JSON.stringify(obj)); //Faster than ES5 clone - http://jsperf.com/deep-cloning-of-objects/5
+				case "undefined":
+					return null; //this is how JSON.stringify behaves for array items
+				default:
+					return obj; //no need to clone primitives
+			}
+		}
+		exports._deepClone = _deepClone;
+//3x faster than cached /^\d+$/.test(str)
+		function isInteger(str) {
+			var i = 0;
+			var len = str.length;
+			var charCode;
+			while (i < len) {
+				charCode = str.charCodeAt(i);
+				if (charCode >= 48 && charCode <= 57) {
+					i++;
+					continue;
+				}
+				return false;
+			}
+			return true;
+		}
+		exports.isInteger = isInteger;
+		/**
+		 * Escapes a json pointer path
+		 * @param path The raw pointer
+		 * @return the Escaped path
+		 */
+		function escapePathComponent(path) {
+			if (path.indexOf('/') === -1 && path.indexOf('~') === -1)
+				return path;
+			return path.replace(/~/g, '~0').replace(/\//g, '~1');
+		}
+		exports.escapePathComponent = escapePathComponent;
+		/**
+		 * Unescapes a json pointer path
+		 * @param path The escaped pointer
+		 * @return The unescaped path
+		 */
+		function unescapePathComponent(path) {
+			return path.replace(/~1/g, '/').replace(/~0/g, '~');
+		}
+		exports.unescapePathComponent = unescapePathComponent;
+		function _getPathRecursive(root, obj) {
+			var found;
+			for (var key in root) {
+				if (hasOwnProperty(root, key)) {
+					if (root[key] === obj) {
+						return escapePathComponent(key) + '/';
+					}
+					else if (typeof root[key] === 'object') {
+						found = _getPathRecursive(root[key], obj);
+						if (found != '') {
+							return escapePathComponent(key) + '/' + found;
+						}
+					}
+				}
+			}
+			return '';
+		}
+		exports._getPathRecursive = _getPathRecursive;
+		function getPath(root, obj) {
+			if (root === obj) {
+				return '/';
+			}
+			var path = _getPathRecursive(root, obj);
+			if (path === '') {
+				throw new Error("Object not found in root");
+			}
+			return '/' + path;
+		}
+		exports.getPath = getPath;
+		/**
+		 * Recursively checks whether an object has any undefined values inside.
+		 */
+		function hasUndefined(obj) {
+			if (obj === undefined) {
+				return true;
+			}
+			if (obj) {
+				if (Array.isArray(obj)) {
+					for (var i = 0, len = obj.length; i < len; i++) {
+						if (hasUndefined(obj[i])) {
+							return true;
+						}
+					}
+				}
+				else if (typeof obj === "object") {
+					var objKeys = _objectKeys(obj);
+					var objKeysLength = objKeys.length;
+					for (var i = 0; i < objKeysLength; i++) {
+						if (hasUndefined(obj[objKeys[i]])) {
+							return true;
+						}
+					}
+				}
+			}
+			return false;
+		}
+		exports.hasUndefined = hasUndefined;
+		function patchErrorMessageFormatter(message, args) {
+			var messageParts = [message];
+			for (var key in args) {
+				var value = typeof args[key] === 'object' ? JSON.stringify(args[key], null, 2) : args[key]; // pretty print
+				if (typeof value !== 'undefined') {
+					messageParts.push(key + ": " + value);
+				}
+			}
+			return messageParts.join('\n');
+		}
+		var PatchError = (function (_super) {
+			__extends(PatchError, _super);
+			function PatchError(message, name, index, operation, tree) {
+				_super.call(this, patchErrorMessageFormatter(message, { name: name, index: index, operation: operation, tree: tree }));
+				this.name = name;
+				this.index = index;
+				this.operation = operation;
+				this.tree = tree;
+				this.message = patchErrorMessageFormatter(message, { name: name, index: index, operation: operation, tree: tree });
+			}
+			return PatchError;
+		}(Error));
+		exports.PatchError = PatchError;
+
+
+		/***/ }),
+	/* 1 */
+	/***/ (function(module, exports, __webpack_require__) {
+
+		var equalsOptions = { strict: true };
+		var _equals = __webpack_require__(3);
+		var areEquals = function (a, b) {
+			return _equals(a, b, equalsOptions);
+		};
+		var helpers_1 = __webpack_require__(0);
+		exports.JsonPatchError = helpers_1.PatchError;
+		exports.deepClone = helpers_1._deepClone;
+		/* We use a Javascript hash to store each
+		 function. Each hash entry (property) uses
+		 the operation identifiers specified in rfc6902.
+		 In this way, we can map each patch operation
+		 to its dedicated function in efficient way.
+		 */
+		/* The operations applicable to an object */
+		var objOps = {
+			add: function (obj, key, document) {
+				obj[key] = this.value;
+				return { newDocument: document };
+			},
+			remove: function (obj, key, document) {
+				var removed = obj[key];
+				delete obj[key];
+				return { newDocument: document, removed: removed };
+			},
+			replace: function (obj, key, document) {
+				var removed = obj[key];
+				obj[key] = this.value;
+				return { newDocument: document, removed: removed };
+			},
+			move: function (obj, key, document) {
+				/* in case move target overwrites an existing value,
+				return the removed value, this can be taxing performance-wise,
+				and is potentially unneeded */
+				var removed = getValueByPointer(document, this.path);
+				if (removed) {
+					removed = helpers_1._deepClone(removed);
+				}
+				var originalValue = applyOperation(document, { op: "remove", path: this.from }).removed;
+				applyOperation(document, { op: "add", path: this.path, value: originalValue });
+				return { newDocument: document, removed: removed };
+			},
+			copy: function (obj, key, document) {
+				var valueToCopy = getValueByPointer(document, this.from);
+				// enforce copy by value so further operations don't affect source (see issue #177)
+				applyOperation(document, { op: "add", path: this.path, value: helpers_1._deepClone(valueToCopy) });
+				return { newDocument: document };
+			},
+			test: function (obj, key, document) {
+				return { newDocument: document, test: areEquals(obj[key], this.value) };
+			},
+			_get: function (obj, key, document) {
+				this.value = obj[key];
+				return { newDocument: document };
+			}
+		};
+		/* The operations applicable to an array. Many are the same as for the object */
+		var arrOps = {
+			add: function (arr, i, document) {
+				if (helpers_1.isInteger(i)) {
+					arr.splice(i, 0, this.value);
+				}
+				else {
+					arr[i] = this.value;
+				}
+				// this may be needed when using '-' in an array
+				return { newDocument: document, index: i };
+			},
+			remove: function (arr, i, document) {
+				var removedList = arr.splice(i, 1);
+				return { newDocument: document, removed: removedList[0] };
+			},
+			replace: function (arr, i, document) {
+				var removed = arr[i];
+				arr[i] = this.value;
+				return { newDocument: document, removed: removed };
+			},
+			move: objOps.move,
+			copy: objOps.copy,
+			test: objOps.test,
+			_get: objOps._get
+		};
+		/**
+		 * Retrieves a value from a JSON document by a JSON pointer.
+		 * Returns the value.
+		 *
+		 * @param document The document to get the value from
+		 * @param pointer an escaped JSON pointer
+		 * @return The retrieved value
+		 */
+		function getValueByPointer(document, pointer) {
+			if (pointer == '') {
+				return document;
+			}
+			var getOriginalDestination = { op: "_get", path: pointer };
+			applyOperation(document, getOriginalDestination);
+			return getOriginalDestination.value;
+		}
+		exports.getValueByPointer = getValueByPointer;
+		/**
+		 * Apply a single JSON Patch Operation on a JSON document.
+		 * Returns the {newDocument, result} of the operation.
+		 * It modifies the `document` and `operation` objects - it gets the values by reference.
+		 * If you would like to avoid touching your values, clone them:
+		 * `jsonpatch.applyOperation(document, jsonpatch._deepClone(operation))`.
+		 *
+		 * @param document The document to patch
+		 * @param operation The operation to apply
+		 * @param validateOperation `false` is without validation, `true` to use default jsonpatch's validation, or you can pass a `validateOperation` callback to be used for validation.
+		 * @param mutateDocument Whether to mutate the original document or clone it before applying
+		 * @param banPrototypeModifications Whether to ban modifications to `__proto__`, defaults to `true`.
+		 * @return `{newDocument, result}` after the operation
+		 */
+		function applyOperation(document, operation, validateOperation, mutateDocument, banPrototypeModifications, index) {
+			if (validateOperation === void 0) { validateOperation = false; }
+			if (mutateDocument === void 0) { mutateDocument = true; }
+			if (banPrototypeModifications === void 0) { banPrototypeModifications = true; }
+			if (index === void 0) { index = 0; }
+			if (validateOperation) {
+				if (typeof validateOperation == 'function') {
+					validateOperation(operation, 0, document, operation.path);
+				}
+				else {
+					validator(operation, 0);
+				}
+			}
+			/* ROOT OPERATIONS */
+			if (operation.path === "") {
+				var returnValue = { newDocument: document };
+				if (operation.op === 'add') {
+					returnValue.newDocument = operation.value;
+					return returnValue;
+				}
+				else if (operation.op === 'replace') {
+					returnValue.newDocument = operation.value;
+					returnValue.removed = document; //document we removed
+					return returnValue;
+				}
+				else if (operation.op === 'move' || operation.op === 'copy') {
+					returnValue.newDocument = getValueByPointer(document, operation.from); // get the value by json-pointer in `from` field
+					if (operation.op === 'move') {
+						returnValue.removed = document;
+					}
+					return returnValue;
+				}
+				else if (operation.op === 'test') {
+					returnValue.test = areEquals(document, operation.value);
+					if (returnValue.test === false) {
+						throw new exports.JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document);
+					}
+					returnValue.newDocument = document;
+					return returnValue;
+				}
+				else if (operation.op === 'remove') {
+					returnValue.removed = document;
+					returnValue.newDocument = null;
+					return returnValue;
+				}
+				else if (operation.op === '_get') {
+					operation.value = document;
+					return returnValue;
+				}
+				else {
+					if (validateOperation) {
+						throw new exports.JsonPatchError('Operation `op` property is not one of operations defined in RFC-6902', 'OPERATION_OP_INVALID', index, operation, document);
+					}
+					else {
+						return returnValue;
+					}
+				}
+			} /* END ROOT OPERATIONS */
+			else {
+				if (!mutateDocument) {
+					document = helpers_1._deepClone(document);
+				}
+				var path = operation.path || "";
+				var keys = path.split('/');
+				var obj = document;
+				var t = 1; //skip empty element - http://jsperf.com/to-shift-or-not-to-shift
+				var len = keys.length;
+				var existingPathFragment = undefined;
+				var key = void 0;
+				var validateFunction = void 0;
+				if (typeof validateOperation == 'function') {
+					validateFunction = validateOperation;
+				}
+				else {
+					validateFunction = validator;
+				}
+				while (true) {
+					key = keys[t];
+					if (banPrototypeModifications && key == '__proto__') {
+						throw new TypeError('JSON-Patch: modifying `__proto__` prop is banned for security reasons, if this was on purpose, please set `banPrototypeModifications` flag false and pass it to this function. More info in fast-json-patch README');
+					}
+					if (validateOperation) {
+						if (existingPathFragment === undefined) {
+							if (obj[key] === undefined) {
+								existingPathFragment = keys.slice(0, t).join('/');
+							}
+							else if (t == len - 1) {
+								existingPathFragment = operation.path;
+							}
+							if (existingPathFragment !== undefined) {
+								validateFunction(operation, 0, document, existingPathFragment);
+							}
+						}
+					}
+					t++;
+					if (Array.isArray(obj)) {
+						if (key === '-') {
+							key = obj.length;
+						}
+						else {
+							if (validateOperation && !helpers_1.isInteger(key)) {
+								throw new exports.JsonPatchError("Expected an unsigned base-10 integer value, making the new referenced value the array element with the zero-based index", "OPERATION_PATH_ILLEGAL_ARRAY_INDEX", index, operation, document);
+							} // only parse key when it's an integer for `arr.prop` to work
+							else if (helpers_1.isInteger(key)) {
+								key = ~~key;
+							}
+						}
+						if (t >= len) {
+							if (validateOperation && operation.op === "add" && key > obj.length) {
+								throw new exports.JsonPatchError("The specified index MUST NOT be greater than the number of elements in the array", "OPERATION_VALUE_OUT_OF_BOUNDS", index, operation, document);
+							}
+							var returnValue = arrOps[operation.op].call(operation, obj, key, document); // Apply patch
+							if (returnValue.test === false) {
+								throw new exports.JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document);
+							}
+							return returnValue;
+						}
+					}
+					else {
+						if (key && key.indexOf('~') != -1) {
+							key = helpers_1.unescapePathComponent(key);
+						}
+						if (t >= len) {
+							var returnValue = objOps[operation.op].call(operation, obj, key, document); // Apply patch
+							if (returnValue.test === false) {
+								throw new exports.JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document);
+							}
+							return returnValue;
+						}
+					}
+					obj = obj[key];
+				}
+			}
+		}
+		exports.applyOperation = applyOperation;
+		/**
+		 * Apply a full JSON Patch array on a JSON document.
+		 * Returns the {newDocument, result} of the patch.
+		 * It modifies the `document` object and `patch` - it gets the values by reference.
+		 * If you would like to avoid touching your values, clone them:
+		 * `jsonpatch.applyPatch(document, jsonpatch._deepClone(patch))`.
+		 *
+		 * @param document The document to patch
+		 * @param patch The patch to apply
+		 * @param validateOperation `false` is without validation, `true` to use default jsonpatch's validation, or you can pass a `validateOperation` callback to be used for validation.
+		 * @param mutateDocument Whether to mutate the original document or clone it before applying
+		 * @param banPrototypeModifications Whether to ban modifications to `__proto__`, defaults to `true`.
+		 * @return An array of `{newDocument, result}` after the patch
+		 */
+		function applyPatch(document, patch, validateOperation, mutateDocument, banPrototypeModifications) {
+			if (mutateDocument === void 0) { mutateDocument = true; }
+			if (banPrototypeModifications === void 0) { banPrototypeModifications = true; }
+			if (validateOperation) {
+				if (!Array.isArray(patch)) {
+					throw new exports.JsonPatchError('Patch sequence must be an array', 'SEQUENCE_NOT_AN_ARRAY');
+				}
+			}
+			if (!mutateDocument) {
+				document = helpers_1._deepClone(document);
+			}
+			var results = new Array(patch.length);
+			for (var i = 0, length_1 = patch.length; i < length_1; i++) {
+				// we don't need to pass mutateDocument argument because if it was true, we already deep cloned the object, we'll just pass `true`
+				results[i] = applyOperation(document, patch[i], validateOperation, true, banPrototypeModifications, i);
+				document = results[i].newDocument; // in case root was replaced
+			}
+			results.newDocument = document;
+			return results;
+		}
+		exports.applyPatch = applyPatch;
+		/**
+		 * Apply a single JSON Patch Operation on a JSON document.
+		 * Returns the updated document.
+		 * Suitable as a reducer.
+		 *
+		 * @param document The document to patch
+		 * @param operation The operation to apply
+		 * @return The updated document
+		 */
+		function applyReducer(document, operation, index) {
+			var operationResult = applyOperation(document, operation);
+			if (operationResult.test === false) {
+				throw new exports.JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document);
+			}
+			return operationResult.newDocument;
+		}
+		exports.applyReducer = applyReducer;
+		/**
+		 * Validates a single operation. Called from `jsonpatch.validate`. Throws `JsonPatchError` in case of an error.
+		 * @param {object} operation - operation object (patch)
+		 * @param {number} index - index of operation in the sequence
+		 * @param {object} [document] - object where the operation is supposed to be applied
+		 * @param {string} [existingPathFragment] - comes along with `document`
+		 */
+		function validator(operation, index, document, existingPathFragment) {
+			if (typeof operation !== 'object' || operation === null || Array.isArray(operation)) {
+				throw new exports.JsonPatchError('Operation is not an object', 'OPERATION_NOT_AN_OBJECT', index, operation, document);
+			}
+			else if (!objOps[operation.op]) {
+				throw new exports.JsonPatchError('Operation `op` property is not one of operations defined in RFC-6902', 'OPERATION_OP_INVALID', index, operation, document);
+			}
+			else if (typeof operation.path !== 'string') {
+				throw new exports.JsonPatchError('Operation `path` property is not a string', 'OPERATION_PATH_INVALID', index, operation, document);
+			}
+			else if (operation.path.indexOf('/') !== 0 && operation.path.length > 0) {
+				// paths that aren't empty string should start with "/"
+				throw new exports.JsonPatchError('Operation `path` property must start with "/"', 'OPERATION_PATH_INVALID', index, operation, document);
+			}
+			else if ((operation.op === 'move' || operation.op === 'copy') && typeof operation.from !== 'string') {
+				throw new exports.JsonPatchError('Operation `from` property is not present (applicable in `move` and `copy` operations)', 'OPERATION_FROM_REQUIRED', index, operation, document);
+			}
+			else if ((operation.op === 'add' || operation.op === 'replace' || operation.op === 'test') && operation.value === undefined) {
+				throw new exports.JsonPatchError('Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)', 'OPERATION_VALUE_REQUIRED', index, operation, document);
+			}
+			else if ((operation.op === 'add' || operation.op === 'replace' || operation.op === 'test') && helpers_1.hasUndefined(operation.value)) {
+				throw new exports.JsonPatchError('Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)', 'OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED', index, operation, document);
+			}
+			else if (document) {
+				if (operation.op == "add") {
+					var pathLen = operation.path.split("/").length;
+					var existingPathLen = existingPathFragment.split("/").length;
+					if (pathLen !== existingPathLen + 1 && pathLen !== existingPathLen) {
+						throw new exports.JsonPatchError('Cannot perform an `add` operation at the desired path', 'OPERATION_PATH_CANNOT_ADD', index, operation, document);
+					}
+				}
+				else if (operation.op === 'replace' || operation.op === 'remove' || operation.op === '_get') {
+					if (operation.path !== existingPathFragment) {
+						throw new exports.JsonPatchError('Cannot perform the operation at a path that does not exist', 'OPERATION_PATH_UNRESOLVABLE', index, operation, document);
+					}
+				}
+				else if (operation.op === 'move' || operation.op === 'copy') {
+					var existingValue = { op: "_get", path: operation.from, value: undefined };
+					var error = validate([existingValue], document);
+					if (error && error.name === 'OPERATION_PATH_UNRESOLVABLE') {
+						throw new exports.JsonPatchError('Cannot perform the operation from a path that does not exist', 'OPERATION_FROM_UNRESOLVABLE', index, operation, document);
+					}
+				}
+			}
+		}
+		exports.validator = validator;
+		/**
+		 * Validates a sequence of operations. If `document` parameter is provided, the sequence is additionally validated against the object document.
+		 * If error is encountered, returns a JsonPatchError object
+		 * @param sequence
+		 * @param document
+		 * @returns {JsonPatchError|undefined}
+		 */
+		function validate(sequence, document, externalValidator) {
+			try {
+				if (!Array.isArray(sequence)) {
+					throw new exports.JsonPatchError('Patch sequence must be an array', 'SEQUENCE_NOT_AN_ARRAY');
+				}
+				if (document) {
+					//clone document and sequence so that we can safely try applying operations
+					applyPatch(helpers_1._deepClone(document), helpers_1._deepClone(sequence), externalValidator || true);
+				}
+				else {
+					externalValidator = externalValidator || validator;
+					for (var i = 0; i < sequence.length; i++) {
+						externalValidator(sequence[i], i, document, undefined);
+					}
+				}
+			}
+			catch (e) {
+				if (e instanceof exports.JsonPatchError) {
+					return e;
+				}
+				else {
+					throw e;
+				}
+			}
+		}
+		exports.validate = validate;
+
+
+		/***/ }),
+	/* 2 */
+	/***/ (function(module, exports, __webpack_require__) {
+
+		/*!
+		 * https://github.com/Starcounter-Jack/JSON-Patch
+		 * (c) 2017 Joachim Wester
+		 * MIT license
+		 */
+		var helpers_1 = __webpack_require__(0);
+		var core_1 = __webpack_require__(1);
+		/* export all core functions */
+		var core_2 = __webpack_require__(1);
+		exports.applyOperation = core_2.applyOperation;
+		exports.applyPatch = core_2.applyPatch;
+		exports.applyReducer = core_2.applyReducer;
+		exports.getValueByPointer = core_2.getValueByPointer;
+		exports.validate = core_2.validate;
+		exports.validator = core_2.validator;
+		/* export some helpers */
+		var helpers_2 = __webpack_require__(0);
+		exports.JsonPatchError = helpers_2.PatchError;
+		exports.deepClone = helpers_2._deepClone;
+		exports.escapePathComponent = helpers_2.escapePathComponent;
+		exports.unescapePathComponent = helpers_2.unescapePathComponent;
+		var beforeDict = new WeakMap();
+		var Mirror = (function () {
+			function Mirror(obj) {
+				this.observers = new Map();
+				this.obj = obj;
+			}
+			return Mirror;
+		}());
+		var ObserverInfo = (function () {
+			function ObserverInfo(callback, observer) {
+				this.callback = callback;
+				this.observer = observer;
+			}
+			return ObserverInfo;
+		}());
+		function getMirror(obj) {
+			return beforeDict.get(obj);
+		}
+		function getObserverFromMirror(mirror, callback) {
+			return mirror.observers.get(callback);
+		}
+		function removeObserverFromMirror(mirror, observer) {
+			mirror.observers.delete(observer.callback);
+		}
+		/**
+		 * Detach an observer from an object
+		 */
+		function unobserve(root, observer) {
+			observer.unobserve();
+		}
+		exports.unobserve = unobserve;
+		/**
+		 * Observes changes made to an object, which can then be retrieved using generate
+		 */
+		function observe(obj, callback) {
+			var patches = [];
+			var observer;
+			var mirror = getMirror(obj);
+			if (!mirror) {
+				mirror = new Mirror(obj);
+				beforeDict.set(obj, mirror);
+			}
+			else {
+				var observerInfo = getObserverFromMirror(mirror, callback);
+				observer = observerInfo && observerInfo.observer;
+			}
+			if (observer) {
+				return observer;
+			}
+			observer = {};
+			mirror.value = helpers_1._deepClone(obj);
+			if (callback) {
+				observer.callback = callback;
+				observer.next = null;
+				var dirtyCheck = function () {
+					generate(observer);
+				};
+				var fastCheck = function () {
+					clearTimeout(observer.next);
+					observer.next = setTimeout(dirtyCheck);
+				};
+				if (typeof window !== 'undefined') {
+					if (window.addEventListener) {
+						window.addEventListener('mouseup', fastCheck);
+						window.addEventListener('keyup', fastCheck);
+						window.addEventListener('mousedown', fastCheck);
+						window.addEventListener('keydown', fastCheck);
+						window.addEventListener('change', fastCheck);
+					}
+					else {
+						document.documentElement.attachEvent('onmouseup', fastCheck);
+						document.documentElement.attachEvent('onkeyup', fastCheck);
+						document.documentElement.attachEvent('onmousedown', fastCheck);
+						document.documentElement.attachEvent('onkeydown', fastCheck);
+						document.documentElement.attachEvent('onchange', fastCheck);
+					}
+				}
+			}
+			observer.patches = patches;
+			observer.object = obj;
+			observer.unobserve = function () {
+				generate(observer);
+				clearTimeout(observer.next);
+				removeObserverFromMirror(mirror, observer);
+				if (typeof window !== 'undefined') {
+					if (window.removeEventListener) {
+						window.removeEventListener('mouseup', fastCheck);
+						window.removeEventListener('keyup', fastCheck);
+						window.removeEventListener('mousedown', fastCheck);
+						window.removeEventListener('keydown', fastCheck);
+					}
+					else {
+						document.documentElement.detachEvent('onmouseup', fastCheck);
+						document.documentElement.detachEvent('onkeyup', fastCheck);
+						document.documentElement.detachEvent('onmousedown', fastCheck);
+						document.documentElement.detachEvent('onkeydown', fastCheck);
+					}
+				}
+			};
+			mirror.observers.set(callback, new ObserverInfo(callback, observer));
+			return observer;
+		}
+		exports.observe = observe;
+		/**
+		 * Generate an array of patches from an observer
+		 */
+		function generate(observer) {
+			var mirror = beforeDict.get(observer.object);
+			_generate(mirror.value, observer.object, observer.patches, "");
+			if (observer.patches.length) {
+				core_1.applyPatch(mirror.value, observer.patches);
+			}
+			var temp = observer.patches;
+			if (temp.length > 0) {
+				observer.patches = [];
+				if (observer.callback) {
+					observer.callback(temp);
+				}
+			}
+			return temp;
+		}
+		exports.generate = generate;
+// Dirty check if obj is different from mirror, generate patches and update mirror
+		function _generate(mirror, obj, patches, path) {
+			if (obj === mirror) {
+				return;
+			}
+			if (typeof obj.toJSON === "function") {
+				obj = obj.toJSON();
+			}
+			var newKeys = helpers_1._objectKeys(obj);
+			var oldKeys = helpers_1._objectKeys(mirror);
+			var changed = false;
+			var deleted = false;
+			//if ever "move" operation is implemented here, make sure this test runs OK: "should not generate the same patch twice (move)"
+			for (var t = oldKeys.length - 1; t >= 0; t--) {
+				var key = oldKeys[t];
+				var oldVal = mirror[key];
+				if (helpers_1.hasOwnProperty(obj, key) && !(obj[key] === undefined && oldVal !== undefined && Array.isArray(obj) === false)) {
+					var newVal = obj[key];
+					if (typeof oldVal == "object" && oldVal != null && typeof newVal == "object" && newVal != null) {
+						_generate(oldVal, newVal, patches, path + "/" + helpers_1.escapePathComponent(key));
+					}
+					else {
+						if (oldVal !== newVal) {
+							changed = true;
+							patches.push({ op: "replace", path: path + "/" + helpers_1.escapePathComponent(key), value: helpers_1._deepClone(newVal), oldValue: helpers_1._deepClone(oldVal) });
+						}
+					}
+				}
+				else if (Array.isArray(mirror) === Array.isArray(obj)) {
+					patches.push({ op: "remove", path: path + "/" + helpers_1.escapePathComponent(key), value: undefined, oldValue: helpers_1._deepClone(oldVal) });
+					deleted = true; // property has been deleted
+				}
+				else {
+					patches.push({ op: "replace", path: path, value: obj, oldValue: undefined });
+					changed = true;
+				}
+			}
+			if (!deleted && newKeys.length == oldKeys.length) {
+				return;
+			}
+			for (var t = 0; t < newKeys.length; t++) {
+				var key = newKeys[t];
+				if (!helpers_1.hasOwnProperty(mirror, key) && obj[key] !== undefined) {
+					patches.push({ op: "add", path: path + "/" + helpers_1.escapePathComponent(key), value: helpers_1._deepClone(obj[key]), oldValue: undefined });
+				}
+			}
+		}
+		/**
+		 * Create an array of patches from the differences in two objects
+		 */
+		function compare(tree1, tree2) {
+			var patches = [];
+			_generate(tree1, tree2, patches, '');
+			return patches;
+		}
+		exports.compare = compare;
+
+
+		/***/ }),
+	/* 3 */
+	/***/ (function(module, exports, __webpack_require__) {
+
+		var pSlice = Array.prototype.slice;
+		var objectKeys = __webpack_require__(5);
+		var isArguments = __webpack_require__(4);
+
+		var deepEqual = module.exports = function (actual, expected, opts) {
+			if (!opts) opts = {};
+			// 7.1. All identical values are equivalent, as determined by ===.
+			if (actual === expected) {
+				return true;
+
+			} else if (actual instanceof Date && expected instanceof Date) {
+				return actual.getTime() === expected.getTime();
+
+				// 7.3. Other pairs that do not both pass typeof value == 'object',
+				// equivalence is determined by ==.
+			} else if (!actual || !expected || typeof actual != 'object' && typeof expected != 'object') {
+				return opts.strict ? actual === expected : actual == expected;
+
+				// 7.4. For all other Object pairs, including Array objects, equivalence is
+				// determined by having the same number of owned properties (as verified
+				// with Object.prototype.hasOwnProperty.call), the same set of keys
+				// (although not necessarily the same order), equivalent values for every
+				// corresponding key, and an identical 'prototype' property. Note: this
+				// accounts for both named and indexed properties on Arrays.
+			} else {
+				return objEquiv(actual, expected, opts);
+			}
+		}
+
+		function isUndefinedOrNull(value) {
+			return value === null || value === undefined;
+		}
+
+		function isBuffer (x) {
+			if (!x || typeof x !== 'object' || typeof x.length !== 'number') return false;
+			if (typeof x.copy !== 'function' || typeof x.slice !== 'function') {
+				return false;
+			}
+			if (x.length > 0 && typeof x[0] !== 'number') return false;
+			return true;
+		}
+
+		function objEquiv(a, b, opts) {
+			var i, key;
+			if (isUndefinedOrNull(a) || isUndefinedOrNull(b))
+				return false;
+			// an identical 'prototype' property.
+			if (a.prototype !== b.prototype) return false;
+			//~~~I've managed to break Object.keys through screwy arguments passing.
+			//   Converting to array solves the problem.
+			if (isArguments(a)) {
+				if (!isArguments(b)) {
+					return false;
+				}
+				a = pSlice.call(a);
+				b = pSlice.call(b);
+				return deepEqual(a, b, opts);
+			}
+			if (isBuffer(a)) {
+				if (!isBuffer(b)) {
+					return false;
+				}
+				if (a.length !== b.length) return false;
+				for (i = 0; i < a.length; i++) {
+					if (a[i] !== b[i]) return false;
+				}
+				return true;
+			}
+			try {
+				var ka = objectKeys(a),
+					kb = objectKeys(b);
+			} catch (e) {//happens when one is a string literal and the other isn't
+				return false;
+			}
+			// having the same number of owned properties (keys incorporates
+			// hasOwnProperty)
+			if (ka.length != kb.length)
+				return false;
+			//the same set of keys (although not necessarily the same order),
+			ka.sort();
+			kb.sort();
+			//~~~cheap key test
+			for (i = ka.length - 1; i >= 0; i--) {
+				if (ka[i] != kb[i])
+					return false;
+			}
+			//equivalent values for every corresponding key, and
+			//~~~possibly expensive deep test
+			for (i = ka.length - 1; i >= 0; i--) {
+				key = ka[i];
+				if (!deepEqual(a[key], b[key], opts)) return false;
+			}
+			return typeof a === typeof b;
+		}
+
+
+		/***/ }),
+	/* 4 */
+	/***/ (function(module, exports) {
+
+		var supportsArgumentsClass = (function(){
+			return Object.prototype.toString.call(arguments)
+		})() == '[object Arguments]';
+
+		exports = module.exports = supportsArgumentsClass ? supported : unsupported;
+
+		exports.supported = supported;
+		function supported(object) {
+			return Object.prototype.toString.call(object) == '[object Arguments]';
+		};
+
+		exports.unsupported = unsupported;
+		function unsupported(object){
+			return object &&
+				typeof object == 'object' &&
+				typeof object.length == 'number' &&
+				Object.prototype.hasOwnProperty.call(object, 'callee') &&
+				!Object.prototype.propertyIsEnumerable.call(object, 'callee') ||
+				false;
+		};
+
+
+		/***/ }),
+	/* 5 */
+	/***/ (function(module, exports) {
+
+		exports = module.exports = typeof Object.keys === 'function'
+			? Object.keys : shim;
+
+		exports.shim = shim;
+		function shim (obj) {
+			var keys = [];
+			for (var key in obj) keys.push(key);
+			return keys;
+		}
+
+
+		/***/ })
+	/******/ ]);
diff --git a/traffic_portal/app/src/assets/js/jsonformatter.min_0.6.0.js b/traffic_portal/app/src/assets/js/jsonformatter.min_0.6.0.js
new file mode 100644
index 0000000..3fbbaf4
--- /dev/null
+++ b/traffic_portal/app/src/assets/js/jsonformatter.min_0.6.0.js
@@ -0,0 +1,7 @@
+/*!
+ * jsonformatter
+ *
+ * Version: 0.6.0 - 2016-08-27T12:58:03.306Z
+ * License: Apache-2.0
+ */
+"use strict";angular.module("jsonFormatter",["RecursionHelper"]).provider("JSONFormatterConfig",function(){var n=!1,e=100,t=5;return{get hoverPreviewEnabled(){return n},set hoverPreviewEnabled(e){n=!!e},get hoverPreviewArrayCount(){return e},set hoverPreviewArrayCount(n){e=parseInt(n,10)},get hoverPreviewFieldCount(){return t},set hoverPreviewFieldCount(n){t=parseInt(n,10)},$get:function(){return{hoverPreviewEnabled:n,hoverPreviewArrayCount:e,hoverPreviewFieldCount:t}}}}).directive("json [...]
diff --git a/traffic_portal/app/src/index.html b/traffic_portal/app/src/index.html
index e5dc6be..04c747d 100644
--- a/traffic_portal/app/src/index.html
+++ b/traffic_portal/app/src/index.html
@@ -36,6 +36,7 @@ under the License.
         <link rel="stylesheet" media="all" href="resources/styles/main.css">
         <link rel="stylesheet" media="all" href="resources/assets/css/custom.css">
 
+        <link rel="stylesheet" media="all" href="resources/assets/css/jsonformatter.min_0.6.0.css">
         <link rel="stylesheet" media="all" href="resources/assets/css/jquery.dataTables.min_1.10.9.css">
         <link rel="stylesheet" media="all" href="resources/assets/css/angular-moment-picker_0.10.2.css">
 
@@ -49,6 +50,8 @@ under the License.
         <script src="resources/assets/js/app.js"></script>
         <script src="resources/assets/js/config.js"></script>
 
+        <script src="resources/assets/js/jsonformatter.min_0.6.0.js"></script>
+        <script src="resources/assets/js/fast-json-patch_v2.1.0.js"></script>
         <script src="resources/assets/js/downloadjs-min_v4.21.js"></script>
         <script src="resources/assets/js/jsdiff-min_3.5.0.js"></script>
         <script src="resources/assets/js/moment-min_2.22.1.js"></script>
diff --git a/traffic_portal/app/src/modules/private/cdns/config/ConfigController.js b/traffic_portal/app/src/modules/private/cdns/config/ConfigController.js
index 1d8bb11..8483a36 100644
--- a/traffic_portal/app/src/modules/private/cdns/config/ConfigController.js
+++ b/traffic_portal/app/src/modules/private/cdns/config/ConfigController.js
@@ -17,185 +17,128 @@
  * under the License.
  */
 
-var ConfigController = function(cdn, currentSnapshot, newSnapshot, $scope, $state, $timeout, $uibModal, locationUtils, cdnService, propertiesModel) {
+let ConfigController = function (cdn, currentSnapshot, newSnapshot, $scope, $state, $uibModal, locationUtils, cdnService, propertiesModel) {
 
-	$scope.cdn = cdn;
-
-	var diffSettings = propertiesModel.properties.snapshot.diff;
-
-	var oldConfig = currentSnapshot.config,
+	const oldConfig = currentSnapshot.config,
 		newConfig = newSnapshot.config;
 
-	var oldTrafficRouters = currentSnapshot.contentRouters,
+	const oldTrafficRouters = currentSnapshot.contentRouters,
 		newTrafficRouters = newSnapshot.contentRouters;
 
-	var oldTrafficMonitors = currentSnapshot.monitors,
+	const oldTrafficMonitors = currentSnapshot.monitors,
 		newTrafficMonitors = newSnapshot.monitors;
 
-	var oldTrafficServers = currentSnapshot.contentServers,
+	const oldTrafficServers = currentSnapshot.contentServers,
 		newTrafficServers = newSnapshot.contentServers;
 
-	var oldDeliveryServices = currentSnapshot.deliveryServices,
+	const oldDeliveryServices = currentSnapshot.deliveryServices,
 		newDeliveryServices = newSnapshot.deliveryServices;
 
-	var oldEdgeCacheGroups = currentSnapshot.edgeLocations,
+	const oldEdgeCacheGroups = currentSnapshot.edgeLocations,
 		newEdgeCacheGroups = newSnapshot.edgeLocations;
 
-	var oldStats = currentSnapshot.stats,
+	const oldTrafficRouterCacheGroups = currentSnapshot.trafficRouterLocations,
+		newTrafficRouterCacheGroups = newSnapshot.trafficRouterLocations;
+
+	const oldStats = currentSnapshot.stats,
 		newStats = newSnapshot.stats;
 
-	var performDiff = function(oldJSON, newJSON, destination) {
-		var div = null,
-			added = 0,
+	let performDiff = function (oldJSON, newJSON, destination) {
+		let added = 0,
 			removed = 0,
-			context = diffSettings.context; // only show X lines of context (around added or removed parts) as defined in traffic_portal_properties.json;
-
-		var display = document.getElementById(destination),
-			fragment = document.createDocumentFragment();
-
-		if (oldJSON) {
-			var diff = JsDiff.diffJson(oldJSON, newJSON);
-			diff.forEach(function(part){
-				var partChanged = part.added || part.removed;
-				if (!partChanged && part.count > (context * 2)) {
-					var partArr = part.value.split("\n"),
-						newArr = [];
-					_.each(partArr, function(element, index) {
-						if (index < context || index > partArr.length - context) {
-							newArr.push(element);
-						}
-					});
-					newArr.splice(context, 0, "\n*****************\n*   TRUNCATED   *\n*****************\n");
-					part.value = newArr.join("\n");
-				}
-				if (part.added) {
-					added++;
-				} else if (part.removed) {
-					removed++;
-				}
-				div = document.createElement('div');
-				div.className = part.added ? 'added' : part.removed ? 'removed' : 'no-change';
-
-				if (partChanged) {
-					var prepend = part.added ? '++' : '--';
-					part.value = prepend + part.value.slice(2);
-				}
-
-				div.appendChild(document.createTextNode(part.value));
-				fragment.appendChild(div);
-			});
-
-			$scope[destination + "Count"].added = added;
-			$scope[destination + "Count"].removed = removed;
-			display.innerHTML = '';
-			display.appendChild(fragment);
-		} else {
-			display.innerHTML = 'Diff failed. You may need to perform your first snapshot.';
-		}
+			updated = 0;
+
+		let oldConfig = oldJSON || {},
+			newConfig = newJSON || {};
+
+		let diff = jsonpatch.compare(oldConfig, newConfig);
+		diff.forEach(function (change) {
+			if (change.op == 'add') {
+				added++;
+			} else if (change.op == 'remove') {
+				removed++;
+			} else if (change.op == 'replace') {
+				change.op = 'update'; // changing the name to 'update'
+				updated++;
+			}
+		});
 
+		$scope[destination + "Count"].added = added;
+		$scope[destination + "Count"].removed = removed;
+		$scope[destination + "Count"].updated = updated;
+		$scope[destination + "Changes"] = diff;
 	};
 
-	var snapshot = function() {
+	let snapshot = function () {
 		cdnService.snapshot(cdn);
 	};
 
+	$scope.cdn = cdn;
+
+	$scope.expandLevel = propertiesModel.properties.snapshot.diff.expandLevel;
+
 	$scope.configCount = {
 		added: 0,
 		removed: 0,
+		updated: 0,
 		templateUrl: 'configPopoverTemplate.html'
 	};
 
 	$scope.contentRoutersCount = {
 		added: 0,
 		removed: 0,
+		updated: 0,
 		templateUrl: 'crPopoverTemplate.html'
 	};
 
 	$scope.monitorsCount = {
 		added: 0,
 		removed: 0,
+		updated: 0,
 		templateUrl: 'mPopoverTemplate.html'
 	};
 
 	$scope.contentServersCount = {
 		added: 0,
 		removed: 0,
+		updated: 0,
 		templateUrl: 'csPopoverTemplate.html'
 	};
 
 	$scope.deliveryServicesCount = {
 		added: 0,
 		removed: 0,
+		updated: 0,
 		templateUrl: 'dsPopoverTemplate.html'
 	};
 
 	$scope.edgeLocationsCount = {
 		added: 0,
 		removed: 0,
+		updated: 0,
 		templateUrl: 'elPopoverTemplate.html'
 	};
 
-	$scope.statsCount = {
+	$scope.trLocationsCount = {
 		added: 0,
 		removed: 0,
-		templateUrl: 'statsPopoverTemplate.html'
-	};
-
-	$scope.diffConfig = function(timeout) {
-		$('#config').html('<i class="fa fa-refresh fa-spin fa-1x fa-fw"></i> Generating diff...');
-		$timeout(function() {
-			performDiff(oldConfig, newConfig, 'config');
-		}, timeout);
+		updated: 0,
+		templateUrl: 'tlPopoverTemplate.html'
 	};
 
-	$scope.diffContentRouters = function(timeout) {
-		$('#contentRouters').html('<i class="fa fa-refresh fa-spin fa-1x fa-fw"></i> Generating diff...');
-		$timeout(function() {
-			performDiff(oldTrafficRouters, newTrafficRouters, 'contentRouters');
-		}, timeout);
-	};
-
-	$scope.diffMonitors = function(timeout) {
-		$('#monitors').html('<i class="fa fa-refresh fa-spin fa-1x fa-fw"></i> Generating diff...');
-		$timeout(function() {
-			performDiff(oldTrafficMonitors, newTrafficMonitors, 'monitors');
-		}, timeout);
-	};
-
-	$scope.diffContentServers = function(timeout) {
-		$('#contentServers').html('<i class="fa fa-refresh fa-spin fa-1x fa-fw"></i> Generating diff...');
-		$timeout(function() {
-			performDiff(oldTrafficServers, newTrafficServers, 'contentServers');
-		}, timeout);
-	};
-
-	$scope.diffDeliveryServices = function(timeout) {
-		$('#deliveryServices').html('<i class="fa fa-refresh fa-spin fa-1x fa-fw"></i> Generating diff...');
-		$timeout(function() {
-			performDiff(oldDeliveryServices, newDeliveryServices, 'deliveryServices');
-		}, timeout);
-	};
-
-	$scope.diffEdgeLocations = function(timeout) {
-		$('#edgeLocations').html('<i class="fa fa-refresh fa-spin fa-1x fa-fw"></i> Generating diff...');
-		$timeout(function() {
-			performDiff(oldEdgeCacheGroups, newEdgeCacheGroups, 'edgeLocations');
-		}, timeout);
-	};
-
-	$scope.diffStats = function(timeout) {
-		$('#stats').html('<i class="fa fa-refresh fa-spin fa-1x fa-fw"></i> Generating diff...');
-		$timeout(function() {
-			performDiff(oldStats, newStats, 'stats');
-		}, timeout);
+	$scope.statsCount = {
+		added: 0,
+		removed: 0,
+		updated: 0,
+		templateUrl: 'statsPopoverTemplate.html'
 	};
 
-	$scope.confirmSnapshot = function(cdn) {
-		var params = {
+	$scope.confirmSnapshot = function (cdn) {
+		let params = {
 			title: 'Perform Snapshot',
 			message: 'Are you sure you want to snapshot the ' + cdn.name + ' config?'
 		};
-		var modalInstance = $uibModal.open({
+		let modalInstance = $uibModal.open({
 			templateUrl: 'common/modules/dialog/confirm/dialog.confirm.tpl.html',
 			controller: 'DialogConfirmController',
 			size: 'sm',
@@ -205,7 +148,7 @@ var ConfigController = function(cdn, currentSnapshot, newSnapshot, $scope, $stat
 				}
 			}
 		});
-		modalInstance.result.then(function() {
+		modalInstance.result.then(function () {
 			snapshot();
 		}, function () {
 			// do nothing
@@ -215,16 +158,34 @@ var ConfigController = function(cdn, currentSnapshot, newSnapshot, $scope, $stat
 	$scope.navigateToPath = locationUtils.navigateToPath;
 
 	angular.element(document).ready(function () {
-		$scope.diffConfig(0);
-		$scope.diffContentRouters(0);
-		$scope.diffMonitors(0);
-		$scope.diffContentServers(0);
-		$scope.diffDeliveryServices(0);
-		$scope.diffEdgeLocations(0);
-		$scope.diffStats(0);
+
+		$('table.changes').dataTable({
+			"lengthMenu": [[25, 50, 100, -1], [25, 50, 100, "All"]],
+			"pageLength": 25,
+			"order": [[0, "asc"]],
+			"language": {
+				"emptyTable": "No pending changes"
+			},
+			"columnDefs": [
+				{ 'orderable': false, 'targets': [2, 3] }
+			]
+		});
+
 	});
 
+	let init = function () {
+		performDiff(oldConfig, newConfig, 'config');
+		performDiff(oldTrafficRouters, newTrafficRouters, 'contentRouters');
+		performDiff(oldTrafficMonitors, newTrafficMonitors, 'monitors');
+		performDiff(oldTrafficServers, newTrafficServers, 'contentServers');
+		performDiff(oldDeliveryServices, newDeliveryServices, 'deliveryServices');
+		performDiff(oldEdgeCacheGroups, newEdgeCacheGroups, 'edgeLocations');
+		performDiff(oldTrafficRouterCacheGroups, newTrafficRouterCacheGroups, 'trLocations');
+		performDiff(oldStats, newStats, 'stats');
+	};
+	init();
+
 };
 
-ConfigController.$inject = ['cdn', 'currentSnapshot', 'newSnapshot', '$scope', '$state', '$timeout', '$uibModal', 'locationUtils', 'cdnService', 'propertiesModel'];
+ConfigController.$inject = ['cdn', 'currentSnapshot', 'newSnapshot', '$scope', '$state', '$uibModal', 'locationUtils', 'cdnService', 'propertiesModel'];
 module.exports = ConfigController;
diff --git a/traffic_portal/app/src/modules/private/cdns/config/_config.scss b/traffic_portal/app/src/modules/private/cdns/config/_config.scss
index e75548e..e9f01ea 100644
--- a/traffic_portal/app/src/modules/private/cdns/config/_config.scss
+++ b/traffic_portal/app/src/modules/private/cdns/config/_config.scss
@@ -46,5 +46,12 @@
   .no-change {
     color: grey;
   }
+
+  table.changes {
+    .value {
+      width: 30%;
+    }
+  }
+
 }
 
diff --git a/traffic_portal/app/src/modules/private/cdns/config/config.tpl.html b/traffic_portal/app/src/modules/private/cdns/config/config.tpl.html
index 5145991..3b1ecda 100644
--- a/traffic_portal/app/src/modules/private/cdns/config/config.tpl.html
+++ b/traffic_portal/app/src/modules/private/cdns/config/config.tpl.html
@@ -26,98 +26,272 @@ under the License.
         </ol>
         <div class="pull-right" ng-show="!settings.isNew">
             <button type="button" class="btn btn-default" ng-click="navigateToPath('/cdns/' + cdn.id)">Cancel</button>
-            <button class="btn btn-primary" title="Snapshot {{cdn.name}} Config" ng-click="confirmSnapshot(cdn)"><i class="fa fa-camera"></i>&nbsp;&nbsp;Perform Snapshot</button>
+            <button type="button" class="btn btn-primary" title="Perform {{cdn.name}} Snapshot" ng-click="confirmSnapshot(cdn)"><i class="fa fa-camera"></i>&nbsp;&nbsp;Perform Snapshot</button>
         </div>
-
         <div class="clearfix"></div>
     </div>
     <div id="snapshotContainer" class="x_content">
         <uib-tabset active="active" justified="true">
-            <uib-tab index="0" class="tab" ng-click="diffConfig(500)">
-                <uib-tab-heading uib-popover-template="configCount.templateUrl" popover-title="{{configCount.added + configCount.removed}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
-                    General [ {{configCount.added}} | {{configCount.removed}} ]
+            <uib-tab index="0" class="tab">
+                <uib-tab-heading uib-popover-template="configCount.templateUrl" popover-title="{{configCount.added + configCount.removed + configCount.updated}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
+                    General Config<br/>[ {{configCount.added}} | {{configCount.removed}} | {{configCount.updated}} ]
+                </uib-tab-heading>
+                <div class="x_content">
+                    <br>
+                    <table id="configChangesTable" class="table responsive-utilities jambo_table changes">
+                        <thead>
+                        <tr class="headings">
+                            <th>Change Type</th>
+                            <th>Path</th>
+                            <th>Current Value</th>
+                            <th>Pending Value</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="configChange in ::configChanges track by $index">
+                            <td data-search="^{{::configChange.op}}$">{{::configChange.op}}</td>
+                            <td data-search="^{{::configChange.path}}$">{{::configChange.path}}</td>
+                            <td class="value" data-search="^{{::configChange.oldValue}}$"><json-formatter open="expandLevel" json="configChange.oldValue"></json-formatter></td>
+                            <td class="value" data-search="^{{::configChange.value}}$"><json-formatter open="expandLevel" json="configChange.value"></json-formatter></td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
+            </uib-tab>
+            <uib-tab index="1" class="tab">
+                <uib-tab-heading uib-popover-template="contentRoutersCount.templateUrl" popover-title="{{contentRoutersCount.added + contentRoutersCount.removed + contentRoutersCount.updated}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
+                    Traffic Routers<br/>[ {{contentRoutersCount.added}} | {{contentRoutersCount.removed}} | {{contentRoutersCount.updated}} ]
                 </uib-tab-heading>
-                <pre id="config"></pre>
+                <div class="x_content">
+                    <br>
+                    <table id="contentRoutersChangesTable" class="table responsive-utilities jambo_table changes">
+                        <thead>
+                        <tr class="headings">
+                            <th>Change Type</th>
+                            <th>Path</th>
+                            <th>Current Value</th>
+                            <th>Pending Value</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="cr in ::contentRoutersChanges track by $index">
+                            <td data-search="^{{::cr.op}}$">{{::cr.op}}</td>
+                            <td data-search="^{{::cr.path}}$">{{::cr.path}}</td>
+                            <td class="value" data-search="^{{::cr.oldValue}}$"><json-formatter open="expandLevel" json="cr.oldValue"></json-formatter></td>
+                            <td class="value" data-search="^{{::cr.value}}$"><json-formatter open="expandLevel" json="cr.value"></json-formatter></td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
             </uib-tab>
-            <uib-tab index="1" class="tab" ng-click="diffContentRouters(500)">
-                <uib-tab-heading uib-popover-template="contentRoutersCount.templateUrl" popover-title="{{contentRoutersCount.added + contentRoutersCount.removed}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
-                    Traffic Routers [ {{contentRoutersCount.added}} | {{contentRoutersCount.removed}} ]
+            <uib-tab index="2" class="tab">
+                <uib-tab-heading uib-popover-template="monitorsCount.templateUrl" popover-title="{{monitorsCount.added + monitorsCount.removed + monitorsCount.updated}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
+                    Traffic Monitors<br/>[ {{monitorsCount.added}} | {{monitorsCount.removed}} | {{monitorsCount.updated}} ]
                 </uib-tab-heading>
-                <pre id="contentRouters"></pre>
+                <div class="x_content">
+                    <br>
+                    <table id="monitorsChangesTable" class="table responsive-utilities jambo_table changes">
+                        <thead>
+                        <tr class="headings">
+                            <th>Change Type</th>
+                            <th>Path</th>
+                            <th>Current Value</th>
+                            <th>Pending Value</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="m in ::monitorsChanges track by $index">
+                            <td data-search="^{{::m.op}}$">{{::m.op}}</td>
+                            <td data-search="^{{::m.path}}$">{{::m.path}}</td>
+                            <td class="value" data-search="^{{::m.oldValue}}$"><json-formatter open="expandLevel" json="m.oldValue"></json-formatter></td>
+                            <td class="value" data-search="^{{::m.value}}$"><json-formatter open="expandLevel" json="m.value"></json-formatter></td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
             </uib-tab>
-            <uib-tab index="1" class="tab" ng-click="diffMonitors(500)">
-                <uib-tab-heading uib-popover-template="monitorsCount.templateUrl" popover-title="{{monitorsCount.added + monitorsCount.removed}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
-                    Traffic Monitors [ {{monitorsCount.added}} | {{monitorsCount.removed}} ]
+            <uib-tab index="3" class="tab">
+                <uib-tab-heading uib-popover-template="contentServersCount.templateUrl" popover-title="{{contentServersCount.added + contentServersCount.removed + contentServersCount.updated}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
+                    Traffic Servers<br/>[ {{contentServersCount.added}} | {{contentServersCount.removed}} | {{contentServersCount.updated}} ]
                 </uib-tab-heading>
-                <pre id="monitors"></pre>
+                <div class="x_content">
+                    <br>
+                    <table id="contentServersChangesTable" class="table responsive-utilities jambo_table changes">
+                        <thead>
+                        <tr class="headings">
+                            <th>Change Type</th>
+                            <th>Path</th>
+                            <th>Current Value</th>
+                            <th>Pending Value</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="cs in ::contentServersChanges track by $index">
+                            <td data-search="^{{::cs.op}}$">{{::cs.op}}</td>
+                            <td data-search="^{{::cs.path}}$">{{::cs.path}}</td>
+                            <td class="value" data-search="^{{::cs.oldValue}}$"><json-formatter open="expandLevel" json="cs.oldValue"></json-formatter></td>
+                            <td class="value" data-search="^{{::cs.value}}$"><json-formatter open="expandLevel" json="cs.value"></json-formatter></td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
             </uib-tab>
-            <uib-tab index="2" class="tab" ng-click="diffContentServers(500)">
-                <uib-tab-heading uib-popover-template="contentServersCount.templateUrl" popover-title="{{contentServersCount.added + contentServersCount.removed}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
-                    Traffic Servers [ {{contentServersCount.added}} | {{contentServersCount.removed}} ]
+            <uib-tab index="4" class="tab">
+                <uib-tab-heading uib-popover-template="deliveryServicesCount.templateUrl" popover-title="{{deliveryServicesCount.added + deliveryServicesCount.removed + deliveryServicesCount.updated}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
+                    Delivery Services<br/>[ {{deliveryServicesCount.added}} | {{deliveryServicesCount.removed}} | {{deliveryServicesCount.updated}} ]
                 </uib-tab-heading>
-                <pre id="contentServers"></pre>
+                <div class="x_content">
+                    <br>
+                    <table id="deliveryServicesChangesTable" class="table responsive-utilities jambo_table changes">
+                        <thead>
+                        <tr class="headings">
+                            <th>Change Type</th>
+                            <th>Path</th>
+                            <th>Current Value</th>
+                            <th>Pending Value</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="ds in ::deliveryServicesChanges track by $index">
+                            <td data-search="^{{::ds.op}}$">{{::ds.op}}</td>
+                            <td data-search="^{{::ds.path}}$">{{::ds.path}}</td>
+                            <td class="value" data-search="^{{::ds.oldValue}}$"><json-formatter open="expandLevel" json="ds.oldValue"></json-formatter></td>
+                            <td class="value" data-search="^{{::ds.value}}$"><json-formatter open="expandLevel" json="ds.value"></json-formatter></td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
             </uib-tab>
-            <uib-tab index="3" class="tab" ng-click="diffDeliveryServices(500)">
-                <uib-tab-heading uib-popover-template="deliveryServicesCount.templateUrl" popover-title="{{deliveryServicesCount.added + deliveryServicesCount.removed}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
-                    Delivery Services [ {{deliveryServicesCount.added}} | {{deliveryServicesCount.removed}} ]
+            <uib-tab index="5" class="tab">
+                <uib-tab-heading uib-popover-template="edgeLocationsCount.templateUrl" popover-title="{{edgeLocationsCount.added + edgeLocationsCount.removed + edgeLocationsCount.updated}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
+                    Edge Cache Groups<br/>[ {{edgeLocationsCount.added}} | {{edgeLocationsCount.removed}} | {{edgeLocationsCount.updated}} ]
                 </uib-tab-heading>
-                <pre id="deliveryServices"></pre>
+                <div class="x_content">
+                    <br>
+                    <table id="edgeLocationsChangesTable" class="table responsive-utilities jambo_table changes">
+                        <thead>
+                        <tr class="headings">
+                            <th>Change Type</th>
+                            <th>Path</th>
+                            <th>Current Value</th>
+                            <th>Pending Value</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="el in ::edgeLocationsChanges track by $index">
+                            <td data-search="^{{::el.op}}$">{{::el.op}}</td>
+                            <td data-search="^{{::el.path}}$">{{::el.path}}</td>
+                            <td class="value" data-search="^{{::el.oldValue}}$"><json-formatter open="expandLevel" json="el.oldValue"></json-formatter></td>
+                            <td class="value" data-search="^{{::el.value}}$"><json-formatter open="expandLevel" json="el.value"></json-formatter></td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
             </uib-tab>
-            <uib-tab index="4" class="tab" ng-click="diffEdgeLocations(500)">
-                <uib-tab-heading uib-popover-template="edgeLocationsCount.templateUrl" popover-title="{{edgeLocationsCount.added + edgeLocationsCount.removed}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
-                    Edge Cache Groups [ {{edgeLocationsCount.added}} | {{edgeLocationsCount.removed}} ]
+            <uib-tab index="6" class="tab">
+                <uib-tab-heading uib-popover-template="trLocationsCount.templateUrl" popover-title="{{trLocationsCount.added + trLocationsCount.removed + trLocationsCount.updated}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
+                    TR Cache Groups<br/>[ {{trLocationsCount.added}} | {{trLocationsCount.removed}} | {{trLocationsCount.updated}} ]
                 </uib-tab-heading>
-                <pre id="edgeLocations"></pre>
+                <div class="x_content">
+                    <br>
+                    <table id="trLocationsChangesTable" class="table responsive-utilities jambo_table changes">
+                        <thead>
+                        <tr class="headings">
+                            <th>Change Type</th>
+                            <th>Path</th>
+                            <th>Current Value</th>
+                            <th>Pending Value</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="tl in ::trLocationsChanges track by $index">
+                            <td data-search="^{{::tl.op}}$">{{::tl.op}}</td>
+                            <td data-search="^{{::tl.path}}$">{{::tl.path}}</td>
+                            <td class="value" data-search="^{{::tl.oldValue}}$"><json-formatter open="expandLevel" json="tl.oldValue"></json-formatter></td>
+                            <td class="value" data-search="^{{::tl.value}}$"><json-formatter open="expandLevel" json="tl.value"></json-formatter></td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
             </uib-tab>
-            <uib-tab index="5" class="tab" ng-click="diffStats(500)">
-                <uib-tab-heading uib-popover-template="statsCount.templateUrl" popover-title="{{statsCount.added + statsCount.removed}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
-                    Stats [ {{statsCount.added}} | {{statsCount.removed}} ]
+            <uib-tab index="7" class="tab">
+                <uib-tab-heading uib-popover-template="statsCount.templateUrl" popover-title="{{statsCount.added + statsCount.removed + statsCount.updated}} Total Changes" popover-trigger="mouseenter" popover-placement="top" popover-append-to-body="true">
+                    Stats<br/>[ {{statsCount.added}} | {{statsCount.removed}} | {{statsCount.updated}} ]
                 </uib-tab-heading>
-                <pre id="stats"></pre>
+                <div class="x_content">
+                    <br>
+                    <table id="statsChangesTable" class="table responsive-utilities jambo_table changes">
+                        <thead>
+                        <tr class="headings">
+                            <th>Change Type</th>
+                            <th>Path</th>
+                            <th>Current Value</th>
+                            <th>Pending Value</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="s in ::statsChanges track by $index">
+                            <td data-search="^{{::s.op}}$">{{::s.op}}</td>
+                            <td data-search="^{{::s.path}}$">{{::s.path}}</td>
+                            <td class="value" data-search="^{{::s.oldValue}}$"><json-formatter open="expandLevel" json="s.oldValue"></json-formatter></td>
+                            <td class="value" data-search="^{{::s.value}}$"><json-formatter open="expandLevel" json="s.value"></json-formatter></td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
             </uib-tab>
         </uib-tabset>
-        <div class="modal-footer">
-            <button type="button" class="btn btn-default" ng-click="navigateToPath('/cdns/' + cdn.id)">Cancel</button>
-            <button class="btn btn-primary" title="Snapshot {{cdn.name}} Config" ng-click="confirmSnapshot(cdn)"><i class="fa fa-camera"></i>&nbsp;&nbsp;Perform Snapshot</button>
-        </div>
     </div>
 </div>
 
 <!--- start: templates for popovers --->
 
 <script type="text/ng-template" id="configPopoverTemplate.html">
-    <div>{{configCount.added}} additions (++)</div>
-    <div>{{configCount.removed}} removals (--)</div>
+    <div>{{configCount.added}} adds</div>
+    <div>{{configCount.removed}} removes</div>
+    <div>{{configCount.updated}} updates</div>
 </script>
 
 <script type="text/ng-template" id="crPopoverTemplate.html">
-    <div>{{contentRoutersCount.added}} additions (++)</div>
-    <div>{{contentRoutersCount.removed}} removals (--)</div>
+    <div>{{contentRoutersCount.added}} adds</div>
+    <div>{{contentRoutersCount.removed}} removes</div>
+    <div>{{contentRoutersCount.updated}} updates</div>
 </script>
 
 <script type="text/ng-template" id="mPopoverTemplate.html">
-    <div>{{monitorsCount.added}} additions (++)</div>
-    <div>{{monitorsCount.removed}} removals (--)</div>
+    <div>{{monitorsCount.added}} adds</div>
+    <div>{{monitorsCount.removed}} removes</div>
+    <div>{{monitorsCount.updated}} updates</div>
 </script>
 
 <script type="text/ng-template" id="csPopoverTemplate.html">
-    <div>{{contentServersCount.added}} additions (++)</div>
-    <div>{{contentServersCount.removed}} removals (--)</div>
+    <div>{{contentServersCount.added}} adds</div>
+    <div>{{contentServersCount.removed}} removes</div>
+    <div>{{contentServersCount.updated}} updates</div>
 </script>
 
 <script type="text/ng-template" id="dsPopoverTemplate.html">
-    <div>{{deliveryServicesCount.added}} additions (++)</div>
-    <div>{{deliveryServicesCount.removed}} removals (--)</div>
+    <div>{{deliveryServicesCount.added}} adds</div>
+    <div>{{deliveryServicesCount.removed}} removes</div>
+    <div>{{deliveryServicesCount.updated}} updates</div>
 </script>
 
 <script type="text/ng-template" id="elPopoverTemplate.html">
-    <div>{{edgeLocationsCount.added}} additions (++)</div>
-    <div>{{edgeLocationsCount.removed}} removals (--)</div>
+    <div>{{edgeLocationsCount.added}} adds</div>
+    <div>{{edgeLocationsCount.removed}} removes</div>
+    <div>{{edgeLocationsCount.updated}} updates</div>
+</script>
+
+<script type="text/ng-template" id="tlPopoverTemplate.html">
+    <div>{{trLocationsCount.added}} adds</div>
+    <div>{{trLocationsCount.removed}} removes</div>
+    <div>{{trLocationsCount.updated}} updates</div>
 </script>
 
 <script type="text/ng-template" id="statsPopoverTemplate.html">
-    <div>{{statsCount.added}} additions (++)</div>
-    <div>{{statsCount.removed}} removals (--)</div>
+    <div>{{statsCount.added}} adds</div>
+    <div>{{statsCount.removed}} removes</div>
+    <div>{{statsCount.updated}} updates</div>
 </script>
 
 <!--- end: templates for popovers --->
diff --git a/traffic_portal/app/src/styles/main.scss b/traffic_portal/app/src/styles/main.scss
index 4ae1d0a..1c1b773 100755
--- a/traffic_portal/app/src/styles/main.scss
+++ b/traffic_portal/app/src/styles/main.scss
@@ -179,6 +179,10 @@ table {
 
 }
 
+.btn-group:not(:last-child) {
+  margin-right: 5px;
+}
+
 
 
 
diff --git a/traffic_portal/app/src/traffic_portal_properties.json b/traffic_portal/app/src/traffic_portal_properties.json
index 635d109..a333365 100644
--- a/traffic_portal/app/src/traffic_portal_properties.json
+++ b/traffic_portal/app/src/traffic_portal_properties.json
@@ -38,8 +38,8 @@
     "snapshot": {
       "_comments": "These are configurable properties for a cdn snapshot",
       "diff": {
-        "_comments": "The number of lines of context provided before and after a addition or removal",
-        "context": 1000
+        "_comments": "The snapshot diff may contains nested json objects. You can specify if you want these objects expanded, and if so, how many levels. 0 = not expanded, n = expanded to n levels. Set this to a high number (i.e. 100) to ensure full expansion.",
+        "expandLevel": 0
       }
     },
     "cacheChecks": {