You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by ma...@apache.org on 2020/09/11 14:08:15 UTC

[trafficcontrol] branch master updated: Multi interface health (#4916)

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

mattjackson 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 2609e6f  Multi interface health (#4916)
2609e6f is described below

commit 2609e6fc59d98ae1985f0424f28956c6970b585d
Author: ocket8888 <oc...@apache.org>
AuthorDate: Fri Sep 11 08:08:00 2020 -0600

    Multi interface health (#4916)
    
    * updated TM to aggregate health data and provide health data per interface
    
    * update for LegacyTrafficMonitorConfigMap
    
    * Removed 'aggregate' parameter from documentation
    
    * Remove 'aggregate' parameter from monitoring structure
    
    * Simplified stat compute function parameters
    
    * Added a bunch of godocs
    
    * Broke tuple handler construction out of massive function
    
    * Simplified definition of function type aliases, added godocs
    
    * Added safe access to the interface map
    
    * Added method to get default addresses from a set of server interface info
    
    * Consolidated interface info types, added convenience methods for getting IP addresses
    
    Also added example tests for the methods
    
    * Changed compute functions to use new structures
    
    * Removed commented-out code
    
    * Added aggregate thresholds to supported TM parameters
    
    * Moved imports under license
    
    * Added String method for Health Thresholds
    
    * Removed unused constant
    
    * Better error reporting for unmarshaling TMParameters
    
    * Fixed TMParameter unmarshaling, added test
    
    * Removed commented-out code
    
    * Changed Legacy structures to current structures in several places
    
    * Removed inaccurate comment
    
    * Forced long GoDoc comments to wrap lines
    
    * Changed legacy structure to newer one.
    
    * GoDoc fixups
    
    * Fixed some more GoDocs
    
    * Add convenience method for comparing interface stats (and test)
    
    * Add proper storage of interface stats alongside cache stats
    
    Also allows for comparison of integral values (int64 and uint64).
    
    * Separated cache stats from interface stats in API data
    
    * Change type alias to real type
    
    * Wrapped overly long comment
    
    * Fix precision overflow problems with calculating stat history violations
    
    * Updated CacheStats marshal to include interface stats
    
    The new API returns "stats" containing generic statistics, and "interfaces" containing
    a map of interface names to interface-specific statistics.
    
    * Fix thread-safe tests
    
    * Removed commented-out code
    
    * Switched from pure-interface-based availability to calculated aggregate
    
    * Updated availability calculation to aggregate interface availability
    
    New calculation is like:
    available = cache_available && all(cache_interfaces, x=>x.available)
    
    * Fixed cache package tests
    
    * wrap long comments
    
    * Fixed incorrect GoDocs
    
    * Switch kbps measurements to be stored on a per-interface basis.
    
    * Fix bandwidth capacity calculation
    
    * Fix bandwidth capacity JSON encoding
    
    * Remove unnecessary parameter names
    
    * Implement creating cache statuses with interface data
    
    * Wrap long comment
    
    * Fix CacheStatFilter not implementing cache.Filter
    
    * Implement handling of 'interfaceStats' query parameter to filter interface stats
    
    * Added interface data to stats summary.
    
    * Fix threadsafe tests
    
    * Move TrafficMonitorConfigMap validity check to method of struct
    
    * Switched from legacy structures to new structures
    
    * Switch to using most recent Traffic Ops API client version
    
    * Revert storing interface maxKbps, as that doesn't make sense
    
    A server's maxKbps is currently calculated through astats as the maximum
    of its interfaces' kbpses.
    
    * Fix polling url string creation to use new interface data
    
    Right now it only uses the service addresses. It also only uses the name
    of the interface containing those service addresses as a replacement for
    `${interface_name}`.
    
    * Switch to using the updated traffic ops client
    
    * Removed the concept of aggregate thresholds
    
    interfaces define their own thresholds, others have been redefined or
    untouched as appropriate.
    
    * Fix segfault in availability calculation
    
    * Fix availability calculation not setting cache server status
    
    * Fix tests using aggregate thresholds
    
    * Fix tests using legacy data structures
    
    * Fix broken availability tests
    
    * Fix/Add test for interface availability evaluation
    
    * Fix example using aggregate thresholds
    
    * Fixed incorrect/broken calculation of DS capacity
    
    * Fix segfault in config-to-map conversion
    
    * Add (small) test for config-to-map conversion
    
    * Override styling with !important when necessary
    
    There's already a PR out to update the TM UI for multiple interfaces,
    which will supersede this change. I'm only doing this to make it easy
    for testers to see the change in a cache server's availability, in case
    that's not merged by the time the PR containing this commit is tested.
    
    * Fix Traffic Monitor ignoring/overwriting the TrafficServers in its config
    
    * Fix misguided attempt to ensure valid TM params, update corresponding test
    
    * Add some GoDocs, reorganize a little
    
    * Fix shadowing built-in functions
    
    * Add method to CRConfigHistoryThreadsafe to get its length.
    
    * More reorganization and GoDocs
    
    * Wrap long lines
    
    * Fix now-inaccurate GoDoc
    
    * Add method for telling if a wrapped TO session is initialized
    
    * Get rid of interface that's only implemented by one structure
    
    * Move responsibility for logging in to Traffic Ops inside wrapped client
    
    * Fix potential segfault, add GoDoc
    
    * Add GoDoc
    
    * Add method for getting unwrapped pointer to legacy client instance
    
    * Update GetCRConfigRaw to use legacy client when necessary
    
    * Add method to upgrade legacy TrafficServers to current version structure
    
    * Add method for upgrading legacy TrafficMonitorConfigMap structures
    
    * Update trafficMonitorConfigMapRaw to use legacy client when necessary
    
    * Add method for converting tc.Server to equivalent tc.ServerNullableV2
    
    * Add method for upgrading tc.ServerNullableV2 to tc.ServerNullable
    
    * Move logic for getting TM CDN Name into wrapped client
    
    Which also makes it fall back to APIv2 when necessary.
    
    * Fix Initialized check not checking if legacy session should be used
    
    * Fixed update method having incorrect (non-pointer) receiver
    
    * Fix unmonitored interfaces causing cache servers to be marked unavailable
    
    * Add testcaches binary to gitignore
    
    * Add tests for interfaces-to-legacy-conversion function
    
    * Add LegacyInterfaceDetails.String
    
    * Fix bug converting interfaces to LID with multiple interfaces
    
    When a set of interface info had only one of IPv4 or IPv6, and
    non-service interfaces appeared after the service interface in the
    iteration, the value stored at the interface name pointer would be
    changed because the iteration variable is referential; therefore
    although IP information was calculated correctly, the wrong
    InterfaceName would be set.
    
    This commit not only fixes that, but also actually speeds up the
    conversion process in such cases.
    
    * Fix server test in Snapshot tests using mock servers with no IP addresses
    
    * Remove non-existent parameters from monitoring.config endpoint docs
    
    * Go fmt
    
    * Add missing license header
    
    * Fixed 'GoDocs'
    
    * Remove unnecessary comma
    
    * Remove irrelevant comments
    
    * Extracted common testing for nil values in ServerNullableV2 structures
    
    * Fix not setting IPv6/IPv4 availability based on previous results
    
    * updated so availability is false if no IPvX address and error reaching cache is included in status
    
    * Pinned TM's TO client to v3 instead of unversioned package
    
    * Add panic guards in stats_over_https precomputation
    
    * Fix interface bandwidth calculation
    
    * Fix bad rebase compilation issues
    
    * Fixed offline reasons not appearing in cache status lines
    
    * Fix cache status for unused protocol calculated incorrectly
    
    * Fix rebase problems
    
    * Change struct name 'StatsCache' -> 'ServerStats'
    
    * Added note about upgrade requirements in CHANGELOG
    
    Co-authored-by: mattjackson220 <ma...@comcast.com>
---
 CHANGELOG.md                                       |    1 +
 .../source/api/v3/cdns_name_configs_monitoring.rst |    2 +-
 docs/source/overview/profiles_and_parameters.rst   |    2 +-
 lib/go-tc/enum.go                                  |    3 -
 lib/go-tc/servers.go                               |  210 +++-
 lib/go-tc/servers_test.go                          | 1313 ++++++++++++++++++++
 lib/go-tc/traffic_monitor.go                       |  312 +++--
 lib/go-tc/traffic_monitor_test.go                  |  447 ++++++-
 lib/go-tc/traffic_router.go                        |  169 ++-
 lib/go-tc/traffic_router_test.go                   |  137 ++
 traffic_monitor/cache/astats.go                    |    1 -
 traffic_monitor/cache/cache.go                     |  136 +-
 traffic_monitor/cache/cache_test.go                |   48 +-
 traffic_monitor/cache/data.go                      |   61 +-
 traffic_monitor/cache/data_test.go                 |   24 +-
 traffic_monitor/cache/stats_over_http.go           |    5 +-
 traffic_monitor/datareq/bandwidthcapacity.go       |   11 +-
 traffic_monitor/datareq/cachedowncount.go          |    2 +-
 traffic_monitor/datareq/cachestate.go              |  236 ++--
 traffic_monitor/datareq/cachestatfilter.go         |   60 +-
 traffic_monitor/datareq/crconfig.go                |    4 +-
 traffic_monitor/datareq/crconfighist.go            |    2 +-
 traffic_monitor/datareq/datareq.go                 |    2 +-
 traffic_monitor/datareq/statsummary.go             |  152 ++-
 traffic_monitor/ds/stat.go                         |   28 +-
 traffic_monitor/ds/stat_test.go                    |    6 +-
 traffic_monitor/health/cache.go                    |  337 ++---
 traffic_monitor/health/cache_test.go               |  159 ++-
 traffic_monitor/manager/manager.go                 |    4 +-
 traffic_monitor/manager/monitorconfig.go           |   47 +-
 traffic_monitor/manager/monitorconfig_test.go      |  203 ++-
 traffic_monitor/manager/opsconfig.go               |   48 +-
 traffic_monitor/manager/stat.go                    |    4 +-
 traffic_monitor/poller/monitorconfig.go            |   10 +-
 traffic_monitor/static/style.css                   |    4 +-
 traffic_monitor/threadsafe/cacheavailablestatus.go |   15 +-
 traffic_monitor/threadsafe/cachemaxkbpses.go       |   17 +-
 traffic_monitor/threadsafe/monitorconfig.go        |    8 +-
 traffic_monitor/threadsafe/resultstathistory.go    |  399 ++++--
 .../threadsafe/resultstathistory_test.go           |  111 +-
 traffic_monitor/todata/todata.go                   |    4 +-
 traffic_monitor/tools/testcaches/.gitignore        |   18 +
 traffic_monitor/towrap/towrap.go                   |  525 +++++---
 .../traffic_ops_golang/crconfig/servers_test.go    |    4 +-
 .../traffic_ops_golang/deliveryservice/capacity.go |   33 +-
 45 files changed, 4254 insertions(+), 1070 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3fb81e..6d28ac1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - Added the `Status Last Updated` field to servers, and the UI, so that we can see when the last status change took place for a server.
 - Added ability to set DNS Listening IPs in dns.properties
 - Added Traffic Monitor: Support astats CSV output. Includes http_polling_format configuration option to specify the Accept header sent to stats endpoints. Adds CSV parsing ability (~100% faster than JSON) to the astats plugin
+- Added ability for Traffic Monitor to determine health of cache based on interface data and aggregate data. Using the new `stats_over_http` `health.polling.format` value that allows monitoring of multiple interfaces will first require that *all* Traffic Monitors monitoring the affected cache server be upgraded.
 
 ### Fixed
 - Fixed #4979 - Returns a Bad Request error during server creation with missing profileId [Related github issue](https://github.com/apache/trafficcontrol/issues/4979)
diff --git a/docs/source/api/v3/cdns_name_configs_monitoring.rst b/docs/source/api/v3/cdns_name_configs_monitoring.rst
index 660225c..cc4ade5 100644
--- a/docs/source/api/v3/cdns_name_configs_monitoring.rst
+++ b/docs/source/api/v3/cdns_name_configs_monitoring.rst
@@ -76,7 +76,7 @@ Response Structure
 
 		:health.connection.timeout:                 A timeout value, in milliseconds, to wait before giving up on a health check request
 		:health.polling.url:                        A URL to request for polling health. Substitutions can be made in a shell-like syntax using the properties of an object from the ``"trafficServers"`` array
-		:health.threshold.availableBandwidthInKbps: The total amount of bandwidth that servers using this profile are allowed, in Kilobits per second. This is a string and using comparison operators to specify ranges, e.g. ">10" means "more than 10 kbps"
+		:health.threshold.availableBandwidthInKbps: The total amount of bandwidth that servers using this profile are allowed - across all network interfaces - in Kilobits per second. This is a string and using comparison operators to specify ranges, e.g. ">10" means "more than 10 kbps"
 		:health.threshold.loadavg:                  The UNIX loadavg at which the server should be marked "unhealthy"
 
 			.. seealso:: :manpage:`uptime(1)`
diff --git a/docs/source/overview/profiles_and_parameters.rst b/docs/source/overview/profiles_and_parameters.rst
index 9b90d5d..bc3cec1 100644
--- a/docs/source/overview/profiles_and_parameters.rst
+++ b/docs/source/overview/profiles_and_parameters.rst
@@ -560,7 +560,7 @@ health.threshold.loadavg
 	.. caution:: If more than one Parameter with this :ref:`parameter-name` and Config File exist on the same :ref:`Profile <profiles>` with different :ref:`Values <parameter-value>`, the actual Value_ used by any given Traffic Monitor instance is undefined (though it will be the Value_ of one of those Parameters).
 
 health.threshold.availableBandwidthInKbps
-	The Value_ of this Parameter sets the amount of bandwidth (in kilobits per second) that Traffic Control will try to keep available on the :term:`cache server`. For example a Value_ of ">1500000" indicates that the :term:`cache server` will be marked "unhealthy" if its available remaining bandwidth on the network interface used by the caching proxy falls below 1.5Gbps.
+	The Value_ of this Parameter sets the amount of bandwidth (in kilobits per second) that Traffic Control will try to keep available on the :term:`cache server` - for all network interfaces. For example a Value_ of ">1500000" indicates that the :term:`cache server` will be marked "unhealthy" if its available remaining bandwidth across all of the network interfaces used by the caching proxy fall below 1.5Gbps.
 
 	.. caution:: If more than one Parameter with this :ref:`parameter-name` and Config File exist on the same :ref:`Profile <profiles>` with different :ref:`Values <parameter-value>`, the actual Value_ used by any given Traffic Monitor instance is undefined (though it will be the Value_ of one of those Parameters).
 
diff --git a/lib/go-tc/enum.go b/lib/go-tc/enum.go
index 196d8c0..f0abc5b 100644
--- a/lib/go-tc/enum.go
+++ b/lib/go-tc/enum.go
@@ -194,9 +194,6 @@ const DSProtocolHTTPS = 1
 const DSProtocolHTTPAndHTTPS = 2
 const DSProtocolHTTPToHTTPS = 3
 
-// CacheInterfacesAggregate represent the interface that stores aggregate data for each interface on the cache
-const CacheInterfacesAggregate = "aggregate"
-
 // CacheStatus represents the Traffic Server status set in Traffic Ops (online, offline, admin_down, reported). The string values of this type should match the Traffic Ops values.
 type CacheStatus string
 
diff --git a/lib/go-tc/servers.go b/lib/go-tc/servers.go
index 5a79013..53c8d49 100644
--- a/lib/go-tc/servers.go
+++ b/lib/go-tc/servers.go
@@ -6,6 +6,8 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"strconv"
+	"strings"
 	"time"
 
 	"github.com/apache/trafficcontrol/lib/go-util"
@@ -97,6 +99,30 @@ type ServerInterfaceInfo struct {
 	Name         string            `json:"name" db:"name"`
 }
 
+// GetDefaultAddress returns the IPv4 and IPv6 service addresses of the interface.
+func (i *ServerInterfaceInfo) GetDefaultAddress() (string, string) {
+	var ipv4 string
+	var ipv6 string
+	for _, ip := range i.IPAddresses {
+		if ip.ServiceAddress {
+			address, _, err := net.ParseCIDR(ip.Address)
+			if err != nil || address == nil {
+				continue
+			}
+			if address.To4() != nil {
+				ipv4 = ip.Address
+			} else if address.To16() != nil {
+				ipv6 = ip.Address
+			}
+
+			if ipv4 != "" && ipv6 != "" {
+				break
+			}
+		}
+	}
+	return ipv4, ipv6
+}
+
 // Value implements the driver.Valuer interface
 // marshals struct to json to pass back as a json.RawMessage
 func (sii *ServerInterfaceInfo) Value() (driver.Value, error) {
@@ -181,6 +207,76 @@ func (lid *LegacyInterfaceDetails) ToInterfaces(ipv4IsService, ipv6IsService boo
 	return []ServerInterfaceInfo{iface}, nil
 }
 
+// String implements the fmt.Stringer interface.
+func (lid LegacyInterfaceDetails) String() string {
+	var b strings.Builder
+	b.Write([]byte("LegacyInterfaceDetails(InterfaceMtu="))
+
+	if lid.InterfaceMtu == nil {
+		b.Write([]byte("nil"))
+	} else {
+		b.WriteString(strconv.FormatInt(int64(*lid.InterfaceMtu), 10))
+	}
+
+	b.Write([]byte(", InterfaceName="))
+	if lid.InterfaceName != nil {
+		b.WriteRune('\'')
+		b.WriteString(*lid.InterfaceName)
+		b.WriteRune('\'')
+	} else {
+		b.Write([]byte("nil"))
+	}
+
+	b.Write([]byte(", IP6Address="))
+	if lid.IP6Address != nil {
+		b.WriteRune('\'')
+		b.WriteString(*lid.IP6Address)
+		b.WriteRune('\'')
+	} else {
+		b.Write([]byte("nil"))
+	}
+
+	b.Write([]byte(", IP6Gateway="))
+	if lid.IP6Gateway != nil {
+		b.WriteRune('\'')
+		b.WriteString(*lid.IP6Gateway)
+		b.WriteRune('\'')
+	} else {
+		b.Write([]byte("nil"))
+	}
+
+	b.Write([]byte(", IPAddress="))
+	if lid.IPAddress != nil {
+		b.WriteRune('\'')
+		b.WriteString(*lid.IPAddress)
+		b.WriteRune('\'')
+	} else {
+		b.Write([]byte("nil"))
+	}
+
+	b.Write([]byte(", IPGateway="))
+	if lid.IPGateway != nil {
+		b.WriteRune('\'')
+		b.WriteString(*lid.IPGateway)
+		b.WriteRune('\'')
+	} else {
+		b.Write([]byte("nil"))
+	}
+
+	b.Write([]byte(", IPNetmask="))
+	if lid.IPNetmask != nil {
+		b.WriteRune('\'')
+		b.WriteString(*lid.IPNetmask)
+		b.WriteRune('\'')
+	} else {
+		b.Write([]byte("nil"))
+	}
+
+	b.WriteRune(')')
+
+	return b.String()
+}
+
 // InterfaceInfoToLegacyInterfaces converts a ServerInterfaceInfo to an
 // equivalent LegacyInterfaceDetails structure. It does this by creating the
 // IP address fields using the "service" interface's IP addresses. All others
@@ -190,11 +286,15 @@ func InterfaceInfoToLegacyInterfaces(serverInterfaces []ServerInterfaceInfo) (Le
 
 	for _, intFace := range serverInterfaces {
 
+		foundServiceInterface := false
+
 		for _, addr := range intFace.IPAddresses {
 			if !addr.ServiceAddress {
 				continue
 			}
 
+			foundServiceInterface = true
+
 			address := addr.Address
 			gateway := addr.Gateway
 
@@ -226,17 +326,26 @@ func InterfaceInfoToLegacyInterfaces(serverInterfaces []ServerInterfaceInfo) (Le
 				legacyDetails.InterfaceMtu = util.IntPtr(int(*intFace.MTU))
 			}
 
-			legacyDetails.InterfaceName = &intFace.Name
+			// This should no longer matter now that short-circuiting is better,
+			// but this temporary variable is necessary because the 'intFace'
+			// variable is referential, so taking '&intFace.Name' would cause
+			// problems when intFace is reassigned.
+			name := intFace.Name
+			legacyDetails.InterfaceName = &name
 
 			// we can jump out here since servers can only legally have one
 			// IPv4 and one IPv6 service address
 			if legacyDetails.IPAddress != nil && *legacyDetails.IPAddress != "" && legacyDetails.IP6Address != nil && *legacyDetails.IP6Address != "" {
-				return legacyDetails, nil
+				break
 			}
 		}
+
+		if foundServiceInterface {
+			return legacyDetails, nil
+		}
 	}
 
-	return legacyDetails, nil
+	return legacyDetails, errors.New("no service addresses found")
 }
 
 type Server struct {
@@ -396,6 +505,101 @@ type ServerNullableV2 struct {
 	IP6IsService *bool `json:"ip6IsService" db:"ip6_address_is_service"`
 }
 
+// ToNullable converts the Server to an equivalent, "nullable" structure.
+//
+// Note that "zero" values (e.g. the empty string "") are NOT coerced to actual
+// null values. In particular, the only fields that will possibly be nil are
+// FQDN - if the original server had a nil FQDN - and DeliveryServices - which
+// will actually be a pointer to a nil map if the original server had a nil
+// DeliveryServices map.
+// Further note that this makes "shallow" copies of member properties; if
+// reference types (map, slice, pointer etc.) are altered on the original after
+// conversion, the changes WILL affect the nullable copy.
+func (s Server) ToNullable() ServerNullableV2 {
+	return ServerNullableV2{
+		ServerNullableV11: ServerNullableV11{
+			CommonServerProperties: CommonServerProperties{
+				Cachegroup:       &s.Cachegroup,
+				CachegroupID:     &s.CachegroupID,
+				CDNID:            &s.CDNID,
+				CDNName:          &s.CDNName,
+				DeliveryServices: &s.DeliveryServices,
+				DomainName:       &s.DomainName,
+				FQDN:             s.FQDN,
+				FqdnTime:         s.FqdnTime,
+				GUID:             &s.GUID,
+				HostName:         &s.HostName,
+				HTTPSPort:        &s.HTTPSPort,
+				ID:               &s.ID,
+				ILOIPAddress:     &s.ILOIPAddress,
+				ILOIPGateway:     &s.ILOIPGateway,
+				ILOIPNetmask:     &s.ILOIPNetmask,
+				ILOPassword:      &s.ILOPassword,
+				ILOUsername:      &s.ILOUsername,
+				LastUpdated:      &s.LastUpdated,
+				MgmtIPAddress:    &s.MgmtIPAddress,
+				MgmtIPGateway:    &s.MgmtIPGateway,
+				MgmtIPNetmask:    &s.MgmtIPNetmask,
+				OfflineReason:    &s.OfflineReason,
+				PhysLocation:     &s.PhysLocation,
+				PhysLocationID:   &s.PhysLocationID,
+				Profile:          &s.Profile,
+				ProfileDesc:      &s.ProfileDesc,
+				ProfileID:        &s.ProfileID,
+				Rack:             &s.Rack,
+				RevalPending:     &s.RevalPending,
+				RouterHostName:   &s.RouterHostName,
+				RouterPortName:   &s.RouterPortName,
+				Status:           &s.Status,
+				StatusID:         &s.StatusID,
+				TCPPort:          &s.TCPPort,
+				Type:             s.Type,
+				TypeID:           &s.TypeID,
+				UpdPending:       &s.UpdPending,
+				XMPPID:           &s.XMPPID,
+				XMPPPasswd:       &s.XMPPPasswd,
+			},
+			LegacyInterfaceDetails: LegacyInterfaceDetails{
+				InterfaceMtu:  &s.InterfaceMtu,
+				InterfaceName: &s.InterfaceName,
+				IPAddress:     &s.IPAddress,
+				IPGateway:     &s.IPGateway,
+				IPNetmask:     &s.IPNetmask,
+				IP6Address:    &s.IP6Address,
+				IP6Gateway:    &s.IP6Gateway,
+			},
+		},
+		IPIsService:  &s.IPIsService,
+		IP6IsService: &s.IP6IsService,
+	}
+}
+
+// Upgrade upgrades the ServerNullableV2 to the new ServerNullable structure.
+//
+// Note that this makes "shallow" copies of all underlying data, so changes to
+// the original will affect the upgraded copy.
+func (s ServerNullableV2) Upgrade() (ServerNullable, error) {
+	ipv4IsService := false
+	if s.IPIsService != nil {
+		ipv4IsService = *s.IPIsService
+	}
+	ipv6IsService := false
+	if s.IP6IsService != nil {
+		ipv6IsService = *s.IP6IsService
+	}
+
+	upgraded := ServerNullable{
+		CommonServerProperties: s.CommonServerProperties,
+	}
+
+	infs, err := s.LegacyInterfaceDetails.ToInterfaces(ipv4IsService, ipv6IsService)
+	if err != nil {
+		return upgraded, err
+	}
+	upgraded.Interfaces = infs
+	return upgraded, nil
+}
+
 // ServerNullable represents an ATC server, as returned by the TO API.
 type ServerNullable struct {
 	CommonServerProperties
diff --git a/lib/go-tc/servers_test.go b/lib/go-tc/servers_test.go
index 443c585..2d53f93 100644
--- a/lib/go-tc/servers_test.go
+++ b/lib/go-tc/servers_test.go
@@ -20,6 +20,9 @@ package tc
  */
 
 import "fmt"
+import "strings"
+import "testing"
+import "time"
 
 func ExampleLegacyInterfaceDetails_ToInterfaces() {
 	lid := LegacyInterfaceDetails{
@@ -56,3 +59,1313 @@ func ExampleLegacyInterfaceDetails_ToInterfaces() {
 	// 	addr=::14/64, gateway=::15, service address=false
 	//
 }
+
+func ExampleLegacyInterfaceDetails_String() {
+	ipv4 := "192.0.2.0"
+	ipv6 := "2001:DB8::/64"
+	name := "test"
+	mtu := 9000
+
+	lid := LegacyInterfaceDetails{
+		InterfaceMtu:  &mtu,
+		InterfaceName: &name,
+		IP6Address:    &ipv6,
+		IP6Gateway:    nil,
+		IPAddress:     &ipv4,
+		IPGateway:     nil,
+		IPNetmask:     nil,
+	}
+
+	fmt.Println(lid.String())
+
+	// Output: LegacyInterfaceDetails(InterfaceMtu=9000, InterfaceName='test', IP6Address='2001:DB8::/64', IP6Gateway=nil, IPAddress='192.0.2.0', IPGateway=nil, IPNetmask=nil)
+}
+
+type interfaceTest struct {
+	ExpectedIPv4        string
+	ExpectedIPv4Gateway string
+	ExpectedIPv6        string
+	ExpectedIPv6Gateway string
+	ExpectedMTU         *uint64
+	ExpectedName        string
+	ExpectedNetmask     string
+	Interfaces          []ServerInterfaceInfo
+}
+
+// tests a set of interfaces' conversion to legacy format against expected
+// values.
+// Note: This doesn't distinguish between nil and pointer-to-empty-string values
+// when a value is not expected. That's because all ATC components treat null
+// and empty-string values the same, so it's not important which is returned by
+// the conversion process (and in fact expecting one or the other could
+// potentially break some applications).
+func testInfs(expected interfaceTest, t *testing.T) {
+	lid, err := InterfaceInfoToLegacyInterfaces(expected.Interfaces)
+	if err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if lid.InterfaceName == nil {
+		t.Error("Unexpectedly nil Interface Name")
+	} else if *lid.InterfaceName != expected.ExpectedName {
+		t.Errorf("Incorrect Interface Name; want: '%s', got: '%s'", expected.ExpectedName, *lid.InterfaceName)
+	}
+
+	if expected.ExpectedMTU != nil {
+		if lid.InterfaceMtu == nil {
+			t.Error("Unexpectedly nil Interface MTU")
+		} else if uint64(*lid.InterfaceMtu) != *expected.ExpectedMTU {
+			t.Errorf("Incorrect Interface MTU; want: %d, got: %d", *expected.ExpectedMTU, *lid.InterfaceMtu)
+		}
+	} else if lid.InterfaceMtu != nil {
+		t.Error("Unexpectedly non-nil Interface MTU")
+	}
+
+	if expected.ExpectedIPv4 != "" {
+		if lid.IPAddress == nil {
+			t.Error("Unexpectedly nil IPv4 Address")
+		} else if *lid.IPAddress != expected.ExpectedIPv4 {
+			t.Errorf("Incorrect IPv4 Address; want: '%s', got: '%s'", expected.ExpectedIPv4, *lid.IPAddress)
+		}
+	} else if lid.IPAddress != nil && *lid.IPAddress != "" {
+		t.Error("Unexpectedly non-empty IPv4 Address")
+	}
+
+	if expected.ExpectedIPv4Gateway != "" {
+		if lid.IPGateway == nil {
+			t.Error("Unexpectedly nil IPv4 Gateway")
+		} else if *lid.IPGateway != expected.ExpectedIPv4Gateway {
+			t.Errorf("Incorrect IPv4 Gateway; want: '%s', got: '%s'", expected.ExpectedIPv4Gateway, *lid.IPGateway)
+		}
+	} else if lid.IPGateway != nil && *lid.IPGateway != "" {
+		t.Error("Unexpectedly non-empty IPv4 Gateway")
+	}
+
+	if expected.ExpectedNetmask != "" {
+		if lid.IPNetmask == nil {
+			t.Error("Unexpectedly nil IPv4 Netmask")
+		} else if *lid.IPNetmask != expected.ExpectedNetmask {
+			t.Errorf("Incorrect IPv4 Netmask; want: '%s', got: '%s'", expected.ExpectedNetmask, *lid.IPNetmask)
+		}
+	} else if lid.IPNetmask != nil && *lid.IPNetmask != "" {
+		t.Error("Unexpectedly non-empty IPv4 Netmask")
+	}
+
+	if expected.ExpectedIPv6 != "" {
+		if lid.IP6Address == nil {
+			t.Error("Unexpectedly nil IPv6 Address")
+		} else if *lid.IP6Address != expected.ExpectedIPv6 {
+			t.Errorf("Incorrect IPv6 Address; want: '%s', got: '%s'", expected.ExpectedIPv6, *lid.IP6Address)
+		}
+	} else if lid.IP6Address != nil && *lid.IP6Address != "" {
+		t.Error("Unexpectedly non-empty IPv6 Address")
+	}
+
+	if expected.ExpectedIPv6Gateway != "" {
+		if lid.IP6Gateway == nil {
+			t.Error("Unexpectedly nil IPv6 Gateway")
+		} else if *lid.IP6Gateway != expected.ExpectedIPv6Gateway {
+			t.Errorf("Incorrect IPv6 Gateway; want: '%s', got: '%s'", expected.ExpectedIPv6Gateway, *lid.IP6Gateway)
+		}
+	} else if lid.IP6Gateway != nil && *lid.IP6Gateway != "" {
+		t.Error("Unexpectedly non-empty IPv6 Gateway")
+	}
+}
+
+func TestInterfaceInfoToLegacyInterfaces(t *testing.T) {
+	var mtu uint64 = 9000
+	ipv4Gateway := "192.0.2.2"
+	ipv6Gateway := "2001:DB8::2"
+
+	cases := map[string]interfaceTest{
+		"single interface, IPv4 only, no gateway, MTU, or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         nil,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only, no gateway or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only, no netmask": interfaceTest{ // Final Destination
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, IPv4 only": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, no gateway, MTU, or netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: "",
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: "",
+			ExpectedMTU:         nil,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        nil,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+			},
+		},
+		"single interface, extra IP addresses": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.1/5",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "192.0.2.2/20",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+			},
+		},
+		"multiple interfaces, IPv4 only, no netmask": interfaceTest{
+			ExpectedIPv4:        "192.0.2.1",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "",
+			ExpectedIPv6:        "",
+			ExpectedIPv6Gateway: "",
+			Interfaces: []ServerInterfaceInfo{
+				{
+					IPAddresses: []ServerIPAddress{
+						{
+							Address:        "192.0.2.1",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+					},
+					MaxBandwidth: nil,
+					Monitor:      true,
+					MTU:          &mtu,
+					Name:         "test",
+				},
+				{
+					IPAddresses: []ServerIPAddress{
+						{
+							Address:        "192.0.2.2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+					MaxBandwidth: nil,
+					Monitor:      false,
+					MTU:          &mtu,
+					Name:         "invalid",
+				},
+			},
+		},
+		"multiple interfaces": interfaceTest{
+			ExpectedIPv4:        "192.0.2.0",
+			ExpectedIPv4Gateway: ipv4Gateway,
+			ExpectedIPv6:        "2001:DB8::1",
+			ExpectedIPv6Gateway: ipv6Gateway,
+			ExpectedMTU:         &mtu,
+			ExpectedName:        "test",
+			ExpectedNetmask:     "255.255.255.0",
+			Interfaces: []ServerInterfaceInfo{
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "invalid1",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.1/5",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::2",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+				ServerInterfaceInfo{
+					MTU:  &mtu,
+					Name: "test",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.0/24",
+							Gateway:        &ipv4Gateway,
+							ServiceAddress: true,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::1",
+							Gateway:        &ipv6Gateway,
+							ServiceAddress: true,
+						},
+					},
+				},
+				ServerInterfaceInfo{
+					MTU:  nil,
+					Name: "invalid2",
+					IPAddresses: []ServerIPAddress{
+						ServerIPAddress{
+							Address:        "192.0.2.2/7",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+						ServerIPAddress{
+							Address:        "2001:DB8::3/12",
+							Gateway:        nil,
+							ServiceAddress: false,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for description, test := range cases {
+		t.Run(description, func(t *testing.T) { testInfs(test, t) })
+	}
+}
+
+func ensureNoNulls(s ServerNullableV2, t *testing.T) {
+	if s.Cachegroup == nil {
+		t.Error("nullable conversion gave nil Cachegroup")
+	}
+
+	if s.CachegroupID == nil {
+		t.Error("nullable conversion gave nil CachegroupID")
+	}
+
+	if s.CDNID == nil {
+		t.Error("nullable conversion gave nil CDNID")
+	}
+
+	if s.CDNName == nil {
+		t.Error("nullable conversion gave nil CDNName")
+	}
+
+	if s.DeliveryServices == nil {
+		t.Error("nullable conversion gave nil DeliveryServices")
+	}
+
+	if s.DomainName == nil {
+		t.Error("nullable conversion gave nil DomainName")
+	}
+
+	if s.FQDN == nil {
+		t.Error("nullable conversion gave nil FQDN")
+	}
+
+	if s.GUID == nil {
+		t.Error("nullable conversion gave nil GUID")
+	}
+
+	if s.HostName == nil {
+		t.Error("nullable conversion gave nil HostName")
+	}
+
+	if s.HTTPSPort == nil {
+		t.Error("nullable conversion gave nil HTTPSPort")
+	}
+
+	if s.ID == nil {
+		t.Error("nullable conversion gave nil ID")
+	}
+
+	if s.ILOIPAddress == nil {
+		t.Error("nullable conversion gave nil ILOIPAddress")
+	}
+
+	if s.ILOIPGateway == nil {
+		t.Error("nullable conversion gave nil ILOIPGateway")
+	}
+
+	if s.ILOIPNetmask == nil {
+		t.Error("nullable conversion gave nil ILOIPNetmask")
+	}
+
+	if s.ILOPassword == nil {
+		t.Error("nullable conversion gave nil ILOPassword")
+	}
+
+	if s.ILOUsername == nil {
+		t.Error("nullable conversion gave nil ILOUsername")
+	}
+
+	if s.InterfaceMtu == nil {
+		t.Error("nullable conversion gave nil InterfaceMtu")
+	}
+
+	if s.InterfaceName == nil {
+		t.Error("nullable conversion gave nil InterfaceName")
+	}
+
+	if s.IP6Address == nil {
+		t.Error("nullable conversion gave nil IP6Address")
+	}
+
+	if s.IP6IsService == nil {
+		t.Error("nullable conversion gave nil IP6IsService")
+	}
+
+	if s.IP6Gateway == nil {
+		t.Error("nullable conversion gave nil IP6Gateway")
+	}
+
+	if s.IPAddress == nil {
+		t.Error("nullable conversion gave nil IPAddress")
+	}
+
+	if s.IPIsService == nil {
+		t.Error("nullable conversion gave nil IPIsService")
+	}
+
+	if s.IPGateway == nil {
+		t.Error("nullable conversion gave nil IPGateway")
+	}
+
+	if s.IPNetmask == nil {
+		t.Error("nullable conversion gave nil IPNetmask")
+	}
+
+	if s.LastUpdated == nil {
+		t.Error("nullable conversion gave nil LastUpdated")
+	}
+
+	if s.MgmtIPAddress == nil {
+		t.Error("nullable conversion gave nil MgmtIPAddress")
+	}
+
+	if s.MgmtIPGateway == nil {
+		t.Error("nullable conversion gave nil MgmtIPGateway")
+	}
+
+	if s.MgmtIPNetmask == nil {
+		t.Error("nullable conversion gave nil MgmtIPNetmask")
+	}
+
+	if s.OfflineReason == nil {
+		t.Error("nullable conversion gave nil OfflineReason")
+	}
+
+	if s.PhysLocation == nil {
+		t.Error("nullable conversion gave nil PhysLocation")
+	}
+
+	if s.PhysLocationID == nil {
+		t.Error("nullable conversion gave nil PhysLocationID")
+	}
+
+	if s.Profile == nil {
+		t.Error("nullable conversion gave nil Profile")
+	}
+
+	if s.ProfileDesc == nil {
+		t.Error("nullable conversion gave nil ProfileDesc")
+	}
+
+	if s.ProfileID == nil {
+		t.Error("nullable conversion gave nil ProfileID")
+	}
+
+	if s.Rack == nil {
+		t.Error("nullable conversion gave nil Rack")
+	}
+
+	if s.RevalPending == nil {
+		t.Error("nullable conversion gave nil RevalPending")
+	}
+
+	if s.RouterHostName == nil {
+		t.Error("nullable conversion gave nil RouterHostName")
+	}
+
+	if s.RouterPortName == nil {
+		t.Error("nullable conversion gave nil RouterPortName")
+	}
+
+	if s.Status == nil {
+		t.Error("nullable conversion gave nil Status")
+	}
+
+	if s.StatusID == nil {
+		t.Error("nullable conversion gave nil StatusID")
+	}
+
+	if s.TCPPort == nil {
+		t.Error("nullable conversion gave nil TCPPort")
+	}
+
+	if s.TypeID == nil {
+		t.Error("nullable conversion gave nil TypeID")
+	}
+
+	if s.UpdPending == nil {
+		t.Error("nullable conversion gave nil UpdPending")
+	}
+
+	if s.XMPPID == nil {
+		t.Error("nullable conversion gave nil XMPPID")
+	}
+
+	if s.XMPPPasswd == nil {
+		t.Error("nullable conversion gave nil XMPPPasswd")
+	}
+}
+
+func TestServer_ToNullable(t *testing.T) {
+	fqdn := "testFQDN"
+	srv := Server{
+		Cachegroup:       "testCachegroup",
+		CachegroupID:     42,
+		CDNID:            43,
+		CDNName:          "testCDNName",
+		DeliveryServices: map[string][]string{"test": []string{"quest"}},
+		DomainName:       "testDomainName",
+		FQDN:             &fqdn,
+		FqdnTime:         time.Now(),
+		GUID:             "testGUID",
+		HostName:         "testHostName",
+		HTTPSPort:        -1,
+		ID:               44,
+		ILOIPAddress:     "testILOIPAddress",
+		ILOIPGateway:     "testILOIPGateway",
+		ILOIPNetmask:     "testILOIPNetmask",
+		ILOPassword:      "testILOPassword",
+		ILOUsername:      "testILOUsername",
+		InterfaceMtu:     -2,
+		InterfaceName:    "testInterfaceName",
+		IP6Address:       "testIP6Address",
+		IP6IsService:     true,
+		IP6Gateway:       "testIP6Gateway",
+		IPAddress:        "testIPAddress",
+		IPIsService:      false,
+		IPGateway:        "testIPGateway",
+		IPNetmask:        "testIPNetmask",
+		LastUpdated:      TimeNoMod(Time{Time: time.Now().Add(time.Minute), Valid: true}),
+		MgmtIPAddress:    "testMgmtIPAddress",
+		MgmtIPGateway:    "testMgmtIPGateway",
+		MgmtIPNetmask:    "testMgmtIPNetmask",
+		OfflineReason:    "testOfflineReason",
+		PhysLocation:     "testPhysLocation",
+		PhysLocationID:   45,
+		Profile:          "testProfile",
+		ProfileDesc:      "testProfileDesc",
+		ProfileID:        46,
+		Rack:             "testRack",
+		RevalPending:     true,
+		RouterHostName:   "testRouterHostName",
+		RouterPortName:   "testRouterPortName",
+		Status:           "testStatus",
+		StatusID:         47,
+		TCPPort:          -3,
+		Type:             "testType",
+		TypeID:           48,
+		UpdPending:       false,
+		XMPPID:           "testXMPPID",
+		XMPPPasswd:       "testXMPPasswd",
+	}
+
+	nullable := srv.ToNullable()
+
+	if nullable.FqdnTime != srv.FqdnTime {
+		t.Errorf("Incorrect FqdnTime after nullable conversion; want: '%s', got: '%s'", srv.FqdnTime, nullable.FqdnTime)
+	}
+
+	if nullable.Type != srv.Type {
+		t.Errorf("Incorrect Type after nullable conversion; want: '%s', got: '%s'", srv.Type, nullable.Type)
+	}
+
+	noNulls := t.Run("check nullable-converted server for null values", func(t *testing.T) { ensureNoNulls(nullable, t) })
+	if !noNulls {
+		t.Fatal("Cannot check nullable server field referred-to values - null values found")
+	}
+
+	if *nullable.Cachegroup != srv.Cachegroup {
+		t.Errorf("Incorrect Cachegroup after nullable conversion; want: '%s', got: '%s'", srv.Cachegroup, *nullable.Cachegroup)
+	}
+
+	if *nullable.CachegroupID != srv.CachegroupID {
+		t.Errorf("Incorrect CachegroupID after nullable conversion; want: %d, got: %d", srv.CachegroupID, *nullable.CachegroupID)
+	}
+
+	if *nullable.CDNID != srv.CDNID {
+		t.Errorf("Incorrect CDNID after nullable conversion; want: %d, got: %d", srv.CDNID, *nullable.CDNID)
+	}
+
+	if *nullable.CDNName != srv.CDNName {
+		t.Errorf("Incorrect CDNName after nullable conversion; want: '%s', got: '%s'", srv.CDNName, *nullable.CDNName)
+	}
+
+	if len(*nullable.DeliveryServices) != len(srv.DeliveryServices) {
+		t.Errorf("Incorrect number of DeliveryServices after nullable conversion; want: %d, got: %d", len(srv.DeliveryServices), len(*nullable.DeliveryServices))
+	} else {
+		for k, v := range srv.DeliveryServices {
+			nullableV, ok := (*nullable.DeliveryServices)[k]
+			if !ok {
+				t.Errorf("Missing Delivery Service '%s' after nullable conversion", k)
+				continue
+			}
+			if len(nullableV) != len(v) {
+				t.Errorf("Delivery Service '%s' has incorrect length after nullable conversion; want: %d, got: %d", k, len(v), len(nullableV))
+			}
+			for i, ds := range v {
+				nullableDS := nullableV[i]
+				if nullableDS != ds {
+					t.Errorf("Incorrect value at position %d in Delivery Service '%s' after nullable conversion; want: '%s', got: '%s'", i, k, ds, nullableDS)
+				}
+			}
+		}
+	}
+
+	if *nullable.DomainName != srv.DomainName {
+		t.Errorf("Incorrect DomainName after nullable conversion; want: '%s', got: '%s'", srv.DomainName, *nullable.DomainName)
+	}
+
+	if *nullable.FQDN != fqdn {
+		t.Errorf("Incorrect FQDN after nullable conversion; want: '%s', got: '%s'", fqdn, *nullable.FQDN)
+	}
+
+	if *nullable.GUID != srv.GUID {
+		t.Errorf("Incorrect GUID after nullable conversion; want: '%s', got: '%s'", srv.GUID, *nullable.GUID)
+	}
+
+	if *nullable.HostName != srv.HostName {
+		t.Errorf("Incorrect HostName after nullable conversion; want: '%s', got: '%s'", srv.HostName, *nullable.HostName)
+	}
+
+	if *nullable.HTTPSPort != srv.HTTPSPort {
+		t.Errorf("Incorrect HTTPSPort after nullable conversion; want: %d, got: %d", srv.HTTPSPort, *nullable.HTTPSPort)
+	}
+
+	if *nullable.ID != srv.ID {
+		t.Errorf("Incorrect ID after nullable conversion; want: %d, got: %d", srv.ID, *nullable.ID)
+	}
+
+	if *nullable.ILOIPAddress != srv.ILOIPAddress {
+		t.Errorf("Incorrect ILOIPAddress after nullable conversion; want: '%s', got: '%s'", srv.ILOIPAddress, *nullable.ILOIPAddress)
+	}
+
+	if *nullable.ILOIPGateway != srv.ILOIPGateway {
+		t.Errorf("Incorrect ILOIPGateway after nullable conversion; want: '%s', got: '%s'", srv.ILOIPGateway, *nullable.ILOIPGateway)
+	}
+
+	if *nullable.ILOIPNetmask != srv.ILOIPNetmask {
+		t.Errorf("Incorrect ILOIPNetmask after nullable conversion; want: '%s', got: '%s'", srv.ILOIPNetmask, *nullable.ILOIPNetmask)
+	}
+
+	if *nullable.ILOPassword != srv.ILOPassword {
+		t.Errorf("Incorrect ILOPassword after nullable conversion; want: '%s', got: '%s'", srv.ILOPassword, *nullable.ILOPassword)
+	}
+
+	if *nullable.ILOUsername != srv.ILOUsername {
+		t.Errorf("Incorrect ILOUsername after nullable conversion; want: '%s', got: '%s'", srv.ILOUsername, *nullable.ILOUsername)
+	}
+
+	if *nullable.InterfaceMtu != srv.InterfaceMtu {
+		t.Errorf("Incorrect InterfaceMtu after nullable conversion; want: %d, got: %d", srv.InterfaceMtu, *nullable.InterfaceMtu)
+	}
+
+	if *nullable.InterfaceName != srv.InterfaceName {
+		t.Errorf("Incorrect InterfaceName after nullable conversion; want: '%s', got: '%s'", srv.InterfaceName, *nullable.InterfaceName)
+	}
+
+	if *nullable.IP6Address != srv.IP6Address {
+		t.Errorf("Incorrect IP6Address after nullable conversion; want: '%s', got: '%s'", srv.IP6Address, *nullable.IP6Address)
+	}
+
+	if *nullable.IP6IsService != srv.IP6IsService {
+		t.Errorf("Incorrect IP6IsService after nullable conversion; want: %t, got: %t", srv.IP6IsService, *nullable.IP6IsService)
+	}
+
+	if *nullable.IP6Gateway != srv.IP6Gateway {
+		t.Errorf("Incorrect IP6Gateway after nullable conversion; want: '%s', got: '%s'", srv.IP6Gateway, *nullable.IP6Gateway)
+	}
+
+	if *nullable.IPAddress != srv.IPAddress {
+		t.Errorf("Incorrect IPAddress after nullable conversion; want: '%s', got: '%s'", srv.IPAddress, *nullable.IPAddress)
+	}
+
+	if *nullable.IPIsService != srv.IPIsService {
+		t.Errorf("Incorrect IPIsService after nullable conversion; want: %t, got: %t", srv.IPIsService, *nullable.IPIsService)
+	}
+
+	if *nullable.IPGateway != srv.IPGateway {
+		t.Errorf("Incorrect IPGateway after nullable conversion; want: '%s', got: '%s'", srv.IPGateway, *nullable.IPGateway)
+	}
+
+	if *nullable.IPNetmask != srv.IPNetmask {
+		t.Errorf("Incorrect IPNetmask after nullable conversion; want: '%s', got: '%s'", srv.IPNetmask, *nullable.IPNetmask)
+	}
+
+	if *nullable.LastUpdated != srv.LastUpdated {
+		t.Errorf("Incorrect LastUpdated after nullable conversion; want: '%s', got: '%s'", srv.LastUpdated, *nullable.LastUpdated)
+	}
+
+	if *nullable.MgmtIPAddress != srv.MgmtIPAddress {
+		t.Errorf("Incorrect MgmtIPAddress after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPAddress, *nullable.MgmtIPAddress)
+	}
+
+	if *nullable.MgmtIPGateway != srv.MgmtIPGateway {
+		t.Errorf("Incorrect MgmtIPGateway after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPGateway, *nullable.MgmtIPGateway)
+	}
+
+	if *nullable.MgmtIPNetmask != srv.MgmtIPNetmask {
+		t.Errorf("Incorrect MgmtIPNetmask after nullable conversion; want: '%s', got: '%s'", srv.MgmtIPNetmask, *nullable.MgmtIPNetmask)
+	}
+
+	if *nullable.OfflineReason != srv.OfflineReason {
+		t.Errorf("Incorrect OfflineReason after nullable conversion; want: '%s', got: '%s'", srv.OfflineReason, *nullable.OfflineReason)
+	}
+
+	if *nullable.PhysLocation != srv.PhysLocation {
+		t.Errorf("Incorrect PhysLocation after nullable conversion; want: '%s', got: '%s'", srv.PhysLocation, *nullable.PhysLocation)
+	}
+
+	if *nullable.PhysLocationID != srv.PhysLocationID {
+		t.Errorf("Incorrect PhysLocationID after nullable conversion; want: %d, got: %d", srv.PhysLocationID, *nullable.PhysLocationID)
+	}
+
+	if *nullable.Profile != srv.Profile {
+		t.Errorf("Incorrect Profile after nullable conversion; want: '%s', got: '%s'", srv.Profile, *nullable.Profile)
+	}
+
+	if *nullable.ProfileDesc != srv.ProfileDesc {
+		t.Errorf("Incorrect ProfileDesc after nullable conversion; want: '%s', got: '%s'", srv.ProfileDesc, *nullable.ProfileDesc)
+	}
+
+	if *nullable.ProfileID != srv.ProfileID {
+		t.Errorf("Incorrect ProfileID after nullable conversion; want: %d, got: %d", srv.ProfileID, *nullable.ProfileID)
+	}
+
+	if *nullable.Rack != srv.Rack {
+		t.Errorf("Incorrect Rack after nullable conversion; want: '%s', got: '%s'", srv.Rack, *nullable.Rack)
+	}
+
+	if *nullable.RevalPending != srv.RevalPending {
+		t.Errorf("Incorrect RevalPending after nullable conversion; want: %t, got: %t", srv.RevalPending, *nullable.RevalPending)
+	}
+
+	if *nullable.RouterHostName != srv.RouterHostName {
+		t.Errorf("Incorrect RouterHostName after nullable conversion; want: '%s', got: '%s'", srv.RouterHostName, *nullable.RouterHostName)
+	}
+
+	if *nullable.RouterPortName != srv.RouterPortName {
+		t.Errorf("Incorrect RouterPortName after nullable conversion; want: '%s', got: '%s'", srv.RouterPortName, *nullable.RouterPortName)
+	}
+
+	if *nullable.Status != srv.Status {
+		t.Errorf("Incorrect Status after nullable conversion; want: '%s', got: '%s'", srv.Status, *nullable.Status)
+	}
+
+	if *nullable.StatusID != srv.StatusID {
+		t.Errorf("Incorrect StatusID after nullable conversion; want: %d, got: %d", srv.StatusID, *nullable.StatusID)
+	}
+
+	if *nullable.TCPPort != srv.TCPPort {
+		t.Errorf("Incorrect TCPPort after nullable conversion; want: %d, got: %d", srv.TCPPort, *nullable.TCPPort)
+	}
+
+	if *nullable.TypeID != srv.TypeID {
+		t.Errorf("Incorrect TypeID after nullable conversion; want: %d, got: %d", srv.TypeID, *nullable.TypeID)
+	}
+
+	if *nullable.UpdPending != srv.UpdPending {
+		t.Errorf("Incorrect UpdPending after nullable conversion; want: %t, got: %t", srv.UpdPending, *nullable.UpdPending)
+	}
+
+	if *nullable.XMPPID != srv.XMPPID {
+		t.Errorf("Incorrect XMPPID after nullable conversion; want: '%s', got: '%s'", srv.XMPPID, *nullable.XMPPID)
+	}
+
+	if *nullable.XMPPPasswd != srv.XMPPPasswd {
+		t.Errorf("Incorrect XMPPPasswd after nullable conversion; want: '%s', got: '%s'", srv.XMPPPasswd, *nullable.XMPPPasswd)
+	}
+}
+
+func TestServerNullableV2_Upgrade(t *testing.T) {
+	fqdn := "testFQDN"
+	srv := Server{
+		Cachegroup:       "testCachegroup",
+		CachegroupID:     42,
+		CDNID:            43,
+		CDNName:          "testCDNName",
+		DeliveryServices: map[string][]string{"test": []string{"quest"}},
+		DomainName:       "testDomainName",
+		FQDN:             &fqdn,
+		FqdnTime:         time.Now(),
+		GUID:             "testGUID",
+		HostName:         "testHostName",
+		HTTPSPort:        -1,
+		ID:               44,
+		ILOIPAddress:     "testILOIPAddress",
+		ILOIPGateway:     "testILOIPGateway",
+		ILOIPNetmask:     "testILOIPNetmask",
+		ILOPassword:      "testILOPassword",
+		ILOUsername:      "testILOUsername",
+		InterfaceMtu:     2,
+		InterfaceName:    "testInterfaceName",
+		IP6Address:       "::1/64",
+		IP6IsService:     true,
+		IP6Gateway:       "::2",
+		IPAddress:        "0.0.0.1",
+		IPIsService:      false,
+		IPGateway:        "0.0.0.2",
+		IPNetmask:        "255.255.255.0",
+		LastUpdated:      TimeNoMod(Time{Time: time.Now().Add(time.Minute), Valid: true}),
+		MgmtIPAddress:    "testMgmtIPAddress",
+		MgmtIPGateway:    "testMgmtIPGateway",
+		MgmtIPNetmask:    "testMgmtIPNetmask",
+		OfflineReason:    "testOfflineReason",
+		PhysLocation:     "testPhysLocation",
+		PhysLocationID:   45,
+		Profile:          "testProfile",
+		ProfileDesc:      "testProfileDesc",
+		ProfileID:        46,
+		Rack:             "testRack",
+		RevalPending:     true,
+		RouterHostName:   "testRouterHostName",
+		RouterPortName:   "testRouterPortName",
+		Status:           "testStatus",
+		StatusID:         47,
+		TCPPort:          -3,
+		Type:             "testType",
+		TypeID:           48,
+		UpdPending:       false,
+		XMPPID:           "testXMPPID",
+		XMPPPasswd:       "testXMPPasswd",
+	}
+
+	// this is so much easier than double the lines to manually construct a
+	// nullable v2 server
+	nullable := srv.ToNullable()
+
+	noNulls := t.Run("check nullable-converted server for null values", func(t *testing.T) { ensureNoNulls(nullable, t) })
+	if !noNulls {
+		t.Fatal("Cannot check nullable server field referred-to values - null values found")
+	}
+
+	upgraded, err := nullable.Upgrade()
+	if err != nil {
+		t.Fatalf("Unexpected error upgrading server: %v", err)
+	}
+
+	if upgraded.Cachegroup == nil {
+		t.Error("upgraded conversion gave nil Cachegroup")
+	} else if *upgraded.Cachegroup != *nullable.Cachegroup {
+		t.Errorf("Incorrect Cachegroup after upgraded conversion; want: '%s', got: '%s'", *nullable.Cachegroup, *upgraded.Cachegroup)
+	}
+
+	if upgraded.CachegroupID == nil {
+		t.Error("upgraded conversion gave nil CachegroupID")
+	} else if *upgraded.CachegroupID != *nullable.CachegroupID {
+		t.Errorf("Incorrect CachegroupID after upgraded conversion; want: %d, got: %d", *nullable.CachegroupID, *upgraded.CachegroupID)
+	}
+
+	if upgraded.CDNID == nil {
+		t.Error("upgraded conversion gave nil CDNID")
+	} else if *upgraded.CDNID != *nullable.CDNID {
+		t.Errorf("Incorrect CDNID after upgraded conversion; want: %d, got: %d", *nullable.CDNID, *upgraded.CDNID)
+	}
+
+	if upgraded.CDNName == nil {
+		t.Error("upgraded conversion gave nil CDNName")
+	} else if *upgraded.CDNName != *nullable.CDNName {
+		t.Errorf("Incorrect CDNName after upgraded conversion; want: '%s', got: '%s'", *nullable.CDNName, *upgraded.CDNName)
+	}
+
+	if upgraded.DeliveryServices == nil {
+		t.Error("upgraded conversion gave nil DeliveryServices")
+	} else if len(*upgraded.DeliveryServices) != len(*nullable.DeliveryServices) {
+		t.Errorf("Incorrect number of DeliveryServices after upgraded conversion; want: %d, got: %d", len(*nullable.DeliveryServices), len(*upgraded.DeliveryServices))
+	} else {
+		for k, v := range *nullable.DeliveryServices {
+			upgradedV, ok := (*upgraded.DeliveryServices)[k]
+			if !ok {
+				t.Errorf("Missing Delivery Service '%s' after upgraded conversion", k)
+				continue
+			}
+			if len(upgradedV) != len(v) {
+				t.Errorf("Delivery Service '%s' has incorrect length after upgraded conversion; want: %d, got: %d", k, len(v), len(upgradedV))
+			}
+			for i, ds := range v {
+				upgradedDS := upgradedV[i]
+				if upgradedDS != ds {
+					t.Errorf("Incorrect value at position %d in Delivery Service '%s' after upgraded conversion; want: '%s', got: '%s'", i, k, ds, upgradedDS)
+				}
+			}
+		}
+	}
+
+	if upgraded.DomainName == nil {
+		t.Error("upgraded conversion gave nil DomainName")
+	} else if *upgraded.DomainName != *nullable.DomainName {
+		t.Errorf("Incorrect DomainName after upgraded conversion; want: '%s', got: '%s'", *nullable.DomainName, *upgraded.DomainName)
+	}
+
+	if upgraded.FQDN == nil {
+		t.Error("upgraded conversion gave nil FQDN")
+	} else if *upgraded.FQDN != fqdn {
+		t.Errorf("Incorrect FQDN after upgraded conversion; want: '%s', got: '%s'", fqdn, *upgraded.FQDN)
+	}
+
+	if upgraded.FqdnTime != nullable.FqdnTime {
+		t.Errorf("Incorrect FqdnTime after upgraded conversion; want: '%s', got: '%s'", nullable.FqdnTime, upgraded.FqdnTime)
+	}
+
+	if upgraded.GUID == nil {
+		t.Error("upgraded conversion gave nil GUID")
+	} else if *upgraded.GUID != *nullable.GUID {
+		t.Errorf("Incorrect GUID after upgraded conversion; want: '%s', got: '%s'", *nullable.GUID, *upgraded.GUID)
+	}
+
+	if upgraded.HostName == nil {
+		t.Error("upgraded conversion gave nil HostName")
+	} else if *upgraded.HostName != *nullable.HostName {
+		t.Errorf("Incorrect HostName after upgraded conversion; want: '%s', got: '%s'", *nullable.HostName, *upgraded.HostName)
+	}
+
+	if upgraded.HTTPSPort == nil {
+		t.Error("upgraded conversion gave nil HTTPSPort")
+	} else if *upgraded.HTTPSPort != *nullable.HTTPSPort {
+		t.Errorf("Incorrect HTTPSPort after upgraded conversion; want: %d, got: %d", *nullable.HTTPSPort, *upgraded.HTTPSPort)
+	}
+
+	if upgraded.ID == nil {
+		t.Error("upgraded conversion gave nil ID")
+	} else if *upgraded.ID != *nullable.ID {
+		t.Errorf("Incorrect ID after upgraded conversion; want: %d, got: %d", *nullable.ID, *upgraded.ID)
+	}
+
+	if upgraded.ILOIPAddress == nil {
+		t.Error("upgraded conversion gave nil ILOIPAddress")
+	} else if *upgraded.ILOIPAddress != *nullable.ILOIPAddress {
+		t.Errorf("Incorrect ILOIPAddress after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOIPAddress, *upgraded.ILOIPAddress)
+	}
+
+	if upgraded.ILOIPGateway == nil {
+		t.Error("upgraded conversion gave nil ILOIPGateway")
+	} else if *upgraded.ILOIPGateway != *nullable.ILOIPGateway {
+		t.Errorf("Incorrect ILOIPGateway after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOIPGateway, *upgraded.ILOIPGateway)
+	}
+
+	if upgraded.ILOIPNetmask == nil {
+		t.Error("upgraded conversion gave nil ILOIPNetmask")
+	} else if *upgraded.ILOIPNetmask != *nullable.ILOIPNetmask {
+		t.Errorf("Incorrect ILOIPNetmask after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOIPNetmask, *upgraded.ILOIPNetmask)
+	}
+
+	if upgraded.ILOPassword == nil {
+		t.Error("upgraded conversion gave nil ILOPassword")
+	} else if *upgraded.ILOPassword != *nullable.ILOPassword {
+		t.Errorf("Incorrect ILOPassword after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOPassword, *upgraded.ILOPassword)
+	}
+
+	if upgraded.ILOUsername == nil {
+		t.Error("upgraded conversion gave nil ILOUsername")
+	} else if *upgraded.ILOUsername != *nullable.ILOUsername {
+		t.Errorf("Incorrect ILOUsername after upgraded conversion; want: '%s', got: '%s'", *nullable.ILOUsername, *upgraded.ILOUsername)
+	}
+
+	infLen := len(upgraded.Interfaces)
+	if infLen < 1 {
+		t.Error("Expected exactly one interface after upgrade, got: 0")
+	} else {
+		if infLen > 1 {
+			t.Errorf("Expected exactly one interface after upgrade, got: %d", infLen)
+		}
+
+		inf := upgraded.Interfaces[0]
+		if inf.Name != *nullable.InterfaceName {
+			t.Errorf("Incorrect interface name after upgrade; want: '%s', got: '%s'", *nullable.InterfaceName, inf.Name)
+		}
+
+		if inf.MTU == nil {
+			t.Error("Unexpectedly nil Interface MTU after upgrade")
+		} else if *inf.MTU != uint64(*nullable.InterfaceMtu) {
+			t.Errorf("Incorrect Interface MTU after upgrade; want: %d, got: %d", *nullable.InterfaceMtu, *inf.MTU)
+		}
+
+		if inf.Monitor {
+			t.Error("Incorrect Interface Monitor after upgrade; want: false, got: true")
+		}
+
+		if inf.MaxBandwidth != nil {
+			t.Error("Unexpectedly non-nil Interface MaxBandwidth after upgrade")
+		}
+
+		if len(inf.IPAddresses) != 2 {
+			t.Errorf("Incorrect number of IP Addresses after upgrade; want: 2, got: %d", len(inf.IPAddresses))
+		} else {
+			ip := inf.IPAddresses[0]
+			cidrIndex := strings.Index(ip.Address, "/")
+			addr := ip.Address
+			if cidrIndex >= 0 {
+				addr = addr[:cidrIndex]
+			}
+
+			// TODO: calculate and verify netmask
+			if addr == *nullable.IPAddress {
+				if ip.Gateway == nil {
+					t.Error("Unexpectedly nil IPv4 Gateway after upgrade")
+				} else if *ip.Gateway != *nullable.IPGateway {
+					t.Errorf("Incorrect IPv4 Gateway after upgrade; want: '%s', got: '%s'", *nullable.IPGateway, *ip.Gateway)
+				}
+
+				if ip.ServiceAddress != *nullable.IPIsService {
+					t.Errorf("Incorrect IPv4 ServiceAddress value after upgrade; want: %t, got: %t", *nullable.IPIsService, ip.ServiceAddress)
+				}
+
+				secondIP := inf.IPAddresses[1]
+				if secondIP.Address != *nullable.IP6Address {
+					t.Errorf("Incorrect IPv6 Address after upgrade; want: '%s', got: '%s'", *nullable.IP6Address, secondIP.Address)
+				} else {
+					if secondIP.Gateway == nil {
+						t.Error("Unexpectedly nil IPv6 Gateway after upgrade")
+					} else if *secondIP.Gateway != *nullable.IP6Gateway {
+						t.Errorf("Incorrect IPv6 Gateway after upgrade; want: '%s', got: '%s'", *nullable.IP6Gateway, *secondIP.Gateway)
+					}
+
+					if secondIP.ServiceAddress != *nullable.IP6IsService {
+						t.Errorf("Incorrect IPv6 ServiceAddress value after upgrade; want: %t, got: %t", *nullable.IP6IsService, secondIP.ServiceAddress)
+					}
+				}
+			} else if ip.Address == *nullable.IP6Address {
+				if ip.Gateway == nil {
+					t.Error("Unexpectedly nil IPv6 Gateway after upgrade")
+				} else if *ip.Gateway != *nullable.IP6Gateway {
+					t.Errorf("Incorrect IPv6 Gateway after upgrade; want: '%s', got: '%s'", *nullable.IP6Gateway, *ip.Gateway)
+				}
+
+				if ip.ServiceAddress != *nullable.IP6IsService {
+					t.Errorf("Incorrect IPv6 ServiceAddress value after upgrade; want: %t, got: %t", *nullable.IP6IsService, ip.ServiceAddress)
+				}
+
+				secondIP := inf.IPAddresses[1]
+				cidrIndex = strings.Index(secondIP.Address, "/")
+				addr = secondIP.Address
+				if cidrIndex >= 0 {
+					addr = addr[:cidrIndex]
+				}
+				// TODO: calculate and verify netmask
+				if addr != *nullable.IPAddress {
+					t.Errorf("Incorrect IPv4 Address after upgrade; want: '%s', got: '%s'", *nullable.IPAddress, secondIP.Address)
+				} else {
+					if secondIP.Gateway == nil {
+						t.Error("Unexpectedly nil IPv4 Gateway after upgrade")
+					} else if *secondIP.Gateway != *nullable.IPGateway {
+						t.Errorf("Incorrect IPv4 Gateway after upgrade; want: '%s', got: '%s'", *nullable.IPGateway, *secondIP.Gateway)
+					}
+
+					if secondIP.ServiceAddress != *nullable.IPIsService {
+						t.Errorf("Incorrect IPv4 ServiceAddress value after upgrade; want: %t, got: %t", *nullable.IPIsService, secondIP.ServiceAddress)
+					}
+				}
+
+			} else {
+				t.Errorf("Unknown IP address '%s' found in interface after upgrade", ip.Address)
+				ip = inf.IPAddresses[1]
+				cidrIndex = strings.Index(ip.Address, "/")
+				addr = ip.Address
+				if cidrIndex >= 0 {
+					addr = addr[:cidrIndex]
+				}
+
+				if addr == *nullable.IPAddress {
+					t.Error("Missing IPv6 address after upgrade")
+					if ip.Gateway == nil {
+						t.Error("Unexpectedly nil IPv4 Gateway after upgrade")
+					} else if *ip.Gateway != *nullable.IPGateway {
+						t.Errorf("Incorrect IPv4 Gateway after upgrade; want: '%s', got: '%s'", *nullable.IPGateway, *ip.Gateway)
+					}
+
+					if ip.ServiceAddress != *nullable.IPIsService {
+						t.Errorf("Incorrect IPv4 ServiceAddress value after upgrade; want: %t, got: %t", *nullable.IPIsService, ip.ServiceAddress)
+					}
+				} else if ip.Address == *nullable.IP6Address {
+					t.Error("Missing IPv4 address after upgrade")
+					if ip.Gateway == nil {
+						t.Error("Unexpectedly nil IPv6 Gateway after upgrade")
+					} else if *ip.Gateway != *nullable.IP6Gateway {
+						t.Errorf("Incorrect IPv6 Gateway after upgrade; want: '%s', got: '%s'", *nullable.IP6Gateway, *ip.Gateway)
+					}
+
+					if ip.ServiceAddress != *nullable.IP6IsService {
+						t.Errorf("Incorrect IPv6 ServiceAddress value after upgrade; want: %t, got: %t", *nullable.IP6IsService, ip.ServiceAddress)
+					}
+				} else {
+					t.Errorf("Unknown IP address '%s' found in interface after upgrade", ip.Address)
+					t.Error("Missing both IPv4 and IPv6 address after upgrade")
+				}
+			}
+		}
+	}
+
+	if upgraded.LastUpdated == nil {
+		t.Error("upgraded conversion gave nil LastUpdated")
+	} else if *upgraded.LastUpdated != *nullable.LastUpdated {
+		t.Errorf("Incorrect LastUpdated after upgraded conversion; want: '%s', got: '%s'", *nullable.LastUpdated, *upgraded.LastUpdated)
+	}
+
+	if upgraded.MgmtIPAddress == nil {
+		t.Error("upgraded conversion gave nil MgmtIPAddress")
+	} else if *upgraded.MgmtIPAddress != *nullable.MgmtIPAddress {
+		t.Errorf("Incorrect MgmtIPAddress after upgraded conversion; want: '%s', got: '%s'", *nullable.MgmtIPAddress, *upgraded.MgmtIPAddress)
+	}
+
+	if upgraded.MgmtIPGateway == nil {
+		t.Error("upgraded conversion gave nil MgmtIPGateway")
+	} else if *upgraded.MgmtIPGateway != *nullable.MgmtIPGateway {
+		t.Errorf("Incorrect MgmtIPGateway after upgraded conversion; want: '%s', got: '%s'", *nullable.MgmtIPGateway, *upgraded.MgmtIPGateway)
+	}
+
+	if upgraded.MgmtIPNetmask == nil {
+		t.Error("upgraded conversion gave nil MgmtIPNetmask")
+	} else if *upgraded.MgmtIPNetmask != *nullable.MgmtIPNetmask {
+		t.Errorf("Incorrect MgmtIPNetmask after upgraded conversion; want: '%s', got: '%s'", *nullable.MgmtIPNetmask, *upgraded.MgmtIPNetmask)
+	}
+
+	if upgraded.OfflineReason == nil {
+		t.Error("upgraded conversion gave nil OfflineReason")
+	} else if *upgraded.OfflineReason != *nullable.OfflineReason {
+		t.Errorf("Incorrect OfflineReason after upgraded conversion; want: '%s', got: '%s'", *nullable.OfflineReason, *upgraded.OfflineReason)
+	}
+
+	if upgraded.PhysLocation == nil {
+		t.Error("upgraded conversion gave nil PhysLocation")
+	} else if *upgraded.PhysLocation != *nullable.PhysLocation {
+		t.Errorf("Incorrect PhysLocation after upgraded conversion; want: '%s', got: '%s'", *nullable.PhysLocation, *upgraded.PhysLocation)
+	}
+
+	if upgraded.PhysLocationID == nil {
+		t.Error("upgraded conversion gave nil PhysLocationID")
+	} else if *upgraded.PhysLocationID != *nullable.PhysLocationID {
+		t.Errorf("Incorrect PhysLocationID after upgraded conversion; want: %d, got: %d", *nullable.PhysLocationID, *upgraded.PhysLocationID)
+	}
+
+	if upgraded.Profile == nil {
+		t.Error("upgraded conversion gave nil Profile")
+	} else if *upgraded.Profile != *nullable.Profile {
+		t.Errorf("Incorrect Profile after upgraded conversion; want: '%s', got: '%s'", *nullable.Profile, *upgraded.Profile)
+	}
+
+	if upgraded.ProfileDesc == nil {
+		t.Error("upgraded conversion gave nil ProfileDesc")
+	} else if *upgraded.ProfileDesc != *nullable.ProfileDesc {
+		t.Errorf("Incorrect ProfileDesc after upgraded conversion; want: '%s', got: '%s'", *nullable.ProfileDesc, *upgraded.ProfileDesc)
+	}
+
+	if upgraded.ProfileID == nil {
+		t.Error("upgraded conversion gave nil ProfileID")
+	} else if *upgraded.ProfileID != *nullable.ProfileID {
+		t.Errorf("Incorrect ProfileID after upgraded conversion; want: %d, got: %d", *nullable.ProfileID, *upgraded.ProfileID)
+	}
+
+	if upgraded.Rack == nil {
+		t.Error("upgraded conversion gave nil Rack")
+	} else if *upgraded.Rack != *nullable.Rack {
+		t.Errorf("Incorrect Rack after upgraded conversion; want: '%s', got: '%s'", *nullable.Rack, *upgraded.Rack)
+	}
+
+	if upgraded.RevalPending == nil {
+		t.Error("upgraded conversion gave nil RevalPending")
+	} else if *upgraded.RevalPending != *nullable.RevalPending {
+		t.Errorf("Incorrect RevalPending after upgraded conversion; want: %t, got: %t", *nullable.RevalPending, *upgraded.RevalPending)
+	}
+
+	if upgraded.RouterHostName == nil {
+		t.Error("upgraded conversion gave nil RouterHostName")
+	} else if *upgraded.RouterHostName != *nullable.RouterHostName {
+		t.Errorf("Incorrect RouterHostName after upgraded conversion; want: '%s', got: '%s'", *nullable.RouterHostName, *upgraded.RouterHostName)
+	}
+
+	if upgraded.RouterPortName == nil {
+		t.Error("upgraded conversion gave nil RouterPortName")
+	} else if *upgraded.RouterPortName != *nullable.RouterPortName {
+		t.Errorf("Incorrect RouterPortName after upgraded conversion; want: '%s', got: '%s'", *nullable.RouterPortName, *upgraded.RouterPortName)
+	}
+
+	if upgraded.Status == nil {
+		t.Error("upgraded conversion gave nil Status")
+	} else if *upgraded.Status != *nullable.Status {
+		t.Errorf("Incorrect Status after upgraded conversion; want: '%s', got: '%s'", *nullable.Status, *upgraded.Status)
+	}
+
+	if upgraded.StatusID == nil {
+		t.Error("upgraded conversion gave nil StatusID")
+	} else if *upgraded.StatusID != *nullable.StatusID {
+		t.Errorf("Incorrect StatusID after upgraded conversion; want: %d, got: %d", *nullable.StatusID, *upgraded.StatusID)
+	}
+
+	if upgraded.TCPPort == nil {
+		t.Error("upgraded conversion gave nil TCPPort")
+	} else if *upgraded.TCPPort != *nullable.TCPPort {
+		t.Errorf("Incorrect TCPPort after upgraded conversion; want: %d, got: %d", *nullable.TCPPort, *upgraded.TCPPort)
+	}
+
+	if upgraded.Type != nullable.Type {
+		t.Errorf("Incorrect Type after upgraded conversion; want: '%s', got: '%s'", nullable.Type, upgraded.Type)
+	}
+
+	if upgraded.TypeID == nil {
+		t.Error("upgraded conversion gave nil TypeID")
+	} else if *upgraded.TypeID != *nullable.TypeID {
+		t.Errorf("Incorrect TypeID after upgraded conversion; want: %d, got: %d", *nullable.TypeID, *upgraded.TypeID)
+	}
+
+	if upgraded.UpdPending == nil {
+		t.Error("upgraded conversion gave nil UpdPending")
+	} else if *upgraded.UpdPending != *nullable.UpdPending {
+		t.Errorf("Incorrect UpdPending after upgraded conversion; want: %t, got: %t", *nullable.UpdPending, *upgraded.UpdPending)
+	}
+
+	if upgraded.XMPPID == nil {
+		t.Error("upgraded conversion gave nil XMPPID")
+	} else if *upgraded.XMPPID != *nullable.XMPPID {
+		t.Errorf("Incorrect XMPPID after upgraded conversion; want: '%s', got: '%s'", *nullable.XMPPID, *upgraded.XMPPID)
+	}
+
+	if upgraded.XMPPPasswd == nil {
+		t.Error("upgraded conversion gave nil XMPPPasswd")
+	} else if *upgraded.XMPPPasswd != *nullable.XMPPPasswd {
+		t.Errorf("Incorrect XMPPPasswd after upgraded conversion; want: '%s', got: '%s'", *nullable.XMPPPasswd, *upgraded.XMPPPasswd)
+	}
+}
diff --git a/lib/go-tc/traffic_monitor.go b/lib/go-tc/traffic_monitor.go
index 0a06afb..a78c809 100644
--- a/lib/go-tc/traffic_monitor.go
+++ b/lib/go-tc/traffic_monitor.go
@@ -1,13 +1,5 @@
 package tc
 
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-	"strconv"
-	"strings"
-)
-
 /*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -26,24 +18,58 @@ import (
  * specific language governing permissions and limitations
  * under the License.
  */
-// TMConfigResponse ...
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// ThresholdPrefix is the prefix of all Names of Parameters used to define
+// monitoring thresholds.
+const ThresholdPrefix = "health.threshold."
+
+// TMConfigResponse is the response to requests made to the
+// cdns/{{Name}}/configs/monitoring endpoint of the Traffic Ops API.
 type TMConfigResponse struct {
 	Response TrafficMonitorConfig `json:"response"`
 }
 
-// LegacyTMConfigResponse ...
+// LegacyTMConfigResponse was the response to requests made to the
+// cdns/{{Name}}/configs/montoring endpoint of the Traffic Ops API in older API
+// versions.
 type LegacyTMConfigResponse struct {
 	Response LegacyTrafficMonitorConfig `json:"response"`
 }
 
-// TrafficMonitorConfig ...
+// TrafficMonitorConfig is the full set of information needed by Traffic
+// Monitor to do its job of monitoring health and statistics of cache servers.
 type TrafficMonitorConfig struct {
-	TrafficServers   []TrafficServer        `json:"trafficServers,omitempty"`
-	CacheGroups      []TMCacheGroup         `json:"cacheGroups,omitempty"`
-	Config           map[string]interface{} `json:"config,omitempty"`
-	TrafficMonitors  []TrafficMonitor       `json:"trafficMonitors,omitempty"`
-	DeliveryServices []TMDeliveryService    `json:"deliveryServices,omitempty"`
-	Profiles         []TMProfile            `json:"profiles,omitempty"`
+	// TrafficServers is the set of all Cache Servers which should be monitored
+	// by the Traffic Monitor.
+	TrafficServers []TrafficServer `json:"trafficServers,omitempty"`
+	// CacheGroups is a collection of Cache Group Names associated with their
+	// geographic coordinates, for use in determining Cache Group availability.
+	CacheGroups []TMCacheGroup `json:"cacheGroups,omitempty"`
+	// Config is a mapping of arbitrary configuration parameters to their
+	// respective values. While there is no defined structure to this map,
+	// certain configuration parameters have specifically defined semantics
+	// which may be found in the Traffic Monitor documentation.
+	Config map[string]interface{} `json:"config,omitempty"`
+	// TrafficMonitors is the set of ALL Traffic Monitors (including whatever
+	// Traffic Monitor requested the endpoint (if indeed one did)) which is
+	// used to determine quorum and peer polling behavior.
+	TrafficMonitors []TrafficMonitor `json:"trafficMonitors,omitempty"`
+	// DeliveryServices is the set of all Delivery Services within the
+	// monitored CDN, which are used to determine Delivery Service-level
+	// statistics.
+	DeliveryServices []TMDeliveryService `json:"deliveryServices,omitempty"`
+	// Profiles is the set of Profiles in use by any and all monitored cache
+	// servers (those given in TrafficServers), which are stored here to
+	// avoid potentially lengthy reiteration.
+	Profiles []TMProfile `json:"profiles,omitempty"`
 }
 
 // ToLegacyConfig converts TrafficMonitorConfig to LegacyTrafficMonitorConfig.
@@ -74,14 +100,102 @@ type LegacyTrafficMonitorConfig struct {
 	Profiles         []TMProfile            `json:"profiles,omitempty"`
 }
 
-// TrafficMonitorConfigMap ...
+// Upgrade converts a legacy TM Config to the newer structure.
+func (s *LegacyTrafficMonitorConfig) Upgrade() *TrafficMonitorConfig {
+	upgraded := TrafficMonitorConfig{
+		CacheGroups:      s.CacheGroups,
+		Config:           s.Config,
+		DeliveryServices: s.DeliveryServices,
+		Profiles:         s.Profiles,
+		TrafficMonitors:  s.TrafficMonitors,
+		TrafficServers:   make([]TrafficServer, 0, len(s.TrafficServers)),
+	}
+	for _, ts := range s.TrafficServers {
+		upgraded.TrafficServers = append(upgraded.TrafficServers, ts.Upgrade())
+	}
+	return &upgraded
+}
+
+// TrafficMonitorConfigMap is a representation of a TrafficMonitorConfig using
+// unique values as map keys.
 type TrafficMonitorConfigMap struct {
-	TrafficServer   map[string]TrafficServer
-	CacheGroup      map[string]TMCacheGroup
-	Config          map[string]interface{}
-	TrafficMonitor  map[string]TrafficMonitor
+	// TrafficServer is a map of cache server hostnames to TrafficServer
+	// objects.
+	//
+	// WARNING: Cache server hostnames are NOT guaranteed to be unique, so when
+	// more than one cache server with the same hostname exists, the two CANNOT
+	// coexist within this structure; only one will appear and, when
+	// constructed using TrafficMonitorTransformToMap, which cache server does
+	// exist in the generated map is undefined.
+	TrafficServer map[string]TrafficServer
+	// CacheGroup is a map of Cache Group Names to TMCacheGroup objects.
+	CacheGroup map[string]TMCacheGroup
+	// Config is a mapping of arbitrary configuration parameters to their
+	// respective values. While there is no defined structure to this map,
+	// certain configuration parameters have specifically defined semantics
+	// which may be found in the Traffic Monitor documentation.
+	Config map[string]interface{}
+	// TrafficMonitor is a map of Traffic Monitor hostnames to TrafficMonitor
+	// objects.
+	//
+	// WARNING: Traffic Monitor hostnames are NOT guaranteed to be unique, so
+	// when more than one cache server with the same hostname exists, the two
+	// CANNOT coexist within this structure; only one will appear and, when
+	// constructed using TrafficMonitorTransformToMap, which Traffic Monitor
+	// does exist in the generated map is undefined.
+	TrafficMonitor map[string]TrafficMonitor
+	// DeliveryService is a map of Delivery Service XMLIDs to TMDeliveryService
+	// objects.
 	DeliveryService map[string]TMDeliveryService
-	Profile         map[string]TMProfile
+	// Profile is a map of Profile Names to TMProfile objects.
+	Profile map[string]TMProfile
+}
+
+// Valid returns a non-nil error if the configuration map is invalid.
+//
+// A configuration map is considered invalid if:
+// - It is nil
+// - It has no CacheGroups
+// - It has no Profiles
+// - It has no Traffic Monitors
+// - It has no Traffic Servers
+// - The Config mapping has no 'peers.polling.interval' key
+// - The Config mapping has no 'health.polling.interval' key
+func (cfg *TrafficMonitorConfigMap) Valid() error {
+	if cfg == nil {
+		return errors.New("MonitorConfig is nil")
+	}
+	if len(cfg.TrafficServer) == 0 {
+		return errors.New("MonitorConfig.TrafficServer empty (is the monitoring.json an empty object?)")
+	}
+	if len(cfg.CacheGroup) == 0 {
+		return errors.New("MonitorConfig.CacheGroup empty")
+	}
+	if len(cfg.TrafficMonitor) == 0 {
+		return errors.New("MonitorConfig.TrafficMonitor empty")
+	}
+	// TODO uncomment this, when TO is fixed to include DeliveryServices.
+	// See https://github.com/apache/trafficcontrol/issues/3528
+	// if len(cfg.DeliveryService) == 0 {
+	// 	return errors.New("MonitorConfig.DeliveryService empty")
+	// }
+	if len(cfg.Profile) == 0 {
+		return errors.New("MonitorConfig.Profile empty")
+	}
+
+	if intervalI, ok := cfg.Config["peers.polling.interval"]; !ok {
+		return errors.New(`MonitorConfig.Config["peers.polling.interval"] missing, peers.polling.interval parameter required`)
+	} else if _, ok := intervalI.(float64); !ok {
+		return fmt.Errorf(`MonitorConfig.Config["peers.polling.interval"] '%v' not a number, parameter peers.polling.interval must be a number`, intervalI)
+	}
+
+	if intervalI, ok := cfg.Config["health.polling.interval"]; !ok {
+		return errors.New(`MonitorConfig.Config["health.polling.interval"] missing, health.polling.interval parameter required`)
+	} else if _, ok := intervalI.(float64); !ok {
+		return fmt.Errorf(`MonitorConfig.Config["health.polling.interval"] '%v' not a number, parameter health.polling.interval must be a number`, intervalI)
+	}
+
+	return nil
 }
 
 // LegacyTrafficMonitorConfigMap ...
@@ -94,20 +208,52 @@ type LegacyTrafficMonitorConfigMap struct {
 	Profile         map[string]TMProfile
 }
 
-// TrafficMonitor ...
+// Upgrade returns a TrafficMonitorConfigMap that is equivalent to this legacy
+// configuration map.
+//
+// Note that all fields except TrafficServer are "shallow" copies, so modifying
+// the original will impact the upgraded copy.
+func (c *LegacyTrafficMonitorConfigMap) Upgrade() *TrafficMonitorConfigMap {
+	upgraded := TrafficMonitorConfigMap{
+		CacheGroup:      c.CacheGroup,
+		Config:          c.Config,
+		DeliveryService: c.DeliveryService,
+		Profile:         c.Profile,
+		TrafficMonitor:  c.TrafficMonitor,
+		TrafficServer:   make(map[string]TrafficServer, len(c.TrafficServer)),
+	}
+
+	for k, ts := range c.TrafficServer {
+		upgraded.TrafficServer[k] = ts.Upgrade()
+	}
+	return &upgraded
+}
+
+// TrafficMonitor is a structure containing enough information about a Traffic
+// Monitor instance for another Traffic Monitor to use it for quorums and peer
+// polling.
 type TrafficMonitor struct {
-	Port         int    `json:"port"`
-	IP6          string `json:"ip6"`
-	IP           string `json:"ip"`
-	HostName     string `json:"hostName"`
-	FQDN         string `json:"fqdn"`
-	Profile      string `json:"profile"`
-	Location     string `json:"location"`
+	// Port is the port on which the Traffic Monitor listens for HTTP requests.
+	Port int `json:"port"`
+	// IP6 is the Traffic Monitor's IPv6 address.
+	IP6 string `json:"ip6"`
+	// IP is the Traffic Monitor's IPv4 address.
+	IP string `json:"ip"`
+	// HostName is the Traffic Monitor's hostname.
+	HostName string `json:"hostName"`
+	// FQDN is the Fully Qualified Domain Name of the Traffic Monitor server.
+	FQDN string `json:"fqdn"`
+	// Profile is the Name of the Profile used by the Traffic Monitor.
+	Profile string `json:"profile"`
+	// Location is the Name of the Cache Group to which the Traffic Monitor
+	// belongs - called "Location" for legacy reasons.
+	Location string `json:"location"`
+	// ServerStatus is the Name of the Status of the Traffic Monitor.
 	ServerStatus string `json:"status"`
 }
 
-// TMCacheGroup ...
-// !!! Note the lowercase!!! this is local to this file, there's a CacheGroup definition in cachegroup.go!
+// TMCacheGroup contains all of the information about a Cache Group necessary
+// for Traffic Monitor to do its job of monitoring health and statistics.
 type TMCacheGroup struct {
 	Name        string                `json:"name"`
 	Coordinates MonitoringCoordinates `json:"coordinates"`
@@ -120,7 +266,9 @@ type MonitoringCoordinates struct {
 	Longitude float64 `json:"longitude"`
 }
 
-// TMDeliveryService ...
+// TMDeliveryService is all of the information about a Delivery Service
+// necessary for Traffic Monitor to do its job of monitoring health and
+// statistics.
 type TMDeliveryService struct {
 	XMLID              string `json:"xmlId"`
 	TotalTPSThreshold  int64  `json:"TotalTpsThreshold"`
@@ -128,14 +276,19 @@ type TMDeliveryService struct {
 	TotalKbpsThreshold int64  `json:"TotalKbpsThreshold"`
 }
 
-// TMProfile ...
+// TMProfile is primarily a collection of the Parameters with special meaning
+// to Traffic Monitor for a Profile of one of the monitored cache servers
+// and/or other Traffic Monitors, along with some identifying information for
+// the Profile.
 type TMProfile struct {
 	Parameters TMParameters `json:"parameters"`
 	Name       string       `json:"name"`
 	Type       string       `json:"type"`
 }
 
-// TMParameters ...
+// TMParameters is a structure containing all of the Parameters with special
+// meaning to Traffic Monitor. For specifics regarding each Parameter, refer to
+// the official documentation.
 // TODO change TO to return this struct, so a custom UnmarshalJSON isn't necessary.
 type TMParameters struct {
 	HealthConnectionTimeout int    `json:"health.connection.timeout"`
@@ -149,30 +302,47 @@ type TMParameters struct {
 
 const DefaultHealthThresholdComparator = "<"
 
+// HealthThreshold describes some value against which to compare health
+// measurements to determine if a cache server is healthy.
 type HealthThreshold struct {
-	Val        float64
+	// Val is the actual, numeric, threshold value.
+	Val float64
+	// Comparator is the comparator used to compare the Val to the monitored
+	// value. One of '=', '>', '<', '>=', or '<=' - other values are invalid.
 	Comparator string // TODO change to enum?
 }
 
-// strToThreshold takes a string like ">=42" and returns a HealthThreshold with a Val of `42` and a Comparator of `">="`. If no comparator exists, `DefaultHealthThresholdComparator` is used. If the string is not of the form "(>|<|)(=|)\d+" an error is returned
+// String implements the fmt.Stringer interface.
+func (t HealthThreshold) String() string {
+	return fmt.Sprintf("%s%f", t.Comparator, t.Val)
+}
+
+// strToThreshold takes a string like ">=42" and returns a HealthThreshold with
+// a Val of `42` and a Comparator of `">="`. If no comparator exists,
+// `DefaultHealthThresholdComparator` is used. If the string does not match
+// "(>|<|)(=|)\d+" an error is returned.
 func strToThreshold(s string) (HealthThreshold, error) {
-	comparators := []string{"=", ">", "<", ">=", "<="}
+	// The order of these is important - don't re-order without considering the
+	// consequences.
+	comparators := []string{">=", "<=", ">", "<", "="}
 	for _, comparator := range comparators {
 		if strings.HasPrefix(s, comparator) {
 			valStr := s[len(comparator):]
 			val, err := strconv.ParseFloat(valStr, 64)
 			if err != nil {
-				return HealthThreshold{}, fmt.Errorf("invalid threshold: NaN")
+				return HealthThreshold{}, fmt.Errorf("invalid threshold: NaN (%v)", err)
 			}
 			return HealthThreshold{Val: val, Comparator: comparator}, nil
 		}
 	}
 	val, err := strconv.ParseFloat(s, 64)
 	if err != nil {
-		return HealthThreshold{}, fmt.Errorf("invalid threshold: NaN")
+		return HealthThreshold{}, fmt.Errorf("invalid threshold: NaN (%v)", err)
 	}
 	return HealthThreshold{Val: val, Comparator: DefaultHealthThresholdComparator}, nil
 }
+
+// UnmarshalJSON implements the encoding/json.Unmarshaler interface.
 func (params *TMParameters) UnmarshalJSON(bytes []byte) (err error) {
 	raw := map[string]interface{}{}
 	if err := json.Unmarshal(bytes, &raw); err != nil {
@@ -219,14 +389,13 @@ func (params *TMParameters) UnmarshalJSON(bytes []byte) (err error) {
 		}
 	}
 
-	params.Thresholds = map[string]HealthThreshold{}
-	thresholdPrefix := "health.threshold."
+	params.Thresholds = make(map[string]HealthThreshold, len(raw))
 	for k, v := range raw {
-		if strings.HasPrefix(k, thresholdPrefix) {
-			stat := k[len(thresholdPrefix):]
+		if strings.HasPrefix(k, ThresholdPrefix) {
+			stat := k[len(ThresholdPrefix):]
 			vStr := fmt.Sprintf("%v", v) // allows string or numeric JSON types. TODO check if a type switch is faster.
 			if t, err := strToThreshold(vStr); err != nil {
-				return fmt.Errorf("Unmarshalling TMParameters `health.threshold.` parameter value not of the form `(>|)(=|)\\d+`: stat '%s' value '%v'", k, v)
+				return fmt.Errorf("Unmarshalling TMParameters `%s` parameter value not of the form `(>|)(=|)\\d+`: stat '%s' value '%v': %v", ThresholdPrefix, k, v, err)
 			} else {
 				params.Thresholds[stat] = t
 			}
@@ -238,12 +407,12 @@ func (params *TMParameters) UnmarshalJSON(bytes []byte) (err error) {
 func TrafficMonitorTransformToMap(tmConfig *TrafficMonitorConfig) (*TrafficMonitorConfigMap, error) {
 	var tm TrafficMonitorConfigMap
 
-	tm.TrafficServer = make(map[string]TrafficServer)
-	tm.CacheGroup = make(map[string]TMCacheGroup)
-	tm.Config = make(map[string]interface{})
-	tm.TrafficMonitor = make(map[string]TrafficMonitor)
-	tm.DeliveryService = make(map[string]TMDeliveryService)
-	tm.Profile = make(map[string]TMProfile)
+	tm.TrafficServer = make(map[string]TrafficServer, len(tmConfig.TrafficServers))
+	tm.CacheGroup = make(map[string]TMCacheGroup, len(tmConfig.CacheGroups))
+	tm.Config = make(map[string]interface{}, len(tmConfig.Config))
+	tm.TrafficMonitor = make(map[string]TrafficMonitor, len(tmConfig.TrafficMonitors))
+	tm.DeliveryService = make(map[string]TMDeliveryService, len(tmConfig.DeliveryServices))
+	tm.Profile = make(map[string]TMProfile, len(tmConfig.Profiles))
 
 	for _, trafficServer := range tmConfig.TrafficServers {
 		tm.TrafficServer[trafficServer.HostName] = trafficServer
@@ -271,44 +440,7 @@ func TrafficMonitorTransformToMap(tmConfig *TrafficMonitorConfig) (*TrafficMonit
 		tm.Profile[profile.Name] = profile
 	}
 
-	return &tm, MonitorConfigValid(&tm)
-}
-
-func MonitorConfigValid(cfg *TrafficMonitorConfigMap) error {
-	if cfg == nil {
-		return errors.New("MonitorConfig is nil")
-	}
-	if len(cfg.TrafficServer) == 0 {
-		return errors.New("MonitorConfig.TrafficServer empty (is the monitoring.json an empty object?)")
-	}
-	if len(cfg.CacheGroup) == 0 {
-		return errors.New("MonitorConfig.CacheGroup empty")
-	}
-	if len(cfg.TrafficMonitor) == 0 {
-		return errors.New("MonitorConfig.TrafficMonitor empty")
-	}
-	// TODO uncomment this, when TO is fixed to include DeliveryServices.
-	// See https://github.com/apache/trafficcontrol/issues/3528
-	// if len(cfg.DeliveryService) == 0 {
-	// 	return errors.New("MonitorConfig.DeliveryService empty")
-	// }
-	if len(cfg.Profile) == 0 {
-		return errors.New("MonitorConfig.Profile empty")
-	}
-
-	if intervalI, ok := cfg.Config["peers.polling.interval"]; !ok {
-		return errors.New(`MonitorConfig.Config["peers.polling.interval"] missing, peers.polling.interval parameter required`)
-	} else if _, ok := intervalI.(float64); !ok {
-		return fmt.Errorf(`MonitorConfig.Config["peers.polling.interval"] '%v' not a number, parameter peers.polling.interval must be a number`, intervalI)
-	}
-
-	if intervalI, ok := cfg.Config["health.polling.interval"]; !ok {
-		return errors.New(`MonitorConfig.Config["health.polling.interval"] missing, health.polling.interval parameter required`)
-	} else if _, ok := intervalI.(float64); !ok {
-		return fmt.Errorf(`MonitorConfig.Config["health.polling.interval"] '%v' not a number, parameter health.polling.interval must be a number`, intervalI)
-	}
-
-	return nil
+	return &tm, tm.Valid()
 }
 
 func LegacyTrafficMonitorTransformToMap(tmConfig *LegacyTrafficMonitorConfig) (*LegacyTrafficMonitorConfigMap, error) {
diff --git a/lib/go-tc/traffic_monitor_test.go b/lib/go-tc/traffic_monitor_test.go
index 01da451..3dc34e1 100644
--- a/lib/go-tc/traffic_monitor_test.go
+++ b/lib/go-tc/traffic_monitor_test.go
@@ -20,21 +20,23 @@ package tc
  */
 
 import (
+	"encoding/json"
+	"fmt"
 	"testing"
 )
 
-func TestMonitorConfigValid(t *testing.T) {
-	mc := (*TrafficMonitorConfigMap)(nil)
-	if MonitorConfigValid(mc) == nil {
+func TestLegacyMonitorConfigValid(t *testing.T) {
+	mc := (*LegacyTrafficMonitorConfigMap)(nil)
+	if LegacyMonitorConfigValid(mc) == nil {
 		t.Errorf("MonitorCopnfigValid(nil) expected: error, actual: nil")
 	}
-	mc = &TrafficMonitorConfigMap{}
-	if MonitorConfigValid(mc) == nil {
+	mc = &LegacyTrafficMonitorConfigMap{}
+	if LegacyMonitorConfigValid(mc) == nil {
 		t.Errorf("MonitorConfigValid({}) expected: error, actual: nil")
 	}
 
-	validMC := &TrafficMonitorConfigMap{
-		TrafficServer:   map[string]TrafficServer{"a": {}},
+	validMC := &LegacyTrafficMonitorConfigMap{
+		TrafficServer:   map[string]LegacyTrafficServer{"a": {}},
 		CacheGroup:      map[string]TMCacheGroup{"a": {}},
 		TrafficMonitor:  map[string]TrafficMonitor{"a": {}},
 		DeliveryService: map[string]TMDeliveryService{"a": {}},
@@ -44,33 +46,432 @@ func TestMonitorConfigValid(t *testing.T) {
 			"health.polling.interval": 24.0,
 		},
 	}
-	if err := MonitorConfigValid(validMC); err != nil {
+	if err := LegacyMonitorConfigValid(validMC); err != nil {
 		t.Errorf("MonitorConfigValid(%++v) expected: nil, actual: %+v", validMC, err)
 	}
 }
 
-func TestLegacyMonitorConfigValid(t *testing.T) {
-	mc := (*LegacyTrafficMonitorConfigMap)(nil)
-	if LegacyMonitorConfigValid(mc) == nil {
-		t.Errorf("MonitorCopnfigValid(nil) expected: error, actual: nil")
-	}
-	mc = &LegacyTrafficMonitorConfigMap{}
-	if LegacyMonitorConfigValid(mc) == nil {
-		t.Errorf("MonitorConfigValid({}) expected: error, actual: nil")
+func ExampleHealthThreshold_String() {
+	ht := HealthThreshold{Comparator: ">=", Val: 500}
+	fmt.Println(ht)
+	// Output: >=500.000000
+}
+
+func ExampleTMParameters_UnmarshalJSON() {
+	const data = `{
+		"health.connection.timeout": 5,
+		"health.polling.url": "https://example.com/",
+		"health.polling.format": "stats_over_http",
+		"history.count": 1,
+		"health.threshold.bandwidth": ">50",
+		"health.threshold.foo": "<=500"
+	}`
+
+	var params TMParameters
+	if err := json.Unmarshal([]byte(data), &params); err != nil {
+		fmt.Printf("Failed to unmarshal: %v\n", err)
+		return
 	}
+	fmt.Printf("timeout: %d\n", params.HealthConnectionTimeout)
+	fmt.Printf("url: %s\n", params.HealthPollingURL)
+	fmt.Printf("format: %s\n", params.HealthPollingFormat)
+	fmt.Printf("history: %d\n", params.HistoryCount)
+	fmt.Printf("# of Thresholds: %d - foo: %s, bandwidth: %s\n", len(params.Thresholds), params.Thresholds["foo"], params.Thresholds["bandwidth"])
 
-	validMC := &LegacyTrafficMonitorConfigMap{
-		TrafficServer:   map[string]LegacyTrafficServer{"a": {}},
-		CacheGroup:      map[string]TMCacheGroup{"a": {}},
-		TrafficMonitor:  map[string]TrafficMonitor{"a": {}},
+	// Output: timeout: 5
+	// url: https://example.com/
+	// format: stats_over_http
+	// history: 1
+	// # of Thresholds: 2 - foo: <=500.000000, bandwidth: >50.000000
+}
+
+func ExampleTrafficMonitorConfigMap_Valid() {
+	mc := &TrafficMonitorConfigMap{
+		CacheGroup: map[string]TMCacheGroup{"a": {}},
+		Config: map[string]interface{}{
+			"peers.polling.interval":  0.0,
+			"health.polling.interval": 0.0,
+		},
 		DeliveryService: map[string]TMDeliveryService{"a": {}},
 		Profile:         map[string]TMProfile{"a": {}},
+		TrafficMonitor:  map[string]TrafficMonitor{"a": {}},
+		TrafficServer:   map[string]TrafficServer{"a": {}},
+	}
+
+	fmt.Printf("Validity error: %v", mc.Valid())
+
+	// Output: Validity error: <nil>
+}
+
+func ExampleLegacyTrafficMonitorConfigMap_Upgrade() {
+	lcm := LegacyTrafficMonitorConfigMap{
+		CacheGroup: map[string]TMCacheGroup{
+			"test": {
+				Name: "test",
+				Coordinates: MonitoringCoordinates{
+					Latitude:  0,
+					Longitude: 0,
+				},
+			},
+		},
+		Config: map[string]interface{}{
+			"foo": "bar",
+		},
+		DeliveryService: map[string]TMDeliveryService{
+			"test": {
+				XMLID:              "test",
+				TotalTPSThreshold:  -1,
+				ServerStatus:       "testStatus",
+				TotalKbpsThreshold: -1,
+			},
+		},
+		Profile: map[string]TMProfile{
+			"test": {
+				Parameters: TMParameters{
+					HealthConnectionTimeout: -1,
+					HealthPollingURL:        "testURL",
+					HealthPollingFormat:     "astats",
+					HealthPollingType:       "http",
+					HistoryCount:            -1,
+					MinFreeKbps:             -1,
+					Thresholds: map[string]HealthThreshold{
+						"availableBandwidthInKbps": {
+							Comparator: "<",
+							Val:        -1,
+						},
+					},
+				},
+				Name: "test",
+				Type: "testType",
+			},
+		},
+		TrafficMonitor: map[string]TrafficMonitor{
+			"test": {
+				Port:         -1,
+				IP6:          "::1",
+				IP:           "0.0.0.0",
+				HostName:     "test",
+				FQDN:         "test.quest",
+				Profile:      "test",
+				Location:     "test",
+				ServerStatus: "testStatus",
+			},
+		},
+		TrafficServer: map[string]LegacyTrafficServer{
+			"test": {
+				CacheGroup:       "test",
+				DeliveryServices: []tsdeliveryService{},
+				FQDN:             "test.quest",
+				HashID:           "test",
+				HostName:         "test",
+				HTTPSPort:        -1,
+				InterfaceName:    "testInterface",
+				IP:               "0.0.0.1",
+				IP6:              "::2",
+				Port:             -1,
+				Profile:          "test",
+				ServerStatus:     "testStatus",
+				Type:             "testType",
+			},
+		},
+	}
+
+	cm := lcm.Upgrade()
+	fmt.Println("# of Cachegroups:", len(cm.CacheGroup))
+	fmt.Println("Cachegroup Name:", cm.CacheGroup["test"].Name)
+	fmt.Printf("Cachegroup Coordinates: (%v,%v)\n", cm.CacheGroup["test"].Coordinates.Latitude, cm.CacheGroup["test"].Coordinates.Longitude)
+	fmt.Println("# of Config parameters:", len(cm.Config))
+	fmt.Println(`Config["foo"]:`, cm.Config["foo"])
+	fmt.Println("# of DeliveryServices:", len(cm.DeliveryService))
+	fmt.Println("DeliveryService XMLID:", cm.DeliveryService["test"].XMLID)
+	fmt.Println("DeliveryService TotalTPSThreshold:", cm.DeliveryService["test"].TotalTPSThreshold)
+	fmt.Println("DeliveryService ServerStatus:", cm.DeliveryService["test"].ServerStatus)
+	fmt.Println("DeliveryService TotalKbpsThreshold:", cm.DeliveryService["test"].TotalKbpsThreshold)
+	fmt.Println("# of Profiles:", len(cm.Profile))
+	fmt.Println("Profile Name:", cm.Profile["test"].Name)
+	fmt.Println("Profile Type:", cm.Profile["test"].Type)
+	fmt.Println("Profile HealthConnectionTimeout:", cm.Profile["test"].Parameters.HealthConnectionTimeout)
+	fmt.Println("Profile HealthPollingURL:", cm.Profile["test"].Parameters.HealthPollingURL)
+	fmt.Println("Profile HealthPollingFormat:", cm.Profile["test"].Parameters.HealthPollingFormat)
+	fmt.Println("Profile HealthPollingType:", cm.Profile["test"].Parameters.HealthPollingType)
+	fmt.Println("Profile HistoryCount:", cm.Profile["test"].Parameters.HistoryCount)
+	fmt.Println("Profile MinFreeKbps:", cm.Profile["test"].Parameters.MinFreeKbps)
+	fmt.Println("# of Profile Thresholds:", len(cm.Profile["test"].Parameters.Thresholds))
+	fmt.Println("Profile availableBandwidthInKbps Threshold:", cm.Profile["test"].Parameters.Thresholds["availableBandwidthInKbps"])
+	fmt.Println("# of TrafficMonitors:", len(cm.TrafficMonitor))
+	fmt.Println("TrafficMonitor Port:", cm.TrafficMonitor["test"].Port)
+	fmt.Println("TrafficMonitor IP6:", cm.TrafficMonitor["test"].IP6)
+	fmt.Println("TrafficMonitor IP:", cm.TrafficMonitor["test"].IP)
+	fmt.Println("TrafficMonitor HostName:", cm.TrafficMonitor["test"].HostName)
+	fmt.Println("TrafficMonitor FQDN:", cm.TrafficMonitor["test"].FQDN)
+	fmt.Println("TrafficMonitor Profile:", cm.TrafficMonitor["test"].Profile)
+	fmt.Println("TrafficMonitor Location:", cm.TrafficMonitor["test"].Location)
+	fmt.Println("TrafficMonitor ServerStatus:", cm.TrafficMonitor["test"].ServerStatus)
+	fmt.Println("# of TrafficServers:", len(cm.TrafficServer))
+	fmt.Println("TrafficServer CacheGroup:", cm.TrafficServer["test"].CacheGroup)
+	fmt.Println("TrafficServer # of DeliveryServices:", len(cm.TrafficServer["test"].DeliveryServices))
+	fmt.Println("TrafficServer FQDN:", cm.TrafficServer["test"].FQDN)
+	fmt.Println("TrafficServer HashID:", cm.TrafficServer["test"].HashID)
+	fmt.Println("TrafficServer HostName:", cm.TrafficServer["test"].HostName)
+	fmt.Println("TrafficServer HTTPSPort:", cm.TrafficServer["test"].HTTPSPort)
+	fmt.Println("TrafficServer # of Interfaces:", len(cm.TrafficServer["test"].Interfaces))
+	fmt.Println("TrafficServer Interface Name:", cm.TrafficServer["test"].Interfaces[0].Name)
+	fmt.Println("TrafficServer # of Interface IP Addresses:", len(cm.TrafficServer["test"].Interfaces[0].IPAddresses))
+	fmt.Println("TrafficServer first IP Address:", cm.TrafficServer["test"].Interfaces[0].IPAddresses[0].Address)
+	fmt.Println("TrafficServer second IP Address:", cm.TrafficServer["test"].Interfaces[0].IPAddresses[1].Address)
+	fmt.Println("TrafficServer Port:", cm.TrafficServer["test"].Port)
+	fmt.Println("TrafficServer Profile:", cm.TrafficServer["test"].Profile)
+	fmt.Println("TrafficServer ServerStatus:", cm.TrafficServer["test"].ServerStatus)
+	fmt.Println("TrafficServer Type:", cm.TrafficServer["test"].Type)
+
+	// Output: # of Cachegroups: 1
+	// Cachegroup Name: test
+	// Cachegroup Coordinates: (0,0)
+	// # of Config parameters: 1
+	// Config["foo"]: bar
+	// # of DeliveryServices: 1
+	// DeliveryService XMLID: test
+	// DeliveryService TotalTPSThreshold: -1
+	// DeliveryService ServerStatus: testStatus
+	// DeliveryService TotalKbpsThreshold: -1
+	// # of Profiles: 1
+	// Profile Name: test
+	// Profile Type: testType
+	// Profile HealthConnectionTimeout: -1
+	// Profile HealthPollingURL: testURL
+	// Profile HealthPollingFormat: astats
+	// Profile HealthPollingType: http
+	// Profile HistoryCount: -1
+	// Profile MinFreeKbps: -1
+	// # of Profile Thresholds: 1
+	// Profile availableBandwidthInKbps Threshold: <-1.000000
+	// # of TrafficMonitors: 1
+	// TrafficMonitor Port: -1
+	// TrafficMonitor IP6: ::1
+	// TrafficMonitor IP: 0.0.0.0
+	// TrafficMonitor HostName: test
+	// TrafficMonitor FQDN: test.quest
+	// TrafficMonitor Profile: test
+	// TrafficMonitor Location: test
+	// TrafficMonitor ServerStatus: testStatus
+	// # of TrafficServers: 1
+	// TrafficServer CacheGroup: test
+	// TrafficServer # of DeliveryServices: 0
+	// TrafficServer FQDN: test.quest
+	// TrafficServer HashID: test
+	// TrafficServer HostName: test
+	// TrafficServer HTTPSPort: -1
+	// TrafficServer # of Interfaces: 1
+	// TrafficServer Interface Name: testInterface
+	// TrafficServer # of Interface IP Addresses: 2
+	// TrafficServer first IP Address: 0.0.0.1
+	// TrafficServer second IP Address: ::2
+	// TrafficServer Port: -1
+	// TrafficServer Profile: test
+	// TrafficServer ServerStatus: testStatus
+	// TrafficServer Type: testType
+}
+
+func TestTrafficMonitorConfigMap_Valid(t *testing.T) {
+	var mc *TrafficMonitorConfigMap = nil
+	err := mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of nil config map")
+	} else {
+		t.Logf("Got expected error: checking validity of nil config map: %v", err)
+	}
+	mc = &TrafficMonitorConfigMap{
+		CacheGroup: nil,
 		Config: map[string]interface{}{
 			"peers.polling.interval":  42.0,
 			"health.polling.interval": 24.0,
 		},
+		DeliveryService: map[string]TMDeliveryService{"a": {}},
+		Profile:         map[string]TMProfile{"a": {}},
+		TrafficMonitor:  map[string]TrafficMonitor{"a": {}},
+		TrafficServer:   map[string]TrafficServer{"a": {}},
 	}
-	if err := LegacyMonitorConfigValid(validMC); err != nil {
-		t.Errorf("MonitorConfigValid(%++v) expected: nil, actual: %+v", validMC, err)
+
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil CacheGroup")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil CacheGroup: %v", err)
+	}
+
+	mc.CacheGroup = map[string]TMCacheGroup{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with no CacheGroups")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with no CacheGroups: %v", err)
+	}
+
+	mc.CacheGroup["a"] = TMCacheGroup{}
+	mc.Config = nil
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil Config")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil Config: %v", err)
+	}
+
+	mc.Config = map[string]interface{}{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with empty Config")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with empty Config: %v", err)
+	}
+
+	mc.Config["peers.polling.interval"] = 42.0
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map without health.polling.interval")
+	} else {
+		t.Logf("Got expected error: checking validity of config map without health.polling.interval: %v", err)
+	}
+
+	delete(mc.Config, "peers.polling.interval")
+	mc.Config["health.polling.interval"] = 42.0
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map without peers.polling.interval")
+	} else {
+		t.Logf("Got expected error: checking validity of config map without peers.polling.interval: %v", err)
+	}
+
+	mc.Config["peers.polling.interval"] = 42.0
+	// TODO: uncomment these tests when #3528 is resolved
+	// mc.DeliveryService = nil
+	// err = mc.Valid()
+	// if err == nil {
+	// 	t.Error("Didn't get expected error checking validity of config map with nil DeliveryService")
+	// } else {
+	// 	t.Logf("Got expected error: checking validity of config map with nil DeliveryService: %v", err)
+	// }
+
+	// mc.DeliveryService = map[string]TMDeliveryService{}
+	// err = mc.Valid()
+	// if err == nil {
+	// 	t.Error("Didn't get expected error checking validity of config map with no DeliveryServices")
+	// } else {
+	// 	t.Logf("Got expected error: checking validity of config map with no DeliveryServices: %v", err)
+	// }
+
+	// mc.DeliveryService["a"] = TMDeliveryService{}
+	mc.Profile = nil
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil Profile")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil Profile: %v", err)
+	}
+
+	mc.Profile = map[string]TMProfile{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with no Profiles")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with no Profiles: %v", err)
+	}
+
+	mc.Profile["a"] = TMProfile{}
+	mc.TrafficMonitor = nil
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil TrafficMonitor")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil TrafficMonitor: %v", err)
+	}
+
+	mc.TrafficMonitor = map[string]TrafficMonitor{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with no TrafficMonitors")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with no TrafficMonitors: %v", err)
+	}
+
+	mc.TrafficMonitor["a"] = TrafficMonitor{}
+	mc.TrafficServer = nil
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with nil TrafficServer")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with nil TrafficServer: %v", err)
+	}
+
+	mc.TrafficServer = map[string]TrafficServer{}
+	err = mc.Valid()
+	if err == nil {
+		t.Error("Didn't get expected error checking validity of config map with no TrafficServers")
+	} else {
+		t.Logf("Got expected error: checking validity of config map with no TrafficServers: %v", err)
+	}
+}
+
+func TestTrafficMonitorTransformToMap(t *testing.T) {
+	tmc := TrafficMonitorConfig{
+		TrafficServers: []TrafficServer{
+			{
+				HostName: "testHostname",
+				Interfaces: []ServerInterfaceInfo{
+					{
+						Name: "testInterface",
+						IPAddresses: []ServerIPAddress{
+							{
+								Address:        "::1",
+								ServiceAddress: true,
+							},
+						},
+					},
+				},
+			},
+		},
+		CacheGroups: []TMCacheGroup{
+			TMCacheGroup{},
+		},
+		Config: map[string]interface{}{
+			"peers.polling.interval":  5.0,
+			"health.polling.interval": 5.0,
+		},
+		TrafficMonitors: []TrafficMonitor{
+			TrafficMonitor{},
+		},
+		DeliveryServices: []TMDeliveryService{},
+		Profiles: []TMProfile{
+			{
+				Name: "test",
+				Parameters: TMParameters{
+					Thresholds: map[string]HealthThreshold{
+						"availableBandwidthInKbps": {
+							Comparator: ">",
+							Val:        42,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	converted, err := TrafficMonitorTransformToMap(&tmc)
+	if err != nil {
+		t.Fatalf("Unexpected error converting valid TrafficMonitorConfig to map: %v", err)
 	}
-}
\ No newline at end of file
+	if converted == nil {
+		t.Fatal("Null map after conversion")
+	}
+
+	if len(converted.TrafficServer) != 1 {
+		t.Errorf("Incorrect number of traffic servers after conversion; expected: 1, got: %d", len(converted.TrafficServer))
+	}
+
+	if _, ok := converted.TrafficServer["testHostname"]; !ok {
+		t.Error("Expected server 'testHostname' to exist in map after conversion, but it didn't")
+	} else if len(converted.TrafficServer["testHostname"].Interfaces) != 1 {
+		t.Errorf("Incorrect number of interfaces on converted traffic server; expected: 1, got: %d", len(converted.TrafficServer["testHostname"].Interfaces))
+	} else if len(converted.TrafficServer["testHostname"].Interfaces[0].IPAddresses) != 1 {
+		t.Errorf("Incorrect number of IP addresses on converted traffic server's interface; expected: 1, got: %d", len(converted.TrafficServer["testHostname"].Interfaces[0].IPAddresses))
+	}
+}
diff --git a/lib/go-tc/traffic_router.go b/lib/go-tc/traffic_router.go
index 8d5bbe4..d58c181 100644
--- a/lib/go-tc/traffic_router.go
+++ b/lib/go-tc/traffic_router.go
@@ -20,10 +20,7 @@ package tc
  */
 
 import (
-	"net"
 	"time"
-
-	"github.com/apache/trafficcontrol/lib/go-log"
 )
 
 // SOA (Start of Authority record) defines the SOA record for the CDN's
@@ -135,58 +132,89 @@ type StaticDNS struct {
 
 // LegacyTrafficServer ...
 type LegacyTrafficServer struct {
-	Profile          string              `json:"profile"`
-	IP               string              `json:"ip"`
-	ServerStatus     string              `json:"status"`
 	CacheGroup       string              `json:"cacheGroup"`
-	IP6              string              `json:"ip6"`
-	Port             int                 `json:"port"`
-	HTTPSPort        int                 `json:"httpsPort,omitempty"`
-	HostName         string              `json:"hostName"`
+	DeliveryServices []tsdeliveryService `json:"deliveryServices,omitempty"` // the deliveryServices key does not exist on mids
 	FQDN             string              `json:"fqdn"`
+	HashID           string              `json:"hashId"`
+	HostName         string              `json:"hostName"`
+	HTTPSPort        int                 `json:"httpsPort,omitempty"`
 	InterfaceName    string              `json:"interfaceName"`
+	IP               string              `json:"ip"`
+	IP6              string              `json:"ip6"`
+	Port             int                 `json:"port"`
+	Profile          string              `json:"profile"`
+	ServerStatus     string              `json:"status"`
 	Type             string              `json:"type"`
-	HashID           string              `json:"hashId"`
-	DeliveryServices []tsdeliveryService `json:"deliveryServices,omitempty"` // the deliveryServices key does not exist on mids
 }
 
-// GetDefaultAddress returns the ipv4 and ipv6 service addresses of the interface.
-func (i *InterfaceInfo) GetDefaultAddress() (string, string) {
-	var ipv4 string
-	var ipv6 string
-	for _, ip := range i.IPAddresses {
-		if ip.ServiceAddress {
-			address, _, err := net.ParseCIDR(ip.Address)
-			if err != nil {
-				log.Warnf("Unable to parse ipaddress %v on interface %v: %v", ip.Address, i.Name, err)
-			} else if address == nil {
-				log.Warnf("Unable to parse ipaddress %v on interface %v", ip.Address, i.Name)
-				continue
-			}
-			if address.To4() != nil {
-				ipv4 = ip.Address
-			} else if address.To16() != nil {
-				ipv6 = ip.Address
-			} else {
-				log.Warnf("Invalid address %v on interface %v", address, i.Name)
-			}
-
-			if ipv4 != "" && ipv6 != "" {
-				break
-			}
+// Upgrade upgrades the LegacyTrafficServer into its modern-day equivalent.
+//
+// Note that the DeliveryServices slice is a "shallow" copy of the original, so
+// making changes to the original slice will affect the upgraded copy.
+func (s LegacyTrafficServer) Upgrade() TrafficServer {
+	upgraded := TrafficServer{
+		CacheGroup:       s.CacheGroup,
+		DeliveryServices: s.DeliveryServices,
+		FQDN:             s.FQDN,
+		HashID:           s.HashID,
+		HostName:         s.HostName,
+		HTTPSPort:        s.HTTPSPort,
+		Interfaces: []ServerInterfaceInfo{
+			{
+				MaxBandwidth: nil,
+				MTU:          nil,
+				Monitor:      false,
+				Name:         s.InterfaceName,
+				IPAddresses:  []ServerIPAddress{},
+			},
+		},
+		Port:         s.Port,
+		Profile:      s.Profile,
+		ServerStatus: s.ServerStatus,
+		Type:         s.Type,
+	}
+
+	if s.IP != "" {
+		upgraded.Interfaces[0] = ServerInterfaceInfo{
+			MaxBandwidth: nil,
+			MTU:          nil,
+			Monitor:      false,
+			Name:         s.InterfaceName,
+			IPAddresses: []ServerIPAddress{
+				{
+					Address:        s.IP,
+					Gateway:        nil,
+					ServiceAddress: true,
+				},
+			},
 		}
 	}
-	return ipv4, ipv6
+
+	if s.IP6 != "" {
+		upgraded.Interfaces[0] = ServerInterfaceInfo{
+			MaxBandwidth: nil,
+			MTU:          nil,
+			Monitor:      false,
+			Name:         s.InterfaceName,
+			IPAddresses: append(upgraded.Interfaces[0].IPAddresses, ServerIPAddress{
+				Address:        s.IP6,
+				Gateway:        nil,
+				ServiceAddress: true,
+			}),
+		}
+	}
+
+	return upgraded
 }
 
 // GetVIPInterface returns the primary interface specified by the `Monitor` property of an Interface. First interface marked as `Monitor` is returned.
-func GetVIPInterface(ts TrafficServer) InterfaceInfo {
+func GetVIPInterface(ts TrafficServer) ServerInterfaceInfo {
 	for _, interf := range ts.Interfaces {
 		if interf.Monitor {
 			return interf
 		}
 	}
-	return InterfaceInfo{}
+	return ServerInterfaceInfo{}
 }
 
 // ToLegacyServer converts a TrafficServer to LegacyTrafficServer.
@@ -211,35 +239,50 @@ func (ts *TrafficServer) ToLegacyServer() LegacyTrafficServer {
 	}
 }
 
-// TrafficServer ...
+// TrafficServer represents a cache server for use by Traffic Monitor and
+// Traffic Router instances.
 type TrafficServer struct {
-	Profile          string              `json:"profile"`
-	ServerStatus     string              `json:"status"`
-	CacheGroup       string              `json:"cacheGroup"`
-	Port             int                 `json:"port"`
-	HostName         string              `json:"hostName"`
-	FQDN             string              `json:"fqdn"`
-	Interfaces       []InterfaceInfo     `json:"interfaces"`
-	HTTPSPort        int                 `json:"httpsPort,omitempty"`
-	Type             string              `json:"type"`
-	HashID           string              `json:"hashId"`
-	DeliveryServices []tsdeliveryService `json:"deliveryServices,omitempty"` // the deliveryServices key does not exist on mids
+	CacheGroup       string                `json:"cacheGroup"`
+	DeliveryServices []tsdeliveryService   `json:"deliveryServices,omitempty"` // the deliveryServices key does not exist on mids
+	FQDN             string                `json:"fqdn"`
+	HashID           string                `json:"hashId"`
+	HostName         string                `json:"hostName"`
+	HTTPSPort        int                   `json:"httpsPort,omitempty"`
+	Interfaces       []ServerInterfaceInfo `json:"interfaces"`
+	Port             int                   `json:"port"`
+	Profile          string                `json:"profile"`
+	ServerStatus     string                `json:"status"`
+	Type             string                `json:"type"`
 }
 
-// ServerIPAddress is the data associated with a server's interface's IP address.
-type IPAddress struct {
-	Address        string  `json:"address" db:"address"`
-	Gateway        *string `json:"gateway" db:"gateway"`
-	ServiceAddress bool    `json:"serviceAddress" db:"service_address"`
+// IPv4 gets the server's IPv4 address if one exists, otherwise an empty
+// string.
+//
+// Note: This swallows errors from the legacy data conversion process.
+func (ts *TrafficServer) IPv4() string {
+	if ts == nil {
+		return ""
+	}
+	lid, err := InterfaceInfoToLegacyInterfaces(ts.Interfaces)
+	if err != nil || lid.IPAddress == nil {
+		return ""
+	}
+	return *lid.IPAddress
 }
 
-// ServerInterfaceInfo is the data associated with a server's interface.
-type InterfaceInfo struct {
-	IPAddresses  []IPAddress `json:"ipAddresses" db:"ip_addresses"`
-	MaxBandwidth *uint64     `json:"maxBandwidth" db:"max_bandwidth"`
-	Monitor      bool        `json:"monitor" db:"monitor"`
-	MTU          *uint64     `json:"mtu" db:"mtu"`
-	Name         string      `json:"name" db:"name"`
+// IPv6 gets the server's IPv6 address if one exists, otherwise an empty
+// string.
+//
+// Note: This swallows errors from the legacy data conversion process.
+func (ts *TrafficServer) IPv6() string {
+	if ts == nil {
+		return ""
+	}
+	lid, err := InterfaceInfoToLegacyInterfaces(ts.Interfaces)
+	if err != nil || lid.IP6Address == nil {
+		return ""
+	}
+	return *lid.IP6Address
 }
 
 type tsdeliveryService struct {
diff --git a/lib/go-tc/traffic_router_test.go b/lib/go-tc/traffic_router_test.go
new file mode 100644
index 0000000..ed8af64
--- /dev/null
+++ b/lib/go-tc/traffic_router_test.go
@@ -0,0 +1,137 @@
+package tc
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, 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.
+ */
+
+import "fmt"
+
+func ExampleTrafficServer_IPv4() {
+	server := TrafficServer{
+		Interfaces: []ServerInterfaceInfo{
+			{
+				IPAddresses: []ServerIPAddress{
+					{
+						Address:        "192.0.2.1",
+						ServiceAddress: true,
+					},
+					{
+						Address:        "192.0.2.2",
+						ServiceAddress: false,
+					},
+				},
+				Monitor: true,
+			},
+			{
+				IPAddresses: []ServerIPAddress{
+					{
+						Address:        "192.0.2.3",
+						ServiceAddress: false,
+					},
+				},
+				Monitor: true,
+			},
+		},
+	}
+
+	fmt.Println(server.IPv4())
+	// Output: 192.0.2.1
+}
+
+func ExampleTrafficServer_IPv6() {
+	server := TrafficServer{
+		Interfaces: []ServerInterfaceInfo{
+			{
+				IPAddresses: []ServerIPAddress{
+					{
+						Address:        "2001:DB8::1",
+						ServiceAddress: false,
+					},
+					{
+						Address:        "2001:DB8::2",
+						ServiceAddress: false,
+					},
+				},
+				Monitor: true,
+			},
+			{
+				IPAddresses: []ServerIPAddress{
+					{
+						Address:        "2001:DB8::3",
+						ServiceAddress: true,
+					},
+				},
+				Monitor: true,
+			},
+		},
+	}
+
+	fmt.Println(server.IPv6())
+	// Output: 2001:DB8::3
+}
+
+func ExampleLegacyTrafficServer_Upgrade() {
+	lts := LegacyTrafficServer{
+		CacheGroup:       "testCG",
+		DeliveryServices: []tsdeliveryService{},
+		FQDN:             "test.quest",
+		HashID:           "test",
+		HostName:         "test",
+		HTTPSPort:        -1,
+		InterfaceName:    "testInterface",
+		IP:               "198.0.2.0",
+		IP6:              "2001:DB8::1",
+		Port:             -1,
+		Profile:          "testProfile",
+		ServerStatus:     "testStatus",
+		Type:             "testType",
+	}
+
+	ts := lts.Upgrade()
+	fmt.Println("CacheGroup:", ts.CacheGroup)
+	fmt.Println("# of DeliveryServices:", len(ts.DeliveryServices))
+	fmt.Println("FQDN:", ts.FQDN)
+	fmt.Println("HashID:", ts.HashID)
+	fmt.Println("HostName:", ts.HostName)
+	fmt.Println("HTTPSPort:", ts.HTTPSPort)
+	fmt.Println("# of Interfaces:", len(ts.Interfaces))
+	fmt.Println("Interface Name:", ts.Interfaces[0].Name)
+	fmt.Println("# of Interface IP Addresses:", len(ts.Interfaces[0].IPAddresses))
+	fmt.Println("first IP Address:", ts.Interfaces[0].IPAddresses[0].Address)
+	fmt.Println("second IP Address:", ts.Interfaces[0].IPAddresses[1].Address)
+	fmt.Println("Port:", ts.Port)
+	fmt.Println("Profile:", ts.Profile)
+	fmt.Println("ServerStatus:", ts.ServerStatus)
+	fmt.Println("Type:", ts.Type)
+
+	// Output: CacheGroup: testCG
+	// # of DeliveryServices: 0
+	// FQDN: test.quest
+	// HashID: test
+	// HostName: test
+	// HTTPSPort: -1
+	// # of Interfaces: 1
+	// Interface Name: testInterface
+	// # of Interface IP Addresses: 2
+	// first IP Address: 198.0.2.0
+	// second IP Address: 2001:DB8::1
+	// Port: -1
+	// Profile: testProfile
+	// ServerStatus: testStatus
+	// Type: testType
+}
diff --git a/traffic_monitor/cache/astats.go b/traffic_monitor/cache/astats.go
index 09c1980..fe33dcf 100644
--- a/traffic_monitor/cache/astats.go
+++ b/traffic_monitor/cache/astats.go
@@ -43,7 +43,6 @@ import (
 )
 
 func init() {
-	// AddStatsType("astats", astatsParse, astatsPrecompute)
 	registerDecoder("astats", astatsParse, astatsPrecompute)
 }
 
diff --git a/traffic_monitor/cache/cache.go b/traffic_monitor/cache/cache.go
index afe0c36..1752828 100644
--- a/traffic_monitor/cache/cache.go
+++ b/traffic_monitor/cache/cache.go
@@ -68,20 +68,6 @@ type PrecomputedData struct {
 	Time      time.Time
 }
 
-// Result is the data result returned by a cache.
-// type Result struct {
-// 	ID              string// 	Error           error
-// 	Astats          Astats
-// 	Time            time.Time
-// 	RequestTime     time.Duration
-// 	Vitals          Vitals
-// 	PollID          uint64
-// 	UsingIPv4       bool
-// 	PollFinished    chan<- uint64
-// 	PrecomputedData PrecomputedData
-// 	Available       bool
-// }
-
 // Result is a result of polling a cache server for statistics.
 type Result struct {
 	// Available indicates whether or not the cache server should be considered
@@ -118,6 +104,8 @@ type Result struct {
 	UsingIPv4 bool
 	// Vitals holds the parsed health information returned by the cache server.
 	Vitals Vitals
+	// InterfaceVitals holds the parsed health information returned by the cache server per interface.
+	InterfaceVitals map[string]Vitals
 }
 
 // HasStat returns whether the given stat is in the Result.
@@ -132,16 +120,17 @@ func (result *Result) HasStat(stat string) bool {
 	return false
 }
 
-// Returns the names of all interfaces including the aggregate
+// InterfaceNames returns the names of all network interfaces used by the cache
+// server that was monitored to obtain the result.
 func (result *Result) InterfacesNames() []string {
-	interfaceNames := []string{tc.CacheInterfacesAggregate}
+	interfaceNames := make([]string, 0, len(result.Statistics.Interfaces))
 	for name, _ := range result.Statistics.Interfaces {
 		interfaceNames = append(interfaceNames, name)
 	}
 	return interfaceNames
 }
 
-// Returns the interfaces assigned to this result
+// Interfaces returns the interfaces assigned to this result.
 func (result *Result) Interfaces() map[string]Interface {
 	return result.Statistics.Interfaces
 }
@@ -156,77 +145,98 @@ type Vitals struct {
 	MaxKbpsOut int64
 }
 
-// Stat is a generic stat, including the untyped value and the time the stat was taken.
+// Stat is a generic stat, including the untyped value and the time the stat was
+// taken.
 type Stat struct {
 	Time  int64       `json:"time"`
 	Value interface{} `json:"value"`
 }
 
-// Stats is designed for returning via the API. It contains result history for each cache, as well as common API data.
+// ServerStats is a representation of cache server statistics as present in the
+// TM API.
+type ServerStats struct {
+	// Interfaces contains statistics specific to each monitored interface
+	// of the cache server.
+	Interfaces map[string]map[string][]ResultStatVal `json:"interfaces"`
+	// Stats contains statistics regarding the cache server in general.
+	Stats map[string][]ResultStatVal `json:"stats"`
+}
+
+// Stats is designed for returning via the API. It contains result history
+// for each cache, as well as common API data.
 type Stats struct {
 	srvhttp.CommonAPIData
-	Caches map[tc.CacheName]map[string]map[string][]ResultStatVal `json:"caches"`
+	// Caches is a map of cache server hostnames to groupings of statistics
+	// regarding each cache server and all of its separate network interfaces.
+	Caches map[string]ServerStats `json:"caches"`
 }
 
 // Filter filters whether stats and caches should be returned from a data set.
 type Filter interface {
-	UseStat(name string) bool
-	UseCache(name tc.CacheName) bool
-	WithinStatHistoryMax(int) bool
+	UseCache(tc.CacheName) bool
+	UseInterfaceStat(string) bool
+	UseStat(string) bool
+	WithinStatHistoryMax(uint64) bool
 }
 
 const nsPerMs = 1000000
 
-type StatComputeFunc func(resultInfo ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{}
+// StatComputeFunc functions calculate a specific statistic given a set of
+// polling results, server and profile information, whether or not the server
+// is available, and the name of the specific network interface for which stats
+// will be computed.
+type StatComputeFunc func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{}
 
-// ComputedStats returns a map of cache stats which are computed by Traffic Monitor (rather than returned literally from ATS), mapped to the func to compute them.
+// ComputedStats returns a map of cache stats which are computed by Traffic
+// Monitor (rather than returned literally from ATS), mapped to the function to
+// compute them.
 func ComputedStats() map[string]StatComputeFunc {
 	return map[string]StatComputeFunc{
-		"availableBandwidthInKbps": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"availableBandwidthInKbps": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
 			return info.Vitals.MaxKbpsOut - info.Vitals.KbpsOut
 		},
-		"availableBandwidthInMbps": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
-			return (info.Vitals.MaxKbpsOut - info.Vitals.KbpsOut) / 1000
+		"availableBandwidthInMbps": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
+			return (info.Vitals.MaxKbpsOut - info.Vitals.KbpsOut) / 1000.0
 		},
-		"bandwidth": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"bandwidth": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
 			return info.Vitals.KbpsOut
 		},
-		"error-string": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
-			if info.Error != nil {
-				return info.Error.Error()
-			}
-			return "false"
-		},
-		"isAvailable": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
-			return combinedState // if the cache is missing, default to false
-		},
-		"isHealthy": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
-			if tc.CacheStatusFromString(serverInfo.ServerStatus) == tc.CacheStatusAdminDown {
-				return true
-			}
-			return combinedState.IsAvailable
-		},
-		"kbps": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"kbps": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
 			return info.Vitals.KbpsOut
 		},
-		"gbps": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"gbps": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
 			return float64(info.Vitals.KbpsOut) / 1000000.0
 		},
-		"loadavg": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
-			return info.Vitals.LoadAvg
-		},
-		"maxKbps": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"maxKbps": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
 			return info.Vitals.MaxKbpsOut
 		},
-		"queryTime": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"loadavg": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
+			return info.Vitals.LoadAvg
+		},
+		"queryTime": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
 			return info.RequestTime.Nanoseconds() / nsPerMs
 		},
-		"stateUrl": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"stateUrl": func(_ ResultInfo, _ tc.TrafficServer, serverProfile tc.TMProfile, _ tc.IsAvailable) interface{} {
 			return serverProfile.Parameters.HealthPollingURL
 		},
-		"status": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"status": func(_ ResultInfo, serverInfo tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
 			return serverInfo.ServerStatus
 		},
+		"error-string": func(info ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, _ tc.IsAvailable) interface{} {
+			if info.Error != nil {
+				return info.Error.Error()
+			}
+			return "false"
+		},
+		"isAvailable": func(_ ResultInfo, _ tc.TrafficServer, _ tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+			return combinedState // if the cache is missing, default to false
+		},
+		"isHealthy": func(_ ResultInfo, serverInfo tc.TrafficServer, _ tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+			if tc.CacheStatusFromString(serverInfo.ServerStatus) == tc.CacheStatusAdminDown {
+				return true
+			}
+			return combinedState.IsAvailable
+		},
 
 		// These are back-up values for when a statistics format doesn't
 		// support reporting these stats - which would make sense because five
@@ -234,43 +244,43 @@ func ComputedStats() map[string]StatComputeFunc {
 		// sure what the rest of them are even for. None of these are
 		// documented anywhere. The values in comments are the ones that astats
 		// parsers will give back (because it won't get this far).
-		"system.astatsLoad": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.astatsLoad": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.AstatsLoad
 			return float64(0)
 		},
-		"system.configReloadRequests": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.configReloadRequests": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.ConfigLoadRequest
 			return float64(0)
 		},
-		"system.configReloads": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.configReloads": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.ConfigReloads
 			return float64(0)
 		},
-		"system.inf.name": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.inf.name": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.InfName
 			return ""
 		},
-		"system.inf.speed": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.inf.speed": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.InfSpeed
 			return float64(0)
 		},
-		"system.lastReload": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.lastReload": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.LastReload
 			return float64(0)
 		},
-		"system.lastReloadRequest": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.lastReloadRequest": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.LastReloadRequest
 			return ""
 		},
-		"system.notAvailable": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.notAvailable": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.NotAvailable
 			return ""
 		},
-		"system.proc.loadavg": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.proc.loadavg": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.ProcLoadavg
 			return float64(0)
 		},
-		"system.proc.net.dev": func(info ResultInfo, serverInfo tc.LegacyTrafficServer, serverProfile tc.TMProfile, combinedState tc.IsAvailable) interface{} {
+		"system.proc.net.dev": func(ResultInfo, tc.TrafficServer, tc.TMProfile, tc.IsAvailable) interface{} {
 			// return info.System.ProcNetDev
 			return float64(0)
 		},
diff --git a/traffic_monitor/cache/cache_test.go b/traffic_monitor/cache/cache_test.go
index 67e1a39..b20a392 100644
--- a/traffic_monitor/cache/cache_test.go
+++ b/traffic_monitor/cache/cache_test.go
@@ -23,6 +23,7 @@ import (
 	"testing"
 
 	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
 	"github.com/apache/trafficcontrol/traffic_monitor/todata"
 )
 
@@ -50,20 +51,47 @@ func (f DummyFilterNever) WithinStatHistoryMax(i int) bool {
 	return false
 }
 
+var AggregatedStats = Vitals{
+	KbpsOut:    1500000,
+	MaxKbpsOut: 2500000,
+	LoadAvg:    100000,
+}
+
+var StatToDesiredValue = map[string]float64{
+	"availableBandwidthInKbps": 1000000,
+	"availableBandwidthInMbps": 1000,
+	"bandwidth":                1500000,
+	"kbps":                     1500000,
+	"gbps":                     1.5,
+	"loadavg":                  100000,
+	"maxKbps":                  2500000,
+}
+
+func TestComputeAggregateStats(t *testing.T) {
+	computedStats := ComputedStats()
+
+	for stat, want := range StatToDesiredValue {
+		got := computedStats[stat](ResultInfo{Vitals: AggregatedStats}, tc.TrafficServer{}, tc.TMProfile{}, tc.IsAvailable{})
+		if numGot, ok := util.ToNumeric(got); ok {
+			if numGot != want {
+				t.Errorf("ComputedStats[\"%v\"] return %v instead of %v", stat, got, want)
+			}
+		} else {
+			t.Errorf(`ComputedStats["%s"] returned non-numeric value: %v`, stat, got)
+		}
+	}
+}
+
 func TestComputeStatGbps(t *testing.T) {
-	serverInfo := tc.LegacyTrafficServer{}
+	serverInfo := tc.TrafficServer{}
 	serverProfile := tc.TMProfile{}
 	combinedState := tc.IsAvailable{}
 	computedStats := ComputedStats()
-	got := computedStats["gbps"](ResultInfo{Vitals: Vitals{KbpsOut: 1500000}}, serverInfo, serverProfile, combinedState)
-	want := 1.5
-	if got != want {
-		t.Errorf("ComputedStats[\"gbps\"] return %v instead of %v", got, want)
-	}
 
-	got = computedStats["gbps"](ResultInfo{Vitals: Vitals{KbpsOut: 1400000}}, serverInfo, serverProfile, combinedState)
-	want = 1.4
-	if got != want {
-		t.Errorf("ComputedStats[\"gbps\"] return %v instead of %v", got, want)
+	for stat, want := range StatToDesiredValue {
+		got := computedStats[stat](ResultInfo{Vitals: AggregatedStats}, serverInfo, serverProfile, combinedState)
+		if numGot, ok := util.ToNumeric(got); ok && numGot != want {
+			t.Errorf("ComputedStats[\"%v\"] return %v instead of %v", stat, got, want)
+		}
 	}
 }
diff --git a/traffic_monitor/cache/data.go b/traffic_monitor/cache/data.go
index 84a6b08..664501f 100644
--- a/traffic_monitor/cache/data.go
+++ b/traffic_monitor/cache/data.go
@@ -75,18 +75,14 @@ type AvailableStatus struct {
 }
 
 // CacheAvailableStatuses is the available status of each cache.
-type AvailableStatuses map[tc.CacheName]map[string]AvailableStatus
+type AvailableStatuses map[string]AvailableStatus
 
 // Copy copies this CacheAvailableStatuses. It does not modify, and thus is
 // safe for multiple reader goroutines.
 func (a AvailableStatuses) Copy() AvailableStatuses {
-	b := AvailableStatuses(map[tc.CacheName]map[string]AvailableStatus{})
-	for cacheName, interfaces := range a {
-		interfaceStats := make(map[string]AvailableStatus)
-		for interfaceName, status := range interfaces {
-			interfaceStats[interfaceName] = status
-		}
-		b[cacheName] = interfaceStats
+	b := AvailableStatuses(make(map[string]AvailableStatus, len(a)))
+	for cacheName, status := range a {
+		b[cacheName] = status
 	}
 	return b
 }
@@ -171,29 +167,33 @@ type ResultInfoHistory map[tc.CacheName][]ResultInfo
 // ResultInfo contains all the non-stat result info. This includes the cache ID,
 // any errors, the time of the poll, the request time duration, Astats System
 // (Vitals), Poll ID, and Availability.
+// TODO: Determine why this exists, it doesn't seem to differ from Result in any
+// meaningful way.
 type ResultInfo struct {
-	Available   bool
-	Error       error
-	ID          string
-	PollID      uint64
-	RequestTime time.Duration
-	Statistics  Statistics
-	Time        time.Time
-	UsingIPv4   bool
-	Vitals      Vitals
+	Available       bool
+	Error           error
+	ID              string
+	PollID          uint64
+	RequestTime     time.Duration
+	Statistics      Statistics
+	Time            time.Time
+	UsingIPv4       bool
+	Vitals          Vitals
+	InterfaceVitals map[string]Vitals
 }
 
 func ToInfo(r Result) ResultInfo {
 	return ResultInfo{
-		Available:   r.Available,
-		Error:       r.Error,
-		ID:          r.ID,
-		PollID:      r.PollID,
-		RequestTime: r.RequestTime,
-		Statistics:  r.Statistics,
-		Time:        r.Time,
-		UsingIPv4:   r.UsingIPv4,
-		Vitals:      r.Vitals,
+		Available:       r.Available,
+		Error:           r.Error,
+		ID:              r.ID,
+		PollID:          r.PollID,
+		RequestTime:     r.RequestTime,
+		Statistics:      r.Statistics,
+		Time:            r.Time,
+		UsingIPv4:       r.UsingIPv4,
+		Vitals:          r.Vitals,
+		InterfaceVitals: r.InterfaceVitals,
 	}
 }
 
@@ -222,9 +222,10 @@ func (a ResultInfoHistory) Add(r Result, limit uint64) {
 	a[tc.CacheName(r.ID)] = pruneInfos(append([]ResultInfo{ToInfo(r)}, a[tc.CacheName(r.ID)]...), limit)
 }
 
-// Kbpses is the kbps values of each cache.
-type Kbpses map[tc.CacheName]int64
+// Kbpses is the kbps values of each interface of a cache server.
+type Kbpses map[string]uint64
 
+// Copy returns a deep copy of the Kbpses.
 func (a Kbpses) Copy() Kbpses {
 	b := Kbpses{}
 	for k, v := range a {
@@ -232,7 +233,3 @@ func (a Kbpses) Copy() Kbpses {
 	}
 	return b
 }
-
-func (a Kbpses) AddMax(r Result) {
-	a[tc.CacheName(r.ID)] = r.PrecomputedData.MaxKbps
-}
diff --git a/traffic_monitor/cache/data_test.go b/traffic_monitor/cache/data_test.go
index 9f6f47a..9301327 100644
--- a/traffic_monitor/cache/data_test.go
+++ b/traffic_monitor/cache/data_test.go
@@ -49,9 +49,14 @@ func randAvailableStatuses() AvailableStatuses {
 	a := AvailableStatuses{}
 	num := 100
 	for i := 0; i < num; i++ {
-		cacheName := tc.CacheName(randStr())
-		a[cacheName] = make(map[string]AvailableStatus)
-		a[cacheName][randStr()] = AvailableStatus{Available: AvailableTuple{randBool(), randBool()}, Status: randStr()}
+		cacheName := randStr()
+		a[cacheName] = AvailableStatus{
+			Available: AvailableTuple{
+				IPv4: randBool(),
+				IPv6: randBool(),
+			},
+			Status: randStr(),
+		}
 	}
 	return a
 }
@@ -243,10 +248,15 @@ func TestAvailableStatusesCopy(t *testing.T) {
 			t.Errorf("expected a and b DeepEqual, actual copied map not equal: a: %v b: %v", a, b)
 		}
 
-		cacheName := tc.CacheName(randStr())
-		a[cacheName] = make(map[string]AvailableStatus)
-		// verify a and b don't point to the same map
-		a[cacheName][randStr()] = AvailableStatus{Available: AvailableTuple{randBool(), randBool()}, Status: randStr()}
+		cacheName := randStr()
+		a[cacheName] = AvailableStatus{
+			Available: AvailableTuple{
+				randBool(),
+				randBool(),
+			},
+			Status: randStr(),
+		}
+
 		if reflect.DeepEqual(a, b) {
 			t.Errorf("expected a != b, actual a and b point to the same map: a: %+v", a)
 		}
diff --git a/traffic_monitor/cache/stats_over_http.go b/traffic_monitor/cache/stats_over_http.go
index e0d9af7..e2cf3a8 100644
--- a/traffic_monitor/cache/stats_over_http.go
+++ b/traffic_monitor/cache/stats_over_http.go
@@ -354,7 +354,10 @@ func statsOverHTTPPrecompute(cacheName string, data todata.TOData, stats Statist
 			}
 
 			dsName := string(ds)
-			dsStat := precomputed.DeliveryServiceStats[dsName]
+			dsStat, ok := precomputed.DeliveryServiceStats[dsName]
+			if !ok || dsStat == nil {
+				dsStat = new(DSStat)
+			}
 
 			parsedStat, err := parseNumericStat(value)
 			if err != nil {
diff --git a/traffic_monitor/datareq/bandwidthcapacity.go b/traffic_monitor/datareq/bandwidthcapacity.go
index 358e3e2..bd70da8 100644
--- a/traffic_monitor/datareq/bandwidthcapacity.go
+++ b/traffic_monitor/datareq/bandwidthcapacity.go
@@ -20,16 +20,15 @@
 package datareq
 
 import (
-	"fmt"
+	"strconv"
 
 	"github.com/apache/trafficcontrol/traffic_monitor/threadsafe"
 )
 
 func srvAPIBandwidthCapacityKbps(statMaxKbpses threadsafe.CacheKbpses) []byte {
-	maxKbpses := statMaxKbpses.Get()
-	cap := int64(0)
-	for _, kbps := range maxKbpses {
-		cap += kbps
+	var capacity uint64 = 0
+	for _, kbps := range statMaxKbpses.Get() {
+		capacity += kbps
 	}
-	return []byte(fmt.Sprintf("%d", cap))
+	return []byte(strconv.FormatUint(capacity, 10))
 }
diff --git a/traffic_monitor/datareq/cachedowncount.go b/traffic_monitor/datareq/cachedowncount.go
index f603e64..a86a815 100644
--- a/traffic_monitor/datareq/cachedowncount.go
+++ b/traffic_monitor/datareq/cachedowncount.go
@@ -32,7 +32,7 @@ func srvAPICacheDownCount(localStates peer.CRStatesThreadsafe, monitorConfig thr
 }
 
 // cacheOfflineCount returns the total reported caches marked down, excluding status offline and admin_down.
-func cacheDownCount(caches map[tc.CacheName]tc.IsAvailable, toServers map[string]tc.LegacyTrafficServer) int {
+func cacheDownCount(caches map[tc.CacheName]tc.IsAvailable, toServers map[string]tc.TrafficServer) int {
 	count := 0
 	for cache, available := range caches {
 		if !available.IsAvailable && tc.CacheStatusFromString(toServers[string(cache)].ServerStatus) == tc.CacheStatusReported {
diff --git a/traffic_monitor/datareq/cachestate.go b/traffic_monitor/datareq/cachestate.go
index d266fed..9ce1dbb 100644
--- a/traffic_monitor/datareq/cachestate.go
+++ b/traffic_monitor/datareq/cachestate.go
@@ -26,7 +26,6 @@ import (
 	"github.com/apache/trafficcontrol/lib/go-log"
 	"github.com/apache/trafficcontrol/lib/go-tc"
 	"github.com/apache/trafficcontrol/traffic_monitor/cache"
-	"github.com/apache/trafficcontrol/traffic_monitor/ds"
 	"github.com/apache/trafficcontrol/traffic_monitor/dsdata"
 	"github.com/apache/trafficcontrol/traffic_monitor/peer"
 	"github.com/apache/trafficcontrol/traffic_monitor/threadsafe"
@@ -35,20 +34,30 @@ import (
 	jsoniter "github.com/json-iterator/go"
 )
 
+// NotFoundStatus is the status value of an interface that has not been found in
+// the polled interface data.
+const NotFoundStatus = "unavailable - interface not found"
+
 // CacheStatus contains summary stat data about the given cache.
-// TODO make fields nullable, so error fields can be omitted, letting API callers still get updates for unerrored fields
 type CacheStatus struct {
 	Type        *string  `json:"type,omitempty"`
 	LoadAverage *float64 `json:"load_average,omitempty"`
-	// QueryTimeMilliseconds is the time it took this app to perform a complete query and process the data, end-to-end, for the latest health query.
+	// QueryTimeMilliseconds is the time it took this app to perform a complete
+	// query and process the data, end-to-end, for the latest health query.
 	QueryTimeMilliseconds *int64 `json:"query_time_ms,omitempty"`
-	// HealthTimeMilliseconds is the time it took to make the HTTP request and get back the full response, for the latest health query.
+	// HealthTimeMilliseconds is the time it took to make the HTTP request and
+	// get back the full response, for the latest health query.
 	HealthTimeMilliseconds *int64 `json:"health_time_ms,omitempty"`
-	// StatTimeMilliseconds is the time it took to make the HTTP request and get back the full response, for the latest stat query.
+	// StatTimeMilliseconds is the time it took to make the HTTP request and get
+	// back the full response, for the latest stat query.
 	StatTimeMilliseconds *int64 `json:"stat_time_ms,omitempty"`
-	// StatSpanMilliseconds is the length of time between completing the most recent two stat queries. This can be used as a rough gauge of the end-to-end query processing time.
+	// StatSpanMilliseconds is the length of time between completing the most
+	// recent two stat queries. This can be used as a rough gauge of the
+	// end-to-end query processing time.
 	StatSpanMilliseconds *int64 `json:"stat_span_ms,omitempty"`
-	// HealthSpanMilliseconds is the length of time between completing the most recent two health queries. This can be used as a rough gauge of the end-to-end query processing time.
+	// HealthSpanMilliseconds is the length of time between completing the most
+	// recent two health queries. This can be used as a rough gauge of the
+	// end-to-end query processing time.
 	HealthSpanMilliseconds *int64 `json:"health_span_ms,omitempty"`
 
 	Status                *string  `json:"status,omitempty"`
@@ -63,15 +72,13 @@ type CacheStatus struct {
 	Interfaces *map[string]CacheInterfaceStatus `json:"interfaces,omitempty"`
 }
 
+// CacheInterfaceStatus represents the status of a single network interface of a
+// cache server.
 type CacheInterfaceStatus struct {
-	Status                *string  `json:"status,omitempty"`
-	StatusPoller          *string  `json:"status_poller,omitempty"`
-	BandwidthKbps         *float64 `json:"bandwidth_kbps,omitempty"`
-	BandwidthCapacityKbps *float64 `json:"bandwidth_capacity_kbps,omitempty"`
-	ConnectionCount       *int64   `json:"connection_count,omitempty"`
-	IPv4Available         *bool    `json:"ipv4_available,omitempty"`
-	IPv6Available         *bool    `json:"ipv6_available,omitempty"`
-	CombinedAvailable     *bool    `json:"combined_available,omitempty"`
+	Status        string  `json:"status"`
+	StatusPoller  string  `json:"status_poller"`
+	BandwidthKbps float64 `json:"bandwidth_kbps"`
+	Available     bool    `json:"available"`
 }
 
 func srvAPICacheStates(
@@ -90,6 +97,27 @@ func srvAPICacheStates(
 	return json.Marshal(createCacheStatuses(toData.Get().ServerTypes, statInfoHistory.Get(), statResultHistory, healthHistory.Get(), lastHealthDurations.Get(), localStates.Get().Caches, lastStats.Get(), localCacheStatus, statMaxKbpses, monitorConfig.Get().TrafficServer))
 }
 
+// interfaceStatus returns the status of the given interface, both qualitatively
+// as a human-readable string and as a boolean indicating its availability.
+func interfaceStatus(inf tc.ServerInterfaceInfo, result cache.Result) (string, bool) {
+	var cacheError = ""
+	if result.Error != nil {
+		cacheError = "; " + result.Error.Error()
+	}
+	vitalsMap := result.InterfaceVitals
+	vitals, ok := vitalsMap[inf.Name]
+	if !ok {
+		return "not found in health polling data" + cacheError, false
+	}
+	if inf.MaxBandwidth != nil && *inf.MaxBandwidth < uint64(vitals.KbpsOut) {
+		return "maximum bandwidth exceeded" + cacheError, false
+	}
+	return "available", true
+}
+
+// createCacheStatuses builds a map of cache server hostnames to their
+// respective status by examining the calculated availability and statistics of
+// each cache server and its network interfaces.
 func createCacheStatuses(
 	cacheTypes map[tc.CacheName]tc.CacheType,
 	statInfoHistory cache.ResultInfoHistory,
@@ -100,70 +128,83 @@ func createCacheStatuses(
 	lastStats dsdata.LastStats,
 	localCacheStatusThreadsafe threadsafe.CacheAvailableStatus,
 	statMaxKbpses threadsafe.CacheKbpses,
-	servers map[string]tc.LegacyTrafficServer,
-) map[tc.CacheName]CacheStatus {
+	servers map[string]tc.TrafficServer,
+) map[string]CacheStatus {
 	conns := createCacheConnections(statResultHistory)
-	statii := map[tc.CacheName]CacheStatus{}
+	statii := make(map[string]CacheStatus, len(servers))
 	localCacheStatus := localCacheStatusThreadsafe.Get().Copy() // TODO test whether copy is necessary
 	maxKbpses := statMaxKbpses.Get()
 
-	for cacheNameStr, serverInfo := range servers {
-		cacheName := tc.CacheName(cacheNameStr)
-		interfaceStatus := make(map[string]CacheInterfaceStatus)
+	for cacheName, serverInfo := range servers {
+		interfaceStatuses := make(map[string]CacheInterfaceStatus, len(serverInfo.Interfaces))
 
-		totalKbps := float64(0)
-		totalMaxKbps := float64(0)
-		totalConnections := int64(0)
-		for interfaceName, _ := range localCacheStatus[cacheName] {
-			if interfaceName == tc.CacheInterfacesAggregate {
-				continue
-			}
-			status, statusPoller, ipv4, ipv6, combinedStatus := cacheStatusAndPoller(cacheName, interfaceName, serverInfo, localCacheStatus)
-			var kbps float64
-			if lastStat, ok := lastStats.Caches[cacheName]; !ok {
-				log.Infof("cache not in last kbps cache %s\n", cacheName)
-			} else {
-				kbps = lastStat.Bytes.PerSec / float64(ds.BytesPerKilobit)
-				totalKbps += kbps
-			}
+		var totalMaxKbps float64 = 0
+		maxKbps, maxKbpsOk := maxKbpses[cacheName]
+		if !maxKbpsOk {
+			log.Infof("Cache server '%s' not in max kbps cache", cacheName)
+		} else {
+			totalMaxKbps = float64(maxKbps)
+		}
 
-			var maxKbps float64
-			if v, ok := maxKbpses[cacheName]; !ok {
-				log.Infof("cache not in max kbps cache %s\n", cacheName)
-			} else {
-				maxKbps = float64(v)
-				totalMaxKbps += maxKbps
-			}
+		health, healthOk := healthHistory[tc.CacheName(cacheName)]
+		if !healthOk {
+			log.Infof("Cache server '%s' not in max kbps cache", cacheName)
+		} else if len(health) < 1 {
+			log.Infof("No health data history for cache server '%s'", cacheName)
+			healthOk = false
+		}
 
-			var connections int64
-			connectionsVal, ok := conns[cacheName]
-			if !ok {
-				log.Infof("cache not in connections %s\n", cacheName)
-			} else {
-				totalConnections += connectionsVal
-				connections = connectionsVal
+		var totalKbps float64 = 0
+
+		cacheStatus, statusOk := localCacheStatus[cacheName]
+		poller := "unknown"
+		if !statusOk {
+			log.Warnf("No cache status found for cache '%s'", cacheName)
+		} else {
+			poller = cacheStatus.Poller
+		}
+		for _, inf := range serverInfo.Interfaces {
+			interfaceName := inf.Name
+
+			infStatus := CacheInterfaceStatus{
+				Available:     false,
+				BandwidthKbps: 0,
+				Status:        NotFoundStatus,
+				StatusPoller:  poller,
 			}
-			interfaceStatus[interfaceName] = CacheInterfaceStatus{
-				Status:                &status,
-				StatusPoller:          &statusPoller,
-				BandwidthKbps:         &kbps,
-				BandwidthCapacityKbps: &maxKbps,
-				ConnectionCount:       &connections,
-				IPv4Available:         &ipv4,
-				IPv6Available:         &ipv6,
-				CombinedAvailable:     &combinedStatus,
+
+			if healthOk {
+				infStatus.Status, infStatus.Available = interfaceStatus(inf, health[0])
+				if infVit, ok := health[0].InterfaceVitals[inf.Name]; ok {
+					infStatus.BandwidthKbps = float64(infVit.KbpsOut)
+					if inf.Monitor {
+						totalKbps += infStatus.BandwidthKbps
+					}
+				} else {
+					log.Infof("Cache server '%s' interface '%s' not in last health measurement.", cacheName, inf.Name)
+				}
 			}
+
+			interfaceStatuses[interfaceName] = infStatus
+		}
+
+		var connections int64 = 0
+		connectionsVal, ok := conns[cacheName]
+		if !ok {
+			log.Infof("Cache server '%s' not in connections.", cacheName)
+		} else {
+			connections = connectionsVal
 		}
 
 		cacheTypeStr := ""
-		if cacheType, ok := cacheTypes[cacheName]; !ok {
+		if cacheType, ok := cacheTypes[tc.CacheName(cacheName)]; !ok {
 			log.Infof("Error getting cache type for %v: not in types\n", cacheName)
 		} else {
 			cacheTypeStr = string(cacheType)
 		}
 
 		loadAverage := 0.0
-		if infoHistory, ok := statInfoHistory[cacheName]; !ok {
+		if infoHistory, ok := statInfoHistory[tc.CacheName(cacheName)]; !ok {
 			log.Infof("createCacheStatuses stat info history missing cache %s\n", cacheName)
 		} else if len(infoHistory) < 1 {
 			log.Infof("createCacheStatuses stat info history empty for cache %s\n", cacheName)
@@ -176,27 +217,26 @@ func createCacheStatuses(
 			log.Infof("Error getting cache %v health query time: %v\n", cacheName, err)
 		}
 
-		statTime, err := latestResultInfoTimeMS(cacheName, statInfoHistory)
+		statTime, err := latestResultInfoTimeMS(tc.CacheName(cacheName), statInfoHistory)
 		if err != nil {
 			log.Infof("Error getting cache %v stat result time: %v\n", cacheName, err)
 		}
 
-		healthTime, err := latestResultTimeMS(cacheName, healthHistory)
+		healthTime, err := latestResultTimeMS(tc.CacheName(cacheName), healthHistory)
 		if err != nil {
 			log.Infof("Error getting cache %v health result time: %v\n", cacheName, err)
 		}
 
-		statSpan, err := infoResultSpanMS(cacheName, statInfoHistory)
+		statSpan, err := infoResultSpanMS(tc.CacheName(cacheName), statInfoHistory)
 		if err != nil {
 			log.Infof("Error getting cache %v stat span: %v\n", cacheName, err)
 		}
 
-		healthSpan, err := resultSpanMS(cacheName, healthHistory)
+		healthSpan, err := resultSpanMS(tc.CacheName(cacheName), healthHistory)
 		if err != nil {
 			log.Infof("Error getting cache %v health span: %v\n", cacheName, err)
 		}
 
-		status, statusPoller, ipv4, ipv6, combinedStatus := cacheStatusAndPoller(cacheName, tc.CacheInterfacesAggregate, serverInfo, localCacheStatus)
 		statii[cacheName] = CacheStatus{
 			Type:                   &cacheTypeStr,
 			LoadAverage:            &loadAverage,
@@ -207,21 +247,23 @@ func createCacheStatuses(
 			HealthSpanMilliseconds: &healthSpan,
 			BandwidthKbps:          &totalKbps,
 			BandwidthCapacityKbps:  &totalMaxKbps,
-			ConnectionCount:        &totalConnections,
-			Status:                 &status,
-			StatusPoller:           &statusPoller,
-			IPv4Available:          &ipv4,
-			IPv6Available:          &ipv6,
-			CombinedAvailable:      &combinedStatus,
-			Interfaces:             &interfaceStatus,
+			ConnectionCount:        &connections,
+			Status:                 &cacheStatus.Why,
+			StatusPoller:           &poller,
+			IPv4Available:          &cacheStatus.Available.IPv4,
+			IPv6Available:          &cacheStatus.Available.IPv6,
+			CombinedAvailable:      &cacheStatus.ProcessedAvailable,
+			Interfaces:             &interfaceStatuses,
 		}
 	}
 	return statii
 }
 
-//cacheStatusAndPoller returns the the reason why a cache is unavailable (or that is available), the poller, and 3 booleans in order:
-// IPv4 availability, IPv6 availability and Processed availability which is what the monitor reports based on the PollingProtocol chosen (ipv4only,ipv6only or both)
-func cacheStatusAndPoller(server tc.CacheName, interfaceName string, serverInfo tc.LegacyTrafficServer, localCacheStatus cache.AvailableStatuses) (string, string, bool, bool, bool) {
+// cacheStatusAndPoller returns the reason why a cache is unavailable (or
+// that is available), the poller, and 3 booleans in order: IPv4 availability,
+// IPv6 availability and Processed availability which is what the monitor
+// reports based on the PollingProtocol chosen (ipv4only,ipv6only or both).
+func cacheStatusAndPoller(server string, serverInfo tc.TrafficServer, localCacheStatus cache.AvailableStatuses) (string, string, bool, bool, bool) {
 	switch status := tc.CacheStatusFromString(serverInfo.ServerStatus); status {
 	case tc.CacheStatusAdminDown:
 		fallthrough
@@ -231,33 +273,33 @@ func cacheStatusAndPoller(server tc.CacheName, interfaceName string, serverInfo
 		return status.String(), "", false, false, false
 	}
 
-	if _, ok := localCacheStatus[server]; !ok {
-		log.Infof("cache not in statuses %s\n", server)
-		return "ERROR - not in statuses", "", false, false, false
-	}
-	if _, ok := localCacheStatus[server][interfaceName]; !ok {
-		log.Infof("interface %s not in cache %s", interfaceName, server)
+	status, ok := localCacheStatus[server]
+	if !ok {
+		log.Infof("Cache server '%s' not in statuses.", server)
 		return "ERROR - not in statuses", "", false, false, false
 	}
 
-	statusVal := localCacheStatus[server][interfaceName]
-	if statusVal.Why != "" {
-		return fmt.Sprintf("%s", statusVal.Why), statusVal.Poller, statusVal.Available.IPv4, statusVal.Available.IPv6, statusVal.ProcessedAvailable
-	}
-	if statusVal.ProcessedAvailable {
-		return fmt.Sprintf("%s - available", statusVal.Status), statusVal.Poller, statusVal.Available.IPv4, statusVal.Available.IPv6, statusVal.ProcessedAvailable
+	var statusStr string
+	if status.Why == "" {
+		if status.ProcessedAvailable {
+			statusStr = status.Status + " - available"
+		} else {
+			statusStr = status.Status + " - unavailable"
+		}
+	} else {
+		statusStr = status.Why
 	}
-	return fmt.Sprintf("%s - unavailable", statusVal.Status), statusVal.Poller, statusVal.Available.IPv4, statusVal.Available.IPv6, statusVal.ProcessedAvailable
+	return statusStr, status.Poller, status.Available.IPv4, status.Available.IPv6, status.ProcessedAvailable
 }
 
-func createCacheConnections(statResultHistory threadsafe.ResultStatHistory) map[tc.CacheName]int64 {
-	conns := map[tc.CacheName]int64{}
-	statResultHistory.Range(func(server tc.CacheName, interf string, history threadsafe.ResultStatValHistory) bool {
+func createCacheConnections(statResultHistory threadsafe.ResultStatHistory) map[string]int64 {
+	conns := map[string]int64{}
+	statResultHistory.Range(func(server string, history threadsafe.CacheStatHistory) bool {
 		// We only want to create connections for each cache
-		if interf != tc.CacheInterfacesAggregate {
+		if _, ok := conns[server]; ok {
 			return true
 		}
-		vals := history.Load("proxy.process.http.current_client_connections")
+		vals := history.Stats.Load("proxy.process.http.current_client_connections")
 		if len(vals) == 0 {
 			return true
 		}
@@ -312,10 +354,10 @@ func resultSpanMS(cacheName tc.CacheName, history map[tc.CacheName][]cache.Resul
 	return int64(span / time.Millisecond), nil
 }
 
-func latestQueryTimeMS(cacheName tc.CacheName, lastDurations map[tc.CacheName]time.Duration) (int64, error) {
-	queryTime, ok := lastDurations[cacheName]
+func latestQueryTimeMS(cacheName string, lastDurations map[tc.CacheName]time.Duration) (int64, error) {
+	queryTime, ok := lastDurations[tc.CacheName(cacheName)]
 	if !ok {
-		return 0, fmt.Errorf("cache %v not in last durations\n", cacheName)
+		return 0, fmt.Errorf("cache %v not in last durations", cacheName)
 	}
 	return int64(queryTime / time.Millisecond), nil
 }
diff --git a/traffic_monitor/datareq/cachestatfilter.go b/traffic_monitor/datareq/cachestatfilter.go
index 3ee0761..c9eb702 100644
--- a/traffic_monitor/datareq/cachestatfilter.go
+++ b/traffic_monitor/datareq/cachestatfilter.go
@@ -31,12 +31,13 @@ import (
 
 // CacheStatFilter fulfills the cache.Filter interface, for filtering stats. See the `NewCacheStatFilter` documentation for details on which query parameters are used to filter.
 type CacheStatFilter struct {
-	historyCount int
-	statsToUse   map[string]struct{}
-	wildcard     bool
-	cacheType    tc.CacheType
-	hosts        map[tc.CacheName]struct{}
-	cacheTypes   map[tc.CacheName]tc.CacheType
+	historyCount        uint64
+	statsToUse          map[string]struct{}
+	interfaceStatsToUse map[string]struct{}
+	wildcard            bool
+	cacheType           tc.CacheType
+	hosts               map[tc.CacheName]struct{}
+	cacheTypes          map[tc.CacheName]tc.CacheType
 }
 
 // UseCache returns whether the given cache is in the filter.
@@ -67,8 +68,25 @@ func (f *CacheStatFilter) UseStat(statName string) bool {
 	return false
 }
 
-// WithinStatHistoryMax returns whether the given history index is less than the max history of this filter.
-func (f *CacheStatFilter) WithinStatHistoryMax(n int) bool {
+func (f *CacheStatFilter) UseInterfaceStat(statName string) bool {
+	if len(f.interfaceStatsToUse) == 0 {
+		return true
+	}
+	if !f.wildcard {
+		_, ok := f.interfaceStatsToUse[statName]
+		return ok
+	}
+	for stat := range f.interfaceStatsToUse {
+		if strings.Contains(statName, stat) {
+			return true
+		}
+	}
+	return false
+}
+
+// WithinStatHistoryMax returns whether the given history index is less than the
+// max history of this filter.
+func (f *CacheStatFilter) WithinStatHistoryMax(n uint64) bool {
 	if f.historyCount == 0 {
 		return true
 	}
@@ -86,12 +104,13 @@ func (f *CacheStatFilter) WithinStatHistoryMax(n int) bool {
 // If `type` is empty, all cache types are returned.
 func NewCacheStatFilter(path string, params url.Values, cacheTypes map[tc.CacheName]tc.CacheType) (cache.Filter, error) {
 	validParams := map[string]struct{}{
-		"hc":       struct{}{},
-		"stats":    struct{}{},
-		"wildcard": struct{}{},
-		"type":     struct{}{},
-		"hosts":    struct{}{},
-		"cache":    struct{}{},
+		"hc":             struct{}{},
+		"stats":          struct{}{},
+		"interfaceStats": struct{}{},
+		"wildcard":       struct{}{},
+		"type":           struct{}{},
+		"hosts":          struct{}{},
+		"cache":          struct{}{},
 	}
 	if len(params) > len(validParams) {
 		return nil, fmt.Errorf("invalid query parameters")
@@ -102,9 +121,9 @@ func NewCacheStatFilter(path string, params url.Values, cacheTypes map[tc.CacheN
 		}
 	}
 
-	historyCount := 1
+	var historyCount uint64 = 1
 	if paramHc, exists := params["hc"]; exists && len(paramHc) > 0 {
-		v, err := strconv.Atoi(paramHc[0])
+		v, err := strconv.ParseUint(paramHc[0], 10, 64)
 		if err == nil {
 			historyCount = v
 		}
@@ -118,6 +137,15 @@ func NewCacheStatFilter(path string, params url.Values, cacheTypes map[tc.CacheN
 		}
 	}
 
+	interfaceStatsToUse := map[string]struct{}{}
+	if paramStats, exists := params["interfaceStats"]; exists {
+		for _, paramStat := range paramStats {
+			for _, stat := range strings.Split(paramStat, ",") {
+				interfaceStatsToUse[stat] = struct{}{}
+			}
+		}
+	}
+
 	wildcard := false
 	if paramWildcard, exists := params["wildcard"]; exists && len(paramWildcard) > 0 {
 		wildcard, _ = strconv.ParseBool(paramWildcard[0]) // ignore errors, error => false
diff --git a/traffic_monitor/datareq/crconfig.go b/traffic_monitor/datareq/crconfig.go
index a966805..c5fe485 100644
--- a/traffic_monitor/datareq/crconfig.go
+++ b/traffic_monitor/datareq/crconfig.go
@@ -27,9 +27,9 @@ import (
 	"github.com/apache/trafficcontrol/traffic_monitor/towrap"
 )
 
-func srvTRConfig(opsConfig threadsafe.OpsConfig, toSession towrap.ITrafficOpsSession) ([]byte, time.Time, error) {
+func srvTRConfig(opsConfig threadsafe.OpsConfig, toSession towrap.TrafficOpsSessionThreadsafe) ([]byte, time.Time, error) {
 	cdnName := opsConfig.Get().CdnName
-	if toSession == nil {
+	if !toSession.Initialized() {
 		return nil, time.Time{}, fmt.Errorf("Unable to connect to Traffic Ops")
 	}
 	if cdnName == "" {
diff --git a/traffic_monitor/datareq/crconfighist.go b/traffic_monitor/datareq/crconfighist.go
index b807b55..290ca5b 100644
--- a/traffic_monitor/datareq/crconfighist.go
+++ b/traffic_monitor/datareq/crconfighist.go
@@ -25,7 +25,7 @@ import (
 	"github.com/json-iterator/go"
 )
 
-func srvAPICRConfigHist(toc towrap.ITrafficOpsSession) ([]byte, error) {
+func srvAPICRConfigHist(toc towrap.TrafficOpsSessionThreadsafe) ([]byte, error) {
 	json := jsoniter.ConfigFastest
 	return json.Marshal(toc.CRConfigHistory())
 }
diff --git a/traffic_monitor/datareq/datareq.go b/traffic_monitor/datareq/datareq.go
index ea0cfca..0bccfa6 100644
--- a/traffic_monitor/datareq/datareq.go
+++ b/traffic_monitor/datareq/datareq.go
@@ -42,7 +42,7 @@ import (
 // MakeDispatchMap returns the map of paths to http.HandlerFuncs for dispatching.
 func MakeDispatchMap(
 	opsConfig threadsafe.OpsConfig,
-	toSession towrap.ITrafficOpsSession,
+	toSession towrap.TrafficOpsSessionThreadsafe,
 	localStates peer.CRStatesThreadsafe,
 	peerStates peer.CRStatesPeersThreadsafe,
 	combinedStates peer.CRStatesThreadsafe,
diff --git a/traffic_monitor/datareq/statsummary.go b/traffic_monitor/datareq/statsummary.go
index 7fb938d..d9bed95 100644
--- a/traffic_monitor/datareq/statsummary.go
+++ b/traffic_monitor/datareq/statsummary.go
@@ -34,20 +34,51 @@ import (
 	jsoniter "github.com/json-iterator/go"
 )
 
-type StatSummary struct {
-	Caches map[tc.CacheName]map[string]StatSummaryStat `json:"caches"`
-	srvhttp.CommonAPIData
-}
+// msPerNs is the number of milliseconds in a nanosecond.
+const msPerNs = 1000000
 
+// for whatever reason, all stats are prefixed with this
+const statPrefix = "ats."
+
+// StatSummaryStat represents a summary of a stat's values and changes over a
+// period of time.
 type StatSummaryStat struct {
-	DataPointCount int64   `json:"dpCount"`
-	Start          float64 `json:"start"`
-	End            float64 `json:"end"`
-	High           float64 `json:"high"`
-	Low            float64 `json:"low"`
-	Average        float64 `json:"average"`
-	StartTime      int64   `json:"startTime"`
-	EndTime        int64   `json:"endTime"`
+	// Average is the stat's value's arithmetic mean within the time period.
+	Average float64 `json:"average"`
+	// DataPointCount is the number of measurements of the stat that have been
+	// cataloged in the stat's history.
+	DataPointCount int64 `json:"dpCount"`
+	// End is the final value of the stat at the time of the most recent
+	// measurement.
+	End float64 `json:"end"`
+	// EndTime is the time of the most recent measurement, and defines the end
+	// point of the time period.
+	EndTime int64 `json:"endTime"`
+	// High is the stat's maximum value within the time period.
+	High float64 `json:"high"`
+	// Low is the stat's minimum value within the time period.
+	Low float64 `json:"low"`
+	// Start is the initial value of the stat at the time of the first
+	// measurement.
+	Start float64 `json:"start"`
+	// StartTime is the time of the first measurement, and defines the beginning
+	// of the time period.
+	StartTime int64 `json:"startTime"`
+}
+
+// CacheStatSummary is a summary of a cache server's measured statistics and the
+// measured statistics of its network interfaces.
+type CacheStatSummary struct {
+	// InterfaceStats is a map of network interface names to a map of statistic
+	// names to their summaries.
+	InterfaceStats map[string]map[string]StatSummaryStat `json:"interfaceStats"`
+	// Stats is a map of statistic names to summaries of those statistics.
+	Stats map[string]StatSummaryStat `json:"stats"`
+}
+
+type StatSummary struct {
+	Caches map[string]CacheStatSummary `json:"caches"`
+	srvhttp.CommonAPIData
 }
 
 func srvStatSummary(params url.Values, errorCount threadsafe.Uint, path string, toData todata.TODataThreadsafe, statResultHistory threadsafe.ResultStatHistory) ([]byte, int) {
@@ -63,47 +94,48 @@ func srvStatSummary(params url.Values, errorCount threadsafe.Uint, path string,
 }
 
 func createStatSummary(statResultHistory threadsafe.ResultStatHistory, filter cache.Filter, params url.Values) StatSummary {
-	statPrefix := "ats."
 	ss := StatSummary{
-		Caches:        map[tc.CacheName]map[string]StatSummaryStat{},
+		Caches:        map[string]CacheStatSummary{},
 		CommonAPIData: srvhttp.GetCommonAPIData(params, time.Now()),
 	}
 
-	statResultHistory.Range(func(cacheName tc.CacheName, interf string, stats threadsafe.ResultStatValHistory) bool {
-		if !filter.UseCache(cacheName) {
-			return true
-		}
-		// for now only look at aggregate stats
-		if interf != tc.CacheInterfacesAggregate {
+	statResultHistory.Range(func(cacheName string, stats threadsafe.CacheStatHistory) bool {
+		if !filter.UseCache(tc.CacheName(cacheName)) {
 			return true
 		}
+
+		var cacheStats CacheStatSummary
+
 		ssStats := map[string]StatSummaryStat{}
-		stats.Range(func(statName string, statHistory []cache.ResultStatVal) bool {
-			if !filter.UseStat(statName) {
+		stats.Stats.Range(func(statName string, statHistory []cache.ResultStatVal) bool {
+			if !filter.UseStat(statName) || len(statHistory) == 0 {
 				return true
 			}
-			if len(statHistory) == 0 {
-				return true
+
+			ssStat := StatSummaryStat{
+				EndTime:        statHistory[0].Time.UnixNano() / msPerNs,
+				DataPointCount: 0,
+				StartTime:      statHistory[len(statHistory)-1].Time.UnixNano() / msPerNs,
 			}
-			ssStat := StatSummaryStat{}
-			msPerNs := int64(1000000)
-			ssStat.StartTime = statHistory[len(statHistory)-1].Time.UnixNano() / msPerNs
-			ssStat.EndTime = statHistory[0].Time.UnixNano() / msPerNs
+
 			oldestVal, isOldestValNumeric := util.ToNumeric(statHistory[len(statHistory)-1].Val)
 			newestVal, isNewestValNumeric := util.ToNumeric(statHistory[0].Val)
 			if !isOldestValNumeric || !isNewestValNumeric {
 				return true // skip non-numeric stats
 			}
-			ssStat.Start = oldestVal
+
 			ssStat.End = newestVal
 			ssStat.High = newestVal
 			ssStat.Low = newestVal
+			ssStat.Start = oldestVal
+
 			for _, val := range statHistory {
 				fVal, ok := util.ToNumeric(val.Val)
 				if !ok {
 					log.Warnf("threshold stat %v value %v is not a number, cannot use.", statName, val.Val)
 					return true
 				}
+
 				for i := uint64(0); i < val.Span; i++ {
 					ssStat.DataPointCount++
 					ssStat.Average -= ssStat.Average / float64(ssStat.DataPointCount)
@@ -119,7 +151,67 @@ func createStatSummary(statResultHistory threadsafe.ResultStatHistory, filter ca
 			ssStats[statPrefix+statName] = ssStat
 			return true
 		})
-		ss.Caches[cacheName] = ssStats
+
+		cacheStats.Stats = ssStats
+
+		infStats := make(map[string]map[string]StatSummaryStat, len(stats.Interfaces))
+		for infName, infStatHistory := range stats.Interfaces {
+			if _, ok := infStats[infName]; ok {
+				log.Warnf("Somehow found duplicate interface '%s' in stat history for cache server '%s'", infName, cacheName)
+				continue
+			}
+			infStatMap := map[string]StatSummaryStat{}
+
+			infStatHistory.Range(func(statName string, statHistory []cache.ResultStatVal) bool {
+				if !filter.UseInterfaceStat(statName) || len(statHistory) == 0 {
+					return true
+				}
+
+				ssStat := StatSummaryStat{
+					EndTime:        statHistory[0].Time.UnixNano() / msPerNs,
+					DataPointCount: 0,
+					StartTime:      statHistory[len(statHistory)-1].Time.UnixNano() / msPerNs,
+				}
+
+				oldestVal, isOldestValNumeric := util.ToNumeric(statHistory[len(statHistory)-1].Val)
+				newestVal, isNewestValNumeric := util.ToNumeric(statHistory[0].Val)
+				if !isOldestValNumeric || !isNewestValNumeric {
+					return true // skip non-numeric stats
+				}
+
+				ssStat.End = newestVal
+				ssStat.High = newestVal
+				ssStat.Low = newestVal
+				ssStat.Start = oldestVal
+
+				for _, val := range statHistory {
+					fVal, ok := util.ToNumeric(val.Val)
+					if !ok {
+						log.Warnf("threshold stat %v value %v is not a number, cannot use.", statName, val.Val)
+						return true
+					}
+
+					for i := uint64(0); i < val.Span; i++ {
+						ssStat.DataPointCount++
+						ssStat.Average -= ssStat.Average / float64(ssStat.DataPointCount)
+						ssStat.Average += fVal / float64(ssStat.DataPointCount)
+					}
+					if fVal < ssStat.Low {
+						ssStat.Low = fVal
+					}
+					if fVal > ssStat.High {
+						ssStat.High = fVal
+					}
+				}
+				infStatMap[statPrefix+statName] = ssStat
+				return true
+			})
+
+			infStats[infName] = infStatMap
+		}
+
+		cacheStats.InterfaceStats = infStats
+		ss.Caches[cacheName] = cacheStats
 		return true
 	})
 	return ss
diff --git a/traffic_monitor/ds/stat.go b/traffic_monitor/ds/stat.go
index 8d8adb1..5106e19 100644
--- a/traffic_monitor/ds/stat.go
+++ b/traffic_monitor/ds/stat.go
@@ -259,9 +259,10 @@ func addLastDSStatTotals(lastStat *dsdata.LastDSStat, cachesReporting map[tc.Cac
 	lastStat.Total = total
 }
 
-// addDSPerSecStats calculates and adds the per-second delivery service stats to both the Stats and LastStats structures.
-// Note this mutates both dsStats and lastStats, adding the per-second stats to them.
-func addDSPerSecStats(lastStats *dsdata.LastStats, dsStats *dsdata.Stats, dsName tc.DeliveryServiceName, stat *dsdata.Stat, serverCachegroups map[tc.CacheName]tc.CacheGroupName, serverTypes map[tc.CacheName]tc.CacheType, mc tc.LegacyTrafficMonitorConfigMap, events health.ThreadsafeEvents, precomputed map[tc.CacheName]cache.PrecomputedData, states peer.CRStatesThreadsafe) {
+// addDSPerSecStats calculates and adds the per-second delivery service stats to
+// both the Stats and LastStats structures. Note this mutates both dsStats and
+// lastStats, adding the per-second stats to them.
+func addDSPerSecStats(lastStats *dsdata.LastStats, dsStats *dsdata.Stats, dsName tc.DeliveryServiceName, stat *dsdata.Stat, serverCachegroups map[tc.CacheName]tc.CacheGroupName, serverTypes map[tc.CacheName]tc.CacheType, mc tc.TrafficMonitorConfigMap, events health.ThreadsafeEvents, precomputed map[tc.CacheName]cache.PrecomputedData, states peer.CRStatesThreadsafe) {
 	lastStat, lastStatExists := lastStats.DeliveryServices[dsName]
 	if !lastStatExists {
 		lastStat = newLastDSStat() // TODO sync.Pool?
@@ -369,17 +370,20 @@ func addCachePerSecStats(lastStats *dsdata.LastStats, cacheName tc.CacheName, pr
 	}
 }
 
-// addPerSecStats adds Kbps fields to the NewStats, based on the previous out_bytes in the oldStats, and the time difference.
+// addPerSecStats adds Kbps fields to the NewStats, based on the previous
+// out_bytes in the oldStats, and the time difference.
 //
-// Traffic Server only updates its data every N seconds. So, often we get a new Stats with the same OutBytes as the previous one,
-// So, we must record the last changed value, and the time it changed. Then, if the new OutBytes is different from the previous,
-// we set the (new - old) / lastChangedTime as the KBPS, and update the recorded LastChangedTime and LastChangedValue
+// Traffic Server only updates its data every N seconds. So, often we get a new
+// Stats with the same OutBytes as the previous one, so, we must record the last
+// changed value, and the time it changed. Then, if the new OutBytes is
+// different from the previous, we set the (new - old) / lastChangedTime as the
+// KBPS, and update the recorded LastChangedTime and LastChangedValue
 //
 // TODO handle ATS byte rolling (when the `out_bytes` overflows back to 0)
 //
-// Note this mutates both dsStats and lastStats, adding the per-second stats to them.
-//
-func addPerSecStats(precomputed map[tc.CacheName]cache.PrecomputedData, dsStats *dsdata.Stats, lastStats *dsdata.LastStats, serverCachegroups map[tc.CacheName]tc.CacheGroupName, serverTypes map[tc.CacheName]tc.CacheType, mc tc.LegacyTrafficMonitorConfigMap, events health.ThreadsafeEvents, states peer.CRStatesThreadsafe) {
+// Note this mutates both dsStats and lastStats, adding the per-second stats to
+// them.
+func addPerSecStats(precomputed map[tc.CacheName]cache.PrecomputedData, dsStats *dsdata.Stats, lastStats *dsdata.LastStats, serverCachegroups map[tc.CacheName]tc.CacheGroupName, serverTypes map[tc.CacheName]tc.CacheType, mc tc.TrafficMonitorConfigMap, events health.ThreadsafeEvents, states peer.CRStatesThreadsafe) {
 	for dsName, stat := range dsStats.DeliveryService {
 		addDSPerSecStats(lastStats, dsStats, dsName, stat, serverCachegroups, serverTypes, mc, events, precomputed, states)
 	}
@@ -390,7 +394,7 @@ func addPerSecStats(precomputed map[tc.CacheName]cache.PrecomputedData, dsStats
 
 // CreateStats aggregates and creates statistics from given precomputed stat history. It returns the created stats, information about these stats necessary for the next calculation, and any error.
 // Note lastStats is mutated, being set with the new last stats.
-func CreateStats(precomputed map[tc.CacheName]cache.PrecomputedData, toData todata.TOData, crStates tc.CRStates, lastStats *dsdata.LastStats, now time.Time, mc tc.LegacyTrafficMonitorConfigMap, events health.ThreadsafeEvents, states peer.CRStatesThreadsafe) (*dsdata.Stats, error) {
+func CreateStats(precomputed map[tc.CacheName]cache.PrecomputedData, toData todata.TOData, crStates tc.CRStates, lastStats *dsdata.LastStats, now time.Time, mc tc.TrafficMonitorConfigMap, events health.ThreadsafeEvents, states peer.CRStatesThreadsafe) (*dsdata.Stats, error) {
 	start := time.Now()
 	dsStats := dsdata.NewStats(len(toData.DeliveryServiceServers)) // TODO sync.Pool?
 	for deliveryService := range toData.DeliveryServiceServers {
@@ -460,7 +464,7 @@ func CreateStats(precomputed map[tc.CacheName]cache.PrecomputedData, toData toda
 	return dsStats, nil
 }
 
-func getDSErr(dsName tc.DeliveryServiceName, dsStats dsdata.StatCacheStats, monitorConfig tc.LegacyTrafficMonitorConfigMap) error {
+func getDSErr(dsName tc.DeliveryServiceName, dsStats dsdata.StatCacheStats, monitorConfig tc.TrafficMonitorConfigMap) error {
 	if tpsThreshold := monitorConfig.DeliveryService[dsName.String()].TotalTPSThreshold; tpsThreshold > 0 && dsStats.TpsTotal.Value > float64(tpsThreshold) {
 		return fmt.Errorf("total.tps_total too high (%.2f > %v)", dsStats.TpsTotal.Value, tpsThreshold)
 	}
diff --git a/traffic_monitor/ds/stat_test.go b/traffic_monitor/ds/stat_test.go
index 761574d..c141592 100644
--- a/traffic_monitor/ds/stat_test.go
+++ b/traffic_monitor/ds/stat_test.go
@@ -257,9 +257,9 @@ func getMockMonitorDSLowThresholds(name tc.DeliveryServiceName) tc.TMDeliverySer
 	}
 }
 
-func getMockMonitorConfig(dses []tc.DeliveryServiceName) tc.LegacyTrafficMonitorConfigMap {
-	mc := tc.LegacyTrafficMonitorConfigMap{
-		TrafficServer:   map[string]tc.LegacyTrafficServer{},
+func getMockMonitorConfig(dses []tc.DeliveryServiceName) tc.TrafficMonitorConfigMap {
+	mc := tc.TrafficMonitorConfigMap{
+		TrafficServer:   map[string]tc.TrafficServer{},
 		CacheGroup:      map[string]tc.TMCacheGroup{},
 		Config:          map[string]interface{}{},
 		TrafficMonitor:  map[string]tc.TrafficMonitor{},
diff --git a/traffic_monitor/health/cache.go b/traffic_monitor/health/cache.go
index 12e67f6..208e837 100644
--- a/traffic_monitor/health/cache.go
+++ b/traffic_monitor/health/cache.go
@@ -21,6 +21,7 @@ package health
 
 import (
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/apache/trafficcontrol/lib/go-log"
@@ -33,21 +34,45 @@ import (
 	"github.com/apache/trafficcontrol/traffic_monitor/todata"
 )
 
+// Used as a dummy value for evaluating threshold stats (which don't use real
+// combined states).
+var dummyCombinedState = tc.IsAvailable{}
+
+// AvailableStr is used to describe the state of a cache server that is
+// available to serve traffic.
+const AvailableStr = "available"
+
+// UnavailableStr is used to describe the state of a cache server that is not
+// available to serve traffic.
+const UnavailableStr = "unavailable"
+
 // GetVitals Gets the vitals to decide health on in the right format
-func GetVitals(newResult *cache.Result, prevResult *cache.Result, mc *tc.LegacyTrafficMonitorConfigMap) {
+func GetVitals(newResult *cache.Result, prevResult *cache.Result, mc *tc.TrafficMonitorConfigMap) {
 	if newResult.Error != nil {
 		log.Errorf("cache_health.GetVitals() called with an errored Result!")
 		return
 	}
 
+	if newResult.InterfaceVitals == nil {
+		newResult.InterfaceVitals = map[string]cache.Vitals{}
+	}
+
 	// proc.loadavg -- we're using the 1 minute average (!?)
 	newResult.Vitals.LoadAvg = newResult.Statistics.Loadavg.One
 
-	// We will use the average for vitality, this has the possibility of overflow
-	// however the code had a few overflow issues that has been in production for years
-	// at some point this will need to be chagned when Vitals is revisted
-	log.Infof("Cache '%s' has %d interfaces, we will use average (overflow possible) for vitality", newResult.ID, len(newResult.Statistics.Interfaces))
-	for _, iface := range newResult.Interfaces() {
+	for ifaceName, iface := range newResult.Interfaces() {
+		ifaceVitals := cache.Vitals{
+			BytesIn:    iface.BytesIn,
+			BytesOut:   iface.BytesOut,
+			MaxKbpsOut: iface.Speed * 1000,
+		}
+
+		if prevResult != nil && prevResult.InterfaceVitals != nil && prevResult.InterfaceVitals[ifaceName].BytesOut != 0 {
+			elapsedTimeInSecs := float64(newResult.Time.UnixNano()-prevResult.Time.UnixNano()) / 1000000000
+			ifaceVitals.KbpsOut = int64(float64((ifaceVitals.BytesOut-prevResult.InterfaceVitals[ifaceName].BytesOut)*8/1000) / elapsedTimeInSecs)
+		}
+		newResult.InterfaceVitals[ifaceName] = ifaceVitals
+
 		// Overflow possible
 		newResult.Vitals.BytesOut += iface.BytesOut
 		newResult.Vitals.BytesIn += iface.BytesIn
@@ -55,9 +80,6 @@ func GetVitals(newResult *cache.Result, prevResult *cache.Result, mc *tc.LegacyT
 		newResult.Vitals.MaxKbpsOut += iface.Speed * 1000
 	}
 
-	newResult.Vitals.BytesOut = newResult.Vitals.BytesOut / uint64(len(newResult.Interfaces()))
-	newResult.Vitals.BytesIn = newResult.Vitals.BytesIn / uint64(len(newResult.Interfaces()))
-	newResult.Vitals.MaxKbpsOut = newResult.Vitals.MaxKbpsOut / int64(len(newResult.Interfaces()))
 	if prevResult != nil && prevResult.Vitals.BytesOut != 0 {
 		elapsedTimeInSecs := float64(newResult.Time.UnixNano()-prevResult.Time.UnixNano()) / 1000000000
 		newResult.Vitals.KbpsOut = int64(float64((newResult.Vitals.BytesOut-prevResult.Vitals.BytesOut)*8/1000) / elapsedTimeInSecs)
@@ -65,14 +87,14 @@ func GetVitals(newResult *cache.Result, prevResult *cache.Result, mc *tc.LegacyT
 
 }
 
-func EvalCacheWithStatusInfo(result cache.ResultInfo, mc *tc.LegacyTrafficMonitorConfigMap, status tc.CacheStatus, serverInfo tc.LegacyTrafficServer) (bool, string, string) {
+func EvalCacheWithStatusInfo(result cache.ResultInfo, mc *tc.TrafficMonitorConfigMap, status tc.CacheStatus, serverStatus string) (bool, string, string) {
 	availability := AvailableStr
 	if !result.Available {
 		availability = UnavailableStr
 	}
 	switch {
 	case status == tc.CacheStatusInvalid:
-		log.Errorf("Cache %v got invalid status from Traffic Ops '%v' - treating as OFFLINE\n", result.ID, serverInfo.ServerStatus)
+		log.Errorf("Cache %v got invalid status from Traffic Ops '%v' - treating as OFFLINE\n", result.ID, serverStatus)
 		return false, eventDesc(status, availability+"; invalid status"), ""
 	case status == tc.CacheStatusAdminDown:
 		return false, eventDesc(status, availability), ""
@@ -89,44 +111,67 @@ func EvalCacheWithStatusInfo(result cache.ResultInfo, mc *tc.LegacyTrafficMonito
 	return result.Available, eventDesc(status, availability), ""
 }
 
-const AvailableStr = "available"
-const UnavailableStr = "unavailable"
-
-// EvalCache returns whether the given cache should be marked available, a boolean of whether the result was over ipv4 (false means it was ipv6), a string describing why, and which stat exceeded a threshold. The `stats` may be nil, for pollers which don't poll stats.
-// The availability of EvalCache MAY NOT be used to directly set the cache's local availability, because the threshold stats may not be part of the poller which produced the result. Rather, if the cache was previously unavailable from a threshold, it must be verified that threshold stat is in the results before setting the cache to available.
-// The resultStats may be nil, and if so, won't be checked for thresholds. For example, the Health poller doesn't have Stats.
+// EvalInterface returns whether the given interface should be marked
+// available, a boolean of whether the result was over IPv4 (false means it
+// was IPv6), a string describing why, and which stat exceeded a threshold. The
+// `stats` may be nil, for pollers which don't poll stats. The availability of
+// EvalCache MAY NOT be used to directly set the cache's local availability,
+// because the threshold stats may not be part of the poller which produced the
+// result. Rather, if the cache was previously unavailable from a threshold, it
+// must be verified that threshold stat is in the results before setting the
+// cache to available. The resultStats may be nil, and if so, won't be checked
+// for thresholds. For example, the Health poller doesn't have Stats.
 // TODO change to return a `cache.AvailableStatus`
-func EvalCache(result cache.ResultInfo, resultStats *threadsafe.ResultStatValHistory, mc *tc.LegacyTrafficMonitorConfigMap) (bool, bool, string, string) {
+func EvalInterface(infVitals map[string]cache.Vitals, inf tc.ServerInterfaceInfo) (bool, string) {
+	if !inf.Monitor {
+		return true, ""
+	}
+
+	vitals, ok := infVitals[inf.Name]
+	if !ok {
+		return false, "not found in polled data"
+	}
+
+	if inf.MaxBandwidth == nil {
+		return true, ""
+	}
+
+	if *inf.MaxBandwidth < uint64(vitals.KbpsOut) {
+		return false, "maximum bandwidth exceeded"
+	}
+
+	return true, ""
+}
+
+func EvalAggregate(result cache.ResultInfo, resultStats *threadsafe.ResultStatValHistory, mc *tc.TrafficMonitorConfigMap) (bool, string, string) {
 	serverInfo, ok := mc.TrafficServer[string(result.ID)]
 	if !ok {
 		log.Errorf("Cache %v missing from from Traffic Ops Monitor Config - treating as OFFLINE\n", result.ID)
-		return false, result.UsingIPv4, "ERROR - server missing in Traffic Ops monitor config", ""
+		return false, "ERROR - server missing in Traffic Ops monitor config", ""
 	}
 	status := tc.CacheStatusFromString(serverInfo.ServerStatus)
 	if status == tc.CacheStatusOnline {
 		// return here first, even though EvalCacheWithStatus checks online, because we later assume that if EvalCacheWithStatus returns true, to return false if thresholds are exceeded; but, if the cache is ONLINE, we don't want to check thresholds.
-		return true, result.UsingIPv4, eventDesc(status, AvailableStr), ""
+		return true, eventDesc(status, AvailableStr), ""
 	}
 
-	serverProfile, ok := mc.Profile[serverInfo.Profile]
+	profile, ok := mc.Profile[serverInfo.Profile]
 	if !ok {
-		log.Errorf("Cache %v profile %v missing from from Traffic Ops Monitor Config - treating as OFFLINE\n", result.ID, serverInfo.Profile)
-		return false, result.UsingIPv4, "ERROR - server profile missing in Traffic Ops monitor config", ""
+		log.Errorf("Profile '%v' for cache server '%v' missing from monitoring configuration - treating as OFFLINE", serverInfo.Profile, result.ID)
+		return false, "ERROR - server profile missing in Traffic Ops monitor config", ""
 	}
 
-	avail, eventDescVal, eventMsg := EvalCacheWithStatusInfo(result, mc, status, serverInfo)
+	avail, eventDescVal, eventMsg := EvalCacheWithStatusInfo(result, mc, status, serverInfo.ServerStatus)
 	if !avail {
-		return avail, result.UsingIPv4, eventDescVal, eventMsg
+		return avail, eventDescVal, eventMsg
 	}
 
 	computedStats := cache.ComputedStats()
 
-	for stat, threshold := range serverProfile.Parameters.Thresholds {
+	for stat, threshold := range profile.Parameters.Thresholds {
 		resultStat := interface{}(nil)
-		if computedStatF, ok := computedStats[stat]; ok {
-			dummyCombinedstate := tc.IsAvailable{} // the only stats which use combinedState are things like isAvailable, which don't make sense to ever be thresholds.
-			resultStat = computedStatF(result, serverInfo, serverProfile, dummyCombinedstate)
-		} else {
+		computedStatF, ok := computedStats[stat]
+		if !ok {
 			if resultStats == nil {
 				continue
 			}
@@ -135,6 +180,8 @@ func EvalCache(result cache.ResultInfo, resultStats *threadsafe.ResultStatValHis
 				continue
 			}
 			resultStat = resultStatHistory[0].Val
+		} else {
+			resultStat = computedStatF(result, serverInfo, profile, dummyCombinedState)
 		}
 
 		resultStatNum, ok := util.ToNumeric(resultStat)
@@ -144,43 +191,61 @@ func EvalCache(result cache.ResultInfo, resultStats *threadsafe.ResultStatValHis
 		}
 
 		if !inThreshold(threshold, resultStatNum) {
-			return false, result.UsingIPv4, eventDesc(status, exceedsThresholdMsg(stat, threshold, resultStatNum)), stat
+			return false, eventDesc(status, exceedsThresholdMsg(stat, threshold, resultStatNum)), stat
 		}
 	}
 
-	return avail, result.UsingIPv4, eventDescVal, eventMsg
+	return avail, eventDescVal, eventMsg
 }
 
-// CalcAvailabilityWithStats calculates the availability of each cache in results.
-// statResultHistory may be nil, in which case stats won't be used to calculate availability.
-func CalcAvailability(results []cache.Result, pollerName string, statResultHistory *threadsafe.ResultStatHistory, mc tc.LegacyTrafficMonitorConfigMap, toData todata.TOData, localCacheStatusThreadsafe threadsafe.CacheAvailableStatus, localStates peer.CRStatesThreadsafe, events ThreadsafeEvents, protocol config.PollingProtocol) {
-	localCacheStatuses := localCacheStatusThreadsafe.Get().Copy()
-	statResults := (*threadsafe.ResultStatValHistory)(nil)
-	statResultsVal := (*map[string]threadsafe.ResultStatValHistory)(nil)
-	processAvailableTuple := func(tuple cache.AvailableTuple, serverInfo tc.LegacyTrafficServer) bool {
-		switch protocol {
-		case config.IPv4Only:
+// getProcessAvailableTuple gets a function to process an availability tuple
+// based on the protocol used.
+func getProcessAvailableTuple(protocol config.PollingProtocol) func(cache.AvailableTuple, tc.TrafficServer) bool {
+	switch protocol {
+	case config.IPv4Only:
+		return func(tuple cache.AvailableTuple, _ tc.TrafficServer) bool {
 			return tuple.IPv4
-		case config.IPv6Only:
+		}
+	case config.IPv6Only:
+		return func(tuple cache.AvailableTuple, _ tc.TrafficServer) bool {
 			return tuple.IPv6
-		case config.Both:
-			// only report availability based on defined IP addresses
-			if serverInfo.IP == "" {
+		}
+	case config.Both:
+		return func(tuple cache.AvailableTuple, serverInfo tc.TrafficServer) bool {
+			if serverInfo.IPv4() == "" {
 				return tuple.IPv6
-			} else if serverInfo.IP6 == "" {
+			} else if serverInfo.IPv6() == "" {
 				return tuple.IPv4
 			}
-			// if both IP addresses are defined then report availability based on both
 			return tuple.IPv4 || tuple.IPv6
-		default:
-			log.Errorln("received an unknown PollingProtocol: " + protocol.String())
 		}
-		return false
+	default:
+		log.Errorf("received an unknown Polling Protocol: %s", protocol)
 	}
+	return func(cache.AvailableTuple, tc.TrafficServer) bool { return false }
+}
+
+// CalcAvailability calculates the availability of each cache in results.
+// statResultHistory may be nil, in which case stats won't be used to calculate
+// availability.
+func CalcAvailability(
+	results []cache.Result,
+	pollerName string,
+	statResultHistory *threadsafe.ResultStatHistory,
+	mc tc.TrafficMonitorConfigMap,
+	toData todata.TOData,
+	localCacheStatusThreadsafe threadsafe.CacheAvailableStatus,
+	localStates peer.CRStatesThreadsafe,
+	events ThreadsafeEvents,
+	protocol config.PollingProtocol,
+) {
+	localCacheStatuses := localCacheStatusThreadsafe.Get().Copy()
+	var statResultsVal *threadsafe.CacheStatHistory
+	processAvailableTuple := getProcessAvailableTuple(protocol)
 
 	for _, result := range results {
 		if statResultHistory != nil {
-			t := statResultHistory.LoadOrStore(tc.CacheName(result.ID))
+			t := statResultHistory.LoadOrStore(result.ID)
 			statResultsVal = &t
 		}
 		serverInfo, ok := mc.TrafficServer[result.ID]
@@ -188,133 +253,97 @@ func CalcAvailability(results []cache.Result, pollerName string, statResultHisto
 			log.Errorf("Cache %v missing from from Traffic Ops Monitor Config - treating as OFFLINE\n", result.ID)
 		}
 
-		if _, ok := localCacheStatuses[tc.CacheName(result.ID)]; !ok {
-			localCacheStatuses[tc.CacheName(result.ID)] = make(map[string]cache.AvailableStatus)
+		availStatus := cache.AvailableStatus{
+			LastCheckedIPv4:    result.UsingIPv4,
+			ProcessedAvailable: true,
+			Poller:             pollerName,
+			Status:             serverInfo.ServerStatus,
 		}
 
-		resultInfo := cache.ToInfo(result)
-		for interfaceName, _ := range result.Interfaces() {
-			if statResultsVal != nil {
-				t := (*statResultsVal)[interfaceName]
-				statResults = &t
-			}
-			isAvailable, usingIPv4, whyAvailable, unavailableStat := EvalCache(resultInfo, statResults, &mc)
-
-			// if the cache is now Available, and was previously unavailable due to a threshold, make sure this poller contains the stat which exceeded the threshold.
-			previousStatus, hasPreviousStatus := localCacheStatuses[tc.CacheName(result.ID)][interfaceName]
-			availableTuple := cache.AvailableTuple{}
-
-			if hasPreviousStatus {
-				availableTuple = previousStatus.Available
-				availableTuple.SetAvailability(usingIPv4, isAvailable)
-
-				if processAvailableTuple(availableTuple, serverInfo) {
-					if !processAvailableTuple(previousStatus.Available, serverInfo) && previousStatus.UnavailableStat != "" {
-						if !result.HasStat(previousStatus.UnavailableStat) {
-							return
-						}
-					}
-				}
+		lastStatus, ok := localCacheStatuses[result.ID]
+		if ok {
+			if result.UsingIPv4 {
+				availStatus.Available.IPv4 = true
+				availStatus.Available.IPv6 = serverInfo.IPv6() != "" && lastStatus.Available.IPv6
 			} else {
-				availableTuple.SetAvailability(usingIPv4, isAvailable)
+				availStatus.Available.IPv6 = true
+				availStatus.Available.IPv4 = serverInfo.IPv4() != "" && lastStatus.Available.IPv4
 			}
+		}
 
-			// update availableTuple so TM UI is updated if one IP is removed
-			if availableTuple.IPv4 && serverInfo.IP == "" {
-				availableTuple.IPv4 = false
+		reasons := []string{}
+		resultInfo := cache.ToInfo(result)
+		for _, inf := range serverInfo.Interfaces {
+			if !inf.Monitor {
+				continue
 			}
-			if availableTuple.IPv6 && serverInfo.IP6 == "" {
-				availableTuple.IPv6 = false
+
+			available, why := EvalInterface(resultInfo.InterfaceVitals, inf)
+			if result.UsingIPv4 {
+				availStatus.Available.IPv4 = availStatus.Available.IPv4 && available
+			} else {
+				availStatus.Available.IPv6 = availStatus.Available.IPv6 && available
 			}
 
-			newAvailableState := processAvailableTuple(availableTuple, serverInfo)
-
-			localCacheStatuses[tc.CacheName(result.ID)][interfaceName] = cache.AvailableStatus{
-				Available:          availableTuple,
-				ProcessedAvailable: newAvailableState,
-				Status:             mc.TrafficServer[string(result.ID)].ServerStatus,
-				Why:                whyAvailable,
-				UnavailableStat:    unavailableStat,
-				Poller:             pollerName,
-				LastCheckedIPv4:    usingIPv4,
-			} // TODO move within localStates?
+			if why != "" {
+				reasons = append(reasons, inf.Name+": "+why)
+			}
 		}
 
-		// Compute aggregate data based on each interface
-		aggregateStatus := cache.AvailableStatus{
-			Available: cache.AvailableTuple{
-				IPv4: false,
-				IPv6: false,
-			},
-			ProcessedAvailable: false,
-			LastCheckedIPv4:    false,
-			Status:             "",
-			Why:                "",
-			UnavailableStat:    "",
-			Poller:             "",
+		var aggIsAvailable bool
+		var aggWhyAvailable string
+		var aggUnavailableStat string
+
+		if statResultsVal != nil {
+			aggIsAvailable, aggWhyAvailable, aggUnavailableStat = EvalAggregate(cache.ToInfo(result), &statResultsVal.Stats, &mc)
+		} else {
+			aggIsAvailable, aggWhyAvailable, aggUnavailableStat = EvalAggregate(cache.ToInfo(result), nil, &mc)
 		}
-		for interfaceName, status := range localCacheStatuses[tc.CacheName(result.ID)] {
-			if interfaceName == tc.CacheInterfacesAggregate {
-				continue
-			}
-			aggregateStatus.Available.IPv4 = aggregateStatus.Available.IPv4 || status.Available.IPv4
-			aggregateStatus.Available.IPv6 = aggregateStatus.Available.IPv6 || status.Available.IPv6
-
-			// What does this mean on aggregated data?
-			// For now assume that if any interface was then the aggregate is
-			aggregateStatus.LastCheckedIPv4 = aggregateStatus.LastCheckedIPv4 || status.LastCheckedIPv4
-
-			if status.Why != "" {
-				newWhyText := fmt.Sprintf("%s: %s", interfaceName, status.Why)
-				if aggregateStatus.Why != "" {
-					newWhyText = ", " + newWhyText
-				}
-				aggregateStatus.Why += newWhyText
-			}
 
-			if status.UnavailableStat != "" {
-				newUnavailableText := fmt.Sprintf("%s: %s", interfaceName, status.UnavailableStat)
-				if aggregateStatus.UnavailableStat != "" {
-					newUnavailableText += ", " + newUnavailableText
-				}
-				aggregateStatus.UnavailableStat = newUnavailableText
-			}
+		if result.UsingIPv4 {
+			availStatus.Available.IPv4 = availStatus.Available.IPv4 && aggIsAvailable
+		} else {
+			availStatus.Available.IPv6 = availStatus.Available.IPv6 && aggIsAvailable
+		}
 
-			// What does this mean on aggregated data?
-			// For now use random status unless a REPORTED status is found
-			if tc.CacheStatus(aggregateStatus.Status) != tc.CacheStatusReported {
-				aggregateStatus.Status = status.Status
-			}
+		availStatus.ProcessedAvailable = processAvailableTuple(availStatus.Available, serverInfo)
 
-			// Each interface in a cache should always have the same poller
-			aggregateStatus.Poller = status.Poller
+		if aggWhyAvailable != "" {
+			reasons = append([]string{aggWhyAvailable}, reasons...)
+		}
+		availStatus.Why = strings.Join(reasons, "; ")
+		if aggUnavailableStat != "" {
+			availStatus.UnavailableStat = aggUnavailableStat
 		}
-		aggregateStatus.ProcessedAvailable = processAvailableTuple(aggregateStatus.Available, serverInfo)
-		localCacheStatuses[tc.CacheName(result.ID)][tc.CacheInterfacesAggregate] = aggregateStatus
 
 		localStates.SetCache(tc.CacheName(result.ID), tc.IsAvailable{
-			IsAvailable:   aggregateStatus.ProcessedAvailable,
-			Ipv4Available: aggregateStatus.Available.IPv4,
-			Ipv6Available: aggregateStatus.Available.IPv6,
+			IsAvailable:   availStatus.ProcessedAvailable,
+			Ipv4Available: availStatus.Available.IPv4,
+			Ipv6Available: availStatus.Available.IPv6,
 		})
 
-		if statResultsVal != nil {
-			t := (*statResultsVal)[tc.CacheInterfacesAggregate]
-			statResults = &t
-		}
-		_, usingIPv4, whyAvailable, _ := EvalCache(resultInfo, statResults, &mc)
-		if available, ok := localStates.GetCache(tc.CacheName(result.ID)); !ok || available.IsAvailable != aggregateStatus.ProcessedAvailable {
+		if available, ok := localStates.GetCache(tc.CacheName(result.ID)); !ok || !available.IsAvailable || !availStatus.ProcessedAvailable {
 			protocol := "IPv4"
-			if !usingIPv4 {
+			if !availStatus.LastCheckedIPv4 {
 				protocol = "IPv6"
 			}
 			log.Infof("Changing state for %s was: %t now: %t because %s poller: %v on protocol %v error: %v",
-				result.ID, available.IsAvailable, aggregateStatus.ProcessedAvailable, whyAvailable, pollerName, protocol, result.Error)
-			events.Add(Event{Time: Time(time.Now()), Description: "Protocol: (" + protocol + ") " + whyAvailable +
-				" (" + pollerName + ")", Name: result.ID, Hostname: result.ID,
-				Type: toData.ServerTypes[tc.CacheName(result.ID)].String(), Available: aggregateStatus.ProcessedAvailable,
-				IPv4Available: aggregateStatus.Available.IPv4, IPv6Available: aggregateStatus.Available.IPv6})
+				result.ID, available.IsAvailable, availStatus.ProcessedAvailable, availStatus.Why, pollerName, protocol, result.Error)
+
+			event := Event{
+				Time:          Time(time.Now()),
+				Description:   "Protocol (" + protocol + ") " + availStatus.Why + " (" + pollerName + ") ",
+				Name:          result.ID,
+				Hostname:      result.ID,
+				Type:          toData.ServerTypes[tc.CacheName(result.ID)].String(),
+				Available:     availStatus.ProcessedAvailable,
+				IPv4Available: availStatus.Available.IPv4,
+				IPv6Available: availStatus.Available.IPv6,
+			}
+			events.Add(event)
 		}
+
+		localCacheStatuses[result.ID] = availStatus
 	}
 	calculateDeliveryServiceState(toData.DeliveryServiceServers, localStates, toData)
 	localCacheStatusThreadsafe.Set(localCacheStatuses)
diff --git a/traffic_monitor/health/cache_test.go b/traffic_monitor/health/cache_test.go
index 3d49a44..74486e8 100644
--- a/traffic_monitor/health/cache_test.go
+++ b/traffic_monitor/health/cache_test.go
@@ -24,13 +24,13 @@ import (
 	"testing"
 	"time"
 
-	"github.com/apache/trafficcontrol/traffic_monitor/config"
-
-	"github.com/apache/trafficcontrol/lib/go-tc"
 	"github.com/apache/trafficcontrol/traffic_monitor/cache"
+	"github.com/apache/trafficcontrol/traffic_monitor/config"
 	"github.com/apache/trafficcontrol/traffic_monitor/peer"
 	"github.com/apache/trafficcontrol/traffic_monitor/threadsafe"
 	"github.com/apache/trafficcontrol/traffic_monitor/todata"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
 )
 
 func TestCalcAvailabilityThresholds(t *testing.T) {
@@ -64,22 +64,33 @@ func TestCalcAvailabilityThresholds(t *testing.T) {
 		Time:            time.Now(),
 		RequestTime:     time.Second,
 		Vitals:          cache.Vitals{},
+		InterfaceVitals: map[string]cache.Vitals{},
 		PollID:          42,
 		PollFinished:    make(chan uint64, 1),
 		PrecomputedData: cache.PrecomputedData{},
 		Available:       true,
+		UsingIPv4:       false,
 	}
 	GetVitals(&result, nil, nil)
 
+	totalBytesOut := result.Statistics.Interfaces["bond0"].BytesOut + result.Statistics.Interfaces["eth0"].BytesOut
+	if totalBytesOut != result.Vitals.BytesOut {
+		t.Errorf("Incorrect calculated BytesOut; expected: %d, got: %d", totalBytesOut, result.Vitals.BytesOut)
+	}
+	prevIV := map[string]cache.Vitals{}
+	prevIV["eth0"] = cache.Vitals{BytesOut: result.Vitals.BytesOut - 1250000000}  // 10 gigabits
+	prevIV["bond0"] = cache.Vitals{BytesOut: result.Vitals.BytesOut - 1250000000} // 10 gigabits
+
 	prevResult := cache.Result{
-		Time:   result.Time.Add(time.Second * -1),
-		Vitals: cache.Vitals{BytesOut: result.Vitals.BytesOut - 1250000000}, // 10 gigabits
+		Time:            result.Time.Add(time.Second * -1),
+		Vitals:          cache.Vitals{BytesOut: result.Vitals.BytesOut - 1250000000}, // 10 gigabits
+		InterfaceVitals: prevIV,
 	}
 	GetVitals(&result, &prevResult, nil)
 
 	statResultHistory := (*threadsafe.ResultStatHistory)(nil)
-	mc := tc.LegacyTrafficMonitorConfigMap{
-		TrafficServer: map[string]tc.LegacyTrafficServer{
+	mc := tc.TrafficMonitorConfigMap{
+		TrafficServer: map[string]tc.TrafficServer{
 			string(result.ID): {
 				ServerStatus: string(tc.CacheStatusReported),
 				Profile:      "myProfileName",
@@ -126,26 +137,27 @@ func TestCalcAvailabilityThresholds(t *testing.T) {
 	CalcAvailability(results, pollerName, statResultHistory, mc, toData, localCacheStatusThreadsafe, localStates, events, config.Both)
 
 	localCacheStatuses := localCacheStatusThreadsafe.Get()
-	if _, ok := localCacheStatuses[tc.CacheName(result.ID)]; !ok {
+	localCacheStatus, ok := localCacheStatuses[result.ID]
+	if !ok {
 		t.Fatalf("expected: localCacheStatus[cacheName], actual: missing")
 	}
-	for interfaceName, localCacheStatus := range localCacheStatuses[tc.CacheName(result.ID)] {
-		if interfaceName == tc.CacheInterfacesAggregate {
-			continue
-		}
-		if localCacheStatus.Available.IPv4 {
-			t.Fatalf("localCacheStatus.Available.IPv4 (%s) over kbps threshold expected: false, actual: true", interfaceName)
-		} else if localCacheStatus.Available.IPv6 {
-			t.Fatalf("localCacheStatus.Available.IPv6 (%s) over kbps threshold expected: false, actual: true", interfaceName)
-		} else if localCacheStatus.Status != string(tc.CacheStatusReported) {
-			t.Fatalf("localCacheStatus.Status (%s) expected %v actual %v", interfaceName, "todo", localCacheStatus.Status)
-		} else if localCacheStatus.UnavailableStat != "availableBandwidthInKbps" {
-			t.Fatalf("localCacheStatus.UnavailableStat (%s) expected %v actual %v", interfaceName, "availableBandwidthInKbps", localCacheStatus.UnavailableStat)
-		} else if localCacheStatus.Poller != pollerName {
-			t.Fatalf("localCacheStatus.Poller (%s) expected %v actual %v", interfaceName, pollerName, localCacheStatus.Poller)
-		} else if !strings.Contains(localCacheStatus.Why, "availableBandwidthInKbps too low") {
-			t.Fatalf("localCacheStatus.Why (%s) expected 'availableBandwidthInKbps too low' actual %v", interfaceName, localCacheStatus.Why)
-		}
+
+	if !strings.Contains(localCacheStatus.Why, "availableBandwidthInKbps too low") {
+		t.Errorf("localCacheStatus.Why expected 'availableBandwidthInKbps too low' actual %s", localCacheStatus.Why)
+	} else if !strings.HasPrefix(localCacheStatus.UnavailableStat, "availableBandwidthInKbps") { // only check prefix because we don't care about the specific interfaces right now
+		t.Errorf("localCacheStatus.UnavailableStat expected it to start with: 'availableBandwidthInKbps', actual: '%s'", localCacheStatus.UnavailableStat)
+	}
+	if localCacheStatus.Available.IPv4 {
+		t.Errorf("localCacheStatus.Available.IPv4 over kbps threshold expected: false, actual: true")
+	}
+	if localCacheStatus.Available.IPv6 {
+		t.Error("localCacheStatus.Available.IPv6 over kbps threshold expected: false, actual: true")
+	}
+	if localCacheStatus.Status != string(tc.CacheStatusReported) {
+		t.Errorf("localCacheStatus.Status expected: 'todo', actual: '%s'", localCacheStatus.Status)
+	}
+	if localCacheStatus.Poller != pollerName {
+		t.Errorf("localCacheStatus.Poller expected '%s' actual '%s'", pollerName, localCacheStatus.Poller)
 	}
 
 	// test that the health poll didn't override the stat poll threshold markdown and mark available
@@ -162,18 +174,97 @@ func TestCalcAvailabilityThresholds(t *testing.T) {
 	CalcAvailability(healthResults, healthPollerName, nil, mc, toData, localCacheStatusThreadsafe, localStates, events, config.Both)
 
 	localCacheStatuses = localCacheStatusThreadsafe.Get()
-	localCacheStatus := localCacheStatuses[tc.CacheName(result.ID)]["bond0"]
+	if _, ok := localCacheStatuses[result.ID]; !ok {
+		t.Fatalf("expected: localCacheStatus[cacheName], actual: missing")
+	}
+
+	localCacheStatus = localCacheStatuses[result.ID]
+	if !strings.Contains(localCacheStatus.Why, "availableBandwidthInKbps too low") {
+		t.Errorf("localCacheStatus.Why expected: 'availableBandwidthInKbps too low' actual: '%s'", localCacheStatus.Why)
+	} else if !strings.HasPrefix(localCacheStatus.UnavailableStat, "availableBandwidthInKbps") { // only check prefix because we don't care about the specific interfaces right now
+		t.Errorf("localCacheStatus.UnavailableStat expected it to start with: 'availableBandwidthInKbps', actual: '%s'", localCacheStatus.UnavailableStat)
+	}
+
 	if localCacheStatus.Available.IPv4 {
-		t.Fatalf("localCacheStatus.Available.IPv4 over kbps threshold expected: false, actual: true")
+		t.Fatal("localCacheStatus.Available.IPv4 over kbps threshold expected: false, actual: true")
 	} else if localCacheStatus.Available.IPv6 {
-		t.Fatalf("localCacheStatus.Available.IPv6 over kbps threshold expected: false, actual: true")
+		t.Fatal("localCacheStatus.Available.IPv6 over kbps threshold expected: false, actual: true")
 	} else if localCacheStatus.Status != string(tc.CacheStatusReported) {
-		t.Fatalf("localCacheStatus.Status expected %v actual %v", "tc.CacheStatusReported", localCacheStatus.Status)
-	} else if localCacheStatus.UnavailableStat != "availableBandwidthInKbps" {
-		t.Fatalf("localCacheStatus.UnavailableStat expected %v actual %v", "availableBandwidthInKbps", localCacheStatus.UnavailableStat)
+		t.Fatalf("localCacheStatus.Status expected: 'todo' actual: '%s'", localCacheStatus.Status)
 	} else if localCacheStatus.Poller != healthPollerName {
-		t.Fatalf("localCacheStatus.Poller expected %v actual %v", healthPollerName, localCacheStatus.Poller)
-	} else if !strings.Contains(localCacheStatus.Why, "availableBandwidthInKbps too low") {
-		t.Fatalf("localCacheStatus.Why expected 'availableBandwidthInKbps too low' actual %v", localCacheStatus.Why)
+		t.Fatalf("localCacheStatus.Poller expected: '%s' actual '%s'", healthPollerName, localCacheStatus.Poller)
+	}
+}
+
+func TestEvalInterface(t *testing.T) {
+	result := cache.ResultInfo{
+		Available:       true,
+		Error:           nil,
+		ID:              "test",
+		PollID:          1,
+		RequestTime:     time.Second,
+		Statistics:      cache.Statistics{},
+		Time:            time.Now(),
+		UsingIPv4:       true,
+		Vitals:          cache.Vitals{},
+		InterfaceVitals: map[string]cache.Vitals{},
+	}
+
+	var infMaxKbps uint64 = 200
+	mc := tc.TrafficMonitorConfigMap{
+		Profile: map[string]tc.TMProfile{
+			"testProfile": {},
+		},
+		TrafficServer: map[string]tc.TrafficServer{
+			"test": {
+				Profile: "testProfile",
+				Interfaces: []tc.ServerInterfaceInfo{
+					{
+						Monitor:      true,
+						MaxBandwidth: &infMaxKbps,
+						Name:         "testInterface",
+						IPAddresses: []tc.ServerIPAddress{
+							{
+								Address:        "::1",
+								ServiceAddress: true,
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	available, why := EvalInterface(result.InterfaceVitals, mc.TrafficServer["test"].Interfaces[0])
+	if available {
+		t.Error("Expected unpolled interface to be unavailable, but it wasn't")
+	}
+
+	if why != "not found in polled data" {
+		t.Errorf("Incorrect reason for unpolled interface's availability; expected: 'not found in polled data', got: '%s'", why)
+	}
+
+	result.InterfaceVitals["testInterface"] = cache.Vitals{
+		KbpsOut: 199,
+	}
+	available, why = EvalInterface(result.InterfaceVitals, mc.TrafficServer["test"].Interfaces[0])
+	if !available {
+		t.Error("Expected polled interface within threshold to be available, but it wasn't")
+	}
+
+	if why != "" {
+		t.Errorf("Expected no reason why polled interface within threshold is unavailable, got: '%s'", why)
+	}
+
+	result.InterfaceVitals["testInterface"] = cache.Vitals{
+		KbpsOut: 201,
+	}
+	available, why = EvalInterface(result.InterfaceVitals, mc.TrafficServer["test"].Interfaces[0])
+	if available {
+		t.Error("Expected interface exceeding threshold to be unavailable, but it wasn't")
+	}
+
+	if why != "maximum bandwidth exceeded" {
+		t.Errorf("Incorrect reason for interface exceeding threshold to be unavailable; expected: 'maximum bandwidth exceeded', got: '%s'", why)
 	}
 }
diff --git a/traffic_monitor/manager/manager.go b/traffic_monitor/manager/manager.go
index 657e125..dc297d9 100644
--- a/traffic_monitor/manager/manager.go
+++ b/traffic_monitor/manager/manager.go
@@ -44,7 +44,7 @@ import (
 // Start starts the poller and handler goroutines
 //
 func Start(opsConfigFile string, cfg config.Config, appData config.StaticAppData, trafficMonitorConfigFileName string) error {
-	toSession := towrap.ITrafficOpsSession(towrap.NewTrafficOpsSessionThreadsafe(nil, cfg.CRConfigHistoryCount, cfg))
+	toSession := towrap.NewTrafficOpsSessionThreadsafe(nil, nil, cfg.CRConfigHistoryCount, cfg)
 
 	localStates := peer.NewCRStatesThreadsafe() // this is the local state as discoverer by this traffic_monitor
 	fetchCount := threadsafe.NewUint()          // note this is the number of individual caches fetched from, not the number of times all the caches were polled.
@@ -126,7 +126,7 @@ func Start(opsConfigFile string, cfg config.Config, appData config.StaticAppData
 		toSession,
 		toData,
 		[]chan<- handler.OpsConfig{monitorConfigPoller.OpsConfigChannel},
-		[]chan<- towrap.ITrafficOpsSession{monitorConfigPoller.SessionChannel},
+		[]chan<- towrap.TrafficOpsSessionThreadsafe{monitorConfigPoller.SessionChannel},
 		localStates,
 		peerStates,
 		combinedStates,
diff --git a/traffic_monitor/manager/monitorconfig.go b/traffic_monitor/manager/monitorconfig.go
index 2086fdb..4f44042 100644
--- a/traffic_monitor/manager/monitorconfig.go
+++ b/traffic_monitor/manager/monitorconfig.go
@@ -49,7 +49,7 @@ type PollIntervals struct {
 }
 
 // getPollIntervals reads the Traffic Ops Client monitorConfig structure, and parses and returns the health, peer, stat, and TrafficOps poll intervals
-func getIntervals(monitorConfig tc.LegacyTrafficMonitorConfigMap, cfg config.Config, logMissingParams bool) (PollIntervals, error) {
+func getIntervals(monitorConfig tc.TrafficMonitorConfigMap, cfg config.Config, logMissingParams bool) (PollIntervals, error) {
 	intervals := PollIntervals{}
 	peerPollIntervalI, peerPollIntervalExists := monitorConfig.Config["peers.polling.interval"]
 	if !peerPollIntervalExists {
@@ -129,7 +129,7 @@ func StartMonitorConfigManager(
 	cachesChangeSubscriber chan<- struct{},
 	cfg config.Config,
 	staticAppData config.StaticAppData,
-	toSession towrap.ITrafficOpsSession,
+	toSession towrap.TrafficOpsSessionThreadsafe,
 	toData todata.TODataThreadsafe,
 ) threadsafe.TrafficMonitorConfigMap {
 	monitorConfig := threadsafe.NewTrafficMonitorConfigMap()
@@ -199,7 +199,7 @@ func monitorConfigListen(
 	cachesChangeSubscriber chan<- struct{},
 	cfg config.Config,
 	staticAppData config.StaticAppData,
-	toSession towrap.ITrafficOpsSession,
+	toSession towrap.TrafficOpsSessionThreadsafe,
 	toData todata.TODataThreadsafe,
 ) {
 	defer func() {
@@ -334,15 +334,29 @@ func monitorConfigListen(
 	}
 }
 
-// createServerHealthPollURLs takes the template pollingURLStr, and replaces variables with data from srv, and returns the polling URL for srv.
-func createServerHealthPollURLs(pollingURLStr string, srv tc.LegacyTrafficServer) (string, string) {
-	pollingURL4Str := ""
-	pollingURL6Str := ""
+// createServerHealthPollURLs takes the template pollingURLStr, and replaces
+// variables with data from srv, and returns the polling URL for srv.
+//
+// Note: `${hostname}` is replaced with the server's service IPv4 address (when
+// possible) for IPv4 polls, and its IPv6 service address (when possible) for
+// IPv6 polls - NOT the servers hostname!
+func createServerHealthPollURLs(pollingURLStr string, srv tc.TrafficServer) (string, string) {
+	lid, err := tc.InterfaceInfoToLegacyInterfaces(srv.Interfaces)
+	if err != nil {
+		log.Errorf("Failed to parse polling strings for cache server '%s': %v", srv.HostName, err)
+		return "", ""
+	}
+
+	var infName string
+	if lid.InterfaceName != nil {
+		infName = *lid.InterfaceName
+	}
 
-	if srv.IP != "" {
+	var pollingURL4Str string
+	if lid.IPAddress != nil && *lid.IPAddress != "" {
 		pollingURL4Str = strings.NewReplacer(
-			"${hostname}", srv.IP,
-			"${interface_name}", srv.InterfaceName,
+			"${hostname}", *lid.IPAddress,
+			"${interface_name}", infName,
 			"application=plugin.remap", "application=system",
 			"application=", "application=system",
 		).Replace(pollingURLStr)
@@ -350,10 +364,11 @@ func createServerHealthPollURLs(pollingURLStr string, srv tc.LegacyTrafficServer
 		pollingURL4Str = insertPorts(pollingURL4Str, srv)
 	}
 
-	if srv.IP6 != "" {
+	var pollingURL6Str string
+	if lid.IP6Address != nil && *lid.IP6Address != "" {
 		r := strings.NewReplacer(
-			"${hostname}", "["+ipv6CIDRStrToAddr(srv.IP6)+"]",
-			"${interface_name}", srv.InterfaceName,
+			"${hostname}", "["+ipv6CIDRStrToAddr(*lid.IP6Address)+"]",
+			"${interface_name}", infName,
 			"application=plugin.remap", "application=system",
 			"application=", "application=system",
 		)
@@ -364,12 +379,12 @@ func createServerHealthPollURLs(pollingURLStr string, srv tc.LegacyTrafficServer
 	return pollingURL4Str, pollingURL6Str
 }
 
-func insertPorts(pollingURLStr string, srv tc.LegacyTrafficServer) string {
+func insertPorts(pollingURLStr string, srv tc.TrafficServer) string {
 	if strings.HasPrefix(strings.ToLower(pollingURLStr), "https") {
 		if srv.HTTPSPort != 0 {
 			pollURL, err := url.Parse(pollingURLStr)
 			if err != nil {
-				log.Warnln("profile " + srv.Profile + " cache '" + srv.FQDN + "' polling URL '" + pollingURLStr + "' failed to parse, may not be a valid URL! Using anyway, not using custom HTTPS Port " + strconv.Itoa(srv.HTTPSPort) + "!")
+				log.Warnf("profile '%s' cache server '%s' polling URL '%s' failed to parse, may not be a valid URL! Using anyway, not using custom HTTPS Port %d!", srv.Profile, srv.FQDN, pollingURLStr, srv.HTTPSPort)
 			} else if pollURL.Port() == "" { // if there's both an HTTPS Port and a port in the polling URL, the polling URL takes precedence
 				pollURL.Host += ":" + strconv.Itoa(srv.HTTPSPort)
 				pollingURLStr = pollURL.String()
@@ -379,7 +394,7 @@ func insertPorts(pollingURLStr string, srv tc.LegacyTrafficServer) string {
 		if srv.Port != 0 {
 			pollURL, err := url.Parse(pollingURLStr)
 			if err != nil {
-				log.Warnln("profile " + srv.Profile + " cache '" + srv.FQDN + "' polling URL '" + pollingURLStr + "' failed to parse, may not be a valid URL! Using anyway, not using custom TCP Port " + strconv.Itoa(srv.Port) + "!")
+				log.Warnf("profile '%s' cache server '%s' polling URL '%s' failed to parse, may not be a valid URL! Using anyway, not using custom TCP Port %d!", srv.Profile, srv.FQDN, pollingURLStr, srv.Port)
 			} else if pollURL.Port() == "" { // if there's both a TCP Port and a port in the polling URL, the polling URL takes precedence
 				pollURL.Host += ":" + strconv.Itoa(srv.Port)
 				pollingURLStr = pollURL.String()
diff --git a/traffic_monitor/manager/monitorconfig_test.go b/traffic_monitor/manager/monitorconfig_test.go
index e28410a..8afb38d 100644
--- a/traffic_monitor/manager/monitorconfig_test.go
+++ b/traffic_monitor/manager/monitorconfig_test.go
@@ -20,7 +20,6 @@ package manager
  */
 
 import (
-	"strconv"
 	"testing"
 
 	"github.com/apache/trafficcontrol/lib/go-tc"
@@ -28,69 +27,159 @@ import (
 
 func TestCreateServerHealthPollURL(t *testing.T) {
 	tmpl := `http://${hostname}/_astats?application=&inf.name=${interface_name}`
-	srv := tc.LegacyTrafficServer{IP: "192.0.2.42", IP6: "1::3:3:7", InterfaceName: "george"}
+	srv := tc.TrafficServer{
+		Interfaces: []tc.ServerInterfaceInfo{
+			{
+				IPAddresses: []tc.ServerIPAddress{
+					{
+						Address:        "192.0.2.42",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+					{
+						Address:        "1::3:3:7",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+				},
+				MaxBandwidth: nil,
+				MTU:          nil,
+				Monitor:      true,
+				Name:         "george",
+			},
+		},
+	}
 
-	expectedV4 := `http://` + srv.IP + `/_astats?application=system&inf.name=` + srv.InterfaceName
-	expectedV6 := `http://[` + srv.IP6 + `]/_astats?application=system&inf.name=` + srv.InterfaceName
+	expectedV4 := "http://192.0.2.42/_astats?application=system&inf.name=george"
+	expectedV6 := "http://[1::3:3:7]/_astats?application=system&inf.name=george"
 	actualV4, actualV6 := createServerHealthPollURLs(tmpl, srv)
 
 	if expectedV4 != actualV4 {
-		t.Errorf("for IPv4 expected createServerHealthPollURL '" + expectedV4 + "' actual: '" + actualV4 + "'")
+		t.Errorf("incorrect IPv4 polling URL; expected: '%s', actual: '%s'", expectedV4, actualV4)
 	}
 
 	if expectedV6 != actualV6 {
-		t.Errorf("for IPv6 expected createServerHealthPollURL '" + expectedV6 + "' actual: '" + actualV6 + "'")
+		t.Errorf("incorrect IPv6 polling URL; expected: '%s', actual: '%s'", expectedV6, actualV6)
 	}
 }
 
 func TestCreateServerHealthPollURLTemplatePort(t *testing.T) {
-	tmpl := `http://${hostname}:1234/_astats?application=&inf.name=${interface_name}`
-	srv := tc.LegacyTrafficServer{IP: "192.0.2.42", IP6: "1::3:3:7", InterfaceName: "george"}
+	tmpl := "http://${hostname}:1234/_astats?application=&inf.name=${interface_name}"
+	srv := tc.TrafficServer{
+		Interfaces: []tc.ServerInterfaceInfo{
+			{
+				IPAddresses: []tc.ServerIPAddress{
+					{
+						Address:        "192.0.2.42",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+					{
+						Address:        "1::3:3:7",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+				},
+				MaxBandwidth: nil,
+				MTU:          nil,
+				Monitor:      true,
+				Name:         "george",
+			},
+		},
+		Port: 1234,
+	}
 
-	expectedV4 := `http://` + srv.IP + `:1234/_astats?application=system&inf.name=` + srv.InterfaceName
-	expectedV6 := `http://[` + srv.IP6 + `]:1234/_astats?application=system&inf.name=` + srv.InterfaceName
+	expectedV4 := "http://192.0.2.42:1234/_astats?application=system&inf.name=george"
+	expectedV6 := "http://[1::3:3:7]:1234/_astats?application=system&inf.name=george"
 	actualV4, actualV6 := createServerHealthPollURLs(tmpl, srv)
 
 	if expectedV4 != actualV4 {
-		t.Errorf("for IPv4 expected createServerHealthPollURL '" + expectedV4 + "' actual: '" + actualV4 + "'")
+		t.Errorf("incorrect IPv4 polling URL; expected: '%s', actual: '%s'", expectedV4, actualV4)
 	}
 
 	if expectedV6 != actualV6 {
-		t.Errorf("for IPv6 expected createServerHealthPollURL '" + expectedV6 + "' actual: '" + actualV6 + "'")
+		t.Errorf("incorrect IPv6 polling URL; expected: '%s', actual: '%s'", expectedV6, actualV6)
 	}
 }
 
 func TestCreateServerHealthPollURLServerPort(t *testing.T) {
-	tmpl := `http://${hostname}/_astats?application=&inf.name=${interface_name}`
-	srv := tc.LegacyTrafficServer{IP: "192.0.2.42", IP6: "1::3:3:7", Port: 5678, HTTPSPort: 910, InterfaceName: "george"}
+	tmpl := "http://${hostname}/_astats?application=&inf.name=${interface_name}"
+	srv := tc.TrafficServer{
+		HTTPSPort: 910,
+		Interfaces: []tc.ServerInterfaceInfo{
+			{
+				IPAddresses: []tc.ServerIPAddress{
+					{
+						Address:        "192.0.2.42",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+					{
+						Address:        "1::3:3:7",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+				},
+				MaxBandwidth: nil,
+				MTU:          nil,
+				Monitor:      true,
+				Name:         "george",
+			},
+		},
+		Port: 5678,
+	}
 
-	expectedV4 := `http://` + srv.IP + ":" + strconv.Itoa(srv.Port) + `/_astats?application=system&inf.name=` + srv.InterfaceName
-	expectedV6 := `http://[` + srv.IP6 + "]:" + strconv.Itoa(srv.Port) + `/_astats?application=system&inf.name=` + srv.InterfaceName
+	expectedV4 := "http://192.0.2.42:5678/_astats?application=system&inf.name=george"
+	expectedV6 := "http://[1::3:3:7]:5678/_astats?application=system&inf.name=george"
 	actualV4, actualV6 := createServerHealthPollURLs(tmpl, srv)
 
 	if expectedV4 != actualV4 {
-		t.Errorf("for IPv4 expected createServerHealthPollURL '" + expectedV4 + "' actual: '" + actualV4 + "'")
+		t.Errorf("incorrect IPv4 polling URL; expected: '%s', actual: '%s'", expectedV4, actualV4)
 	}
+
 	if expectedV6 != actualV6 {
-		t.Errorf("for IPv6 expected createServerHealthPollURL '" + expectedV6 + "' actual: '" + actualV6 + "'")
+		t.Errorf("incorrect IPv6 polling URL; expected: '%s', actual: '%s'", expectedV6, actualV6)
 	}
 
 }
 
 func TestCreateServerHealthPollURLServerPortHTTPS(t *testing.T) {
-	tmpl := `hTTps://${hostname}/_astats?application=&inf.name=${interface_name}`
-	srv := tc.LegacyTrafficServer{IP: "192.0.2.42", IP6: "1::3:3:7", Port: 5678, HTTPSPort: 910, InterfaceName: "george"}
+	tmpl := "hTTps://${hostname}/_astats?application=&inf.name=${interface_name}"
+	srv := tc.TrafficServer{
+		HTTPSPort: 910,
+		Interfaces: []tc.ServerInterfaceInfo{
+			{
+				IPAddresses: []tc.ServerIPAddress{
+					{
+						Address:        "192.0.2.42",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+					{
+						Address:        "1::3:3:7",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+				},
+				MaxBandwidth: nil,
+				MTU:          nil,
+				Monitor:      true,
+				Name:         "george",
+			},
+		},
+		Port: 5678,
+	}
 
-	expectedV4 := `https://` + srv.IP + ":" + strconv.Itoa(srv.HTTPSPort) + `/_astats?application=system&inf.name=` + srv.InterfaceName
-	expectedV6 := `https://[` + srv.IP6 + "]:" + strconv.Itoa(srv.HTTPSPort) + `/_astats?application=system&inf.name=` + srv.InterfaceName
+	expectedV4 := "https://192.0.2.42:910/_astats?application=system&inf.name=george"
+	expectedV6 := "https://[1::3:3:7]:910/_astats?application=system&inf.name=george"
 	actualV4, actualV6 := createServerHealthPollURLs(tmpl, srv)
 
 	if expectedV4 != actualV4 {
-		t.Errorf("for IPv4 expected createServerHealthPollURL '" + expectedV4 + "' actual: '" + actualV4 + "'")
+		t.Errorf("incorrect IPv4 polling URL; expected: '%s', actual: '%s'", expectedV4, actualV4)
 	}
 
 	if expectedV6 != actualV6 {
-		t.Errorf("for IPv6 expected createServerHealthPollURL '" + expectedV6 + "' actual: '" + actualV6 + "'")
+		t.Errorf("incorrect IPv6 polling URL; expected: '%s', actual: '%s'", expectedV6, actualV6)
 	}
 }
 
@@ -98,38 +187,82 @@ func TestCreateServerHealthPollURLTemplateAndServerPort(t *testing.T) {
 
 	// if both template and server ports exist, template takes precedence
 
-	tmpl := `http://${hostname}:1234/_astats?application=&inf.name=${interface_name}`
-	srv := tc.LegacyTrafficServer{IP: "192.0.2.42", IP6: "1::3:3:7", Port: 5678, HTTPSPort: 910, InterfaceName: "george"}
+	tmpl := "http://${hostname}:1234/_astats?application=&inf.name=${interface_name}"
+	srv := tc.TrafficServer{
+		HTTPSPort: 910,
+		Interfaces: []tc.ServerInterfaceInfo{
+			{
+				IPAddresses: []tc.ServerIPAddress{
+					{
+						Address:        "192.0.2.42",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+					{
+						Address:        "1::3:3:7",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+				},
+				MaxBandwidth: nil,
+				MTU:          nil,
+				Monitor:      true,
+				Name:         "george",
+			},
+		},
+		Port: 5678,
+	}
 
-	expectedV4 := `http://` + srv.IP + `:1234/_astats?application=system&inf.name=` + srv.InterfaceName
-	expectedV6 := `http://[` + srv.IP6 + `]:1234/_astats?application=system&inf.name=` + srv.InterfaceName
+	expectedV4 := "http://192.0.2.42:1234/_astats?application=system&inf.name=george"
+	expectedV6 := "http://[1::3:3:7]:1234/_astats?application=system&inf.name=george"
 	actualV4, actualV6 := createServerHealthPollURLs(tmpl, srv)
 
 	if expectedV4 != actualV4 {
-		t.Errorf("for IPv4 expected createServerHealthPollURL '" + expectedV4 + "' actual: '" + actualV4 + "'")
+		t.Errorf("incorrect IPv4 polling URL; expected: '%s', actual: '%s'", expectedV4, actualV4)
 	}
 
 	if expectedV6 != actualV6 {
-		t.Errorf("for IPv6 expected createServerHealthPollURL '" + expectedV6 + "' actual: '" + actualV6 + "'")
+		t.Errorf("incorrect IPv6 polling URL; expected: '%s', actual: '%s'", expectedV6, actualV6)
 	}
 }
 
 func TestCreateServerStatPollURL(t *testing.T) {
 	tmpl := `http://${hostname}/_astats?application=&inf.name=${interface_name}`
-	srv := tc.LegacyTrafficServer{IP: "192.0.2.42", IP6: "1::3:3:7", InterfaceName: "george"}
+	srv := tc.TrafficServer{
+		Interfaces: []tc.ServerInterfaceInfo{
+			{
+				IPAddresses: []tc.ServerIPAddress{
+					{
+						Address:        "192.0.2.42",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+					{
+						Address:        "1::3:3:7",
+						Gateway:        nil,
+						ServiceAddress: true,
+					},
+				},
+				MaxBandwidth: nil,
+				MTU:          nil,
+				Monitor:      true,
+				Name:         "george",
+			},
+		},
+	}
 
-	expectedV4 := `http://` + srv.IP + `/_astats?application=&inf.name=` + srv.InterfaceName
-	expectedV6 := `http://[` + srv.IP6 + `]/_astats?application=&inf.name=` + srv.InterfaceName
+	expectedV4 := "http://192.0.2.42/_astats?application=&inf.name=george"
+	expectedV6 := "http://[1::3:3:7]/_astats?application=&inf.name=george"
 
 	healthV4, healthV6 := createServerHealthPollURLs(tmpl, srv)
 	actualV4 := createServerStatPollURL(healthV4)
 	actualV6 := createServerStatPollURL(healthV6)
 
 	if expectedV4 != actualV4 {
-		t.Errorf("for IPv4 expected createServerStatPollURL '" + expectedV4 + "' actual: '" + actualV4 + "'")
+		t.Errorf("incorrect IPv4 polling URL; expected: '%s', actual: '%s'", expectedV4, actualV4)
 	}
 
 	if expectedV6 != actualV6 {
-		t.Errorf("for IPv6 expected createServerStatPollURL '" + expectedV6 + "' actual: '" + actualV6 + "'")
+		t.Errorf("incorrect IPv6 polling URL; expected: '%s', actual: '%s'", expectedV6, actualV6)
 	}
 }
diff --git a/traffic_monitor/manager/opsconfig.go b/traffic_monitor/manager/opsconfig.go
index eecca61..29fe954 100644
--- a/traffic_monitor/manager/opsconfig.go
+++ b/traffic_monitor/manager/opsconfig.go
@@ -20,12 +20,9 @@ package manager
  */
 
 import (
-	"crypto/tls"
 	"fmt"
 	"io/ioutil"
 	"net"
-	"net/http"
-	"net/http/cookiejar"
 	"time"
 
 	"golang.org/x/sys/unix"
@@ -41,7 +38,6 @@ import (
 	"github.com/apache/trafficcontrol/traffic_monitor/threadsafe"
 	"github.com/apache/trafficcontrol/traffic_monitor/todata"
 	"github.com/apache/trafficcontrol/traffic_monitor/towrap"
-	to "github.com/apache/trafficcontrol/traffic_ops/v2-client"
 
 	"github.com/json-iterator/go"
 )
@@ -50,10 +46,10 @@ import (
 // Note the OpsConfigManager is in charge of the httpServer, because ops config changes trigger server changes. If other things needed to trigger server restarts, the server could be put in its own goroutine with signal channels
 func StartOpsConfigManager(
 	opsConfigFile string,
-	toSession towrap.ITrafficOpsSession,
+	toSession towrap.TrafficOpsSessionThreadsafe,
 	toData todata.TODataThreadsafe,
 	opsConfigChangeSubscribers []chan<- handler.OpsConfig,
-	toChangeSubscribers []chan<- towrap.ITrafficOpsSession,
+	toChangeSubscribers []chan<- towrap.TrafficOpsSessionThreadsafe,
 	localStates peer.CRStatesThreadsafe,
 	peerStates peer.CRStatesPeersThreadsafe,
 	combinedStates peer.CRStatesThreadsafe,
@@ -156,7 +152,6 @@ func StartOpsConfigManager(
 		// TODO config? parameter?
 		useCache := false
 		trafficOpsRequestTimeout := time.Second * time.Duration(10)
-		var realToSession *to.Session
 		var toAddr net.Addr
 		var toLoginCount uint64
 
@@ -169,7 +164,7 @@ func StartOpsConfigManager(
 			backoff = util.NewConstantBackoff(util.ConstantBackoffDuration)
 		}
 		for {
-			realToSession, toAddr, err = to.LoginWithAgent(newOpsConfig.Url, newOpsConfig.Username, newOpsConfig.Password, newOpsConfig.Insecure, staticAppData.UserAgent, useCache, trafficOpsRequestTimeout)
+			err = toSession.Update(newOpsConfig.Url, newOpsConfig.Username, newOpsConfig.Password, newOpsConfig.Insecure, staticAppData.UserAgent, useCache, trafficOpsRequestTimeout)
 			if err != nil {
 				handleErr(fmt.Errorf("MonitorConfigPoller: error instantiating Session with traffic_ops (%v): %s\n", toAddr, err))
 				duration := backoff.BackoffDuration()
@@ -177,21 +172,6 @@ func StartOpsConfigManager(
 				time.Sleep(duration)
 
 				if toSession.BackupFileExists() && (toLoginCount >= cfg.TrafficOpsDiskRetryMax) {
-					jar, err := cookiejar.New(nil)
-					if err != nil {
-						log.Errorf("Err getting cookiejar")
-						continue
-					}
-
-					realToSession = to.NewSession(newOpsConfig.Username, newOpsConfig.Password, newOpsConfig.Url, staticAppData.UserAgent, &http.Client{
-						Timeout: trafficOpsRequestTimeout,
-						Transport: &http.Transport{
-							TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
-						},
-						Jar: jar,
-					}, useCache)
-					toSession.Set(realToSession)
-					// At this point we have a valid 'dummy' session. This will allow us to pull from disk but will also retry when TO comes up
 					log.Errorf("error instantiating Session with traffic_ops, backup disk files exist, creating empty traffic_ops session to read")
 					newOpsConfig.UsingDummyTO = true
 					break
@@ -200,14 +180,13 @@ func StartOpsConfigManager(
 				toLoginCount++
 				continue
 			} else {
-				toSession.Set(realToSession)
 				newOpsConfig.UsingDummyTO = false
 				break
 			}
 		}
 		opsConfig.Set(newOpsConfig)
 
-		if cdn, err := getMonitorCDN(realToSession, staticAppData.Hostname); err != nil {
+		if cdn, err := toSession.MonitorCDN(staticAppData.Hostname); err != nil {
 			handleErr(fmt.Errorf("getting CDN name from Traffic Ops, using config CDN '%s': %s\n", newOpsConfig.CdnName, err))
 		} else {
 			if newOpsConfig.CdnName != "" && newOpsConfig.CdnName != cdn {
@@ -236,7 +215,7 @@ func StartOpsConfigManager(
 			go func(s chan<- handler.OpsConfig) { s <- newOpsConfig }(subscriber)
 		}
 		for _, subscriber := range toChangeSubscribers {
-			go func(s chan<- towrap.ITrafficOpsSession) { s <- toSession }(subscriber)
+			go func(s chan<- towrap.TrafficOpsSessionThreadsafe) { s <- toSession }(subscriber)
 		}
 	}
 
@@ -250,20 +229,3 @@ func StartOpsConfigManager(
 
 	return opsConfig, nil
 }
-
-// getMonitorCDN returns the CDN of a given Traffic Monitor.
-// TODO change to get by name, when Traffic Ops supports querying a single server.
-func getMonitorCDN(toc *to.Session, monitorHostname string) (string, error) {
-	servers, _, err := toc.GetServers()
-	if err != nil {
-		return "", fmt.Errorf("getting monitor %s CDN: %v", monitorHostname, err)
-	}
-
-	for _, server := range servers {
-		if server.HostName != monitorHostname {
-			continue
-		}
-		return server.CDNName, nil
-	}
-	return "", fmt.Errorf("no monitor named %v found in Traffic Ops", monitorHostname)
-}
diff --git a/traffic_monitor/manager/stat.go b/traffic_monitor/manager/stat.go
index 262e583..f0ed1a5 100644
--- a/traffic_monitor/manager/stat.go
+++ b/traffic_monitor/manager/stat.go
@@ -246,7 +246,7 @@ func processStatResults(
 	lastStatEndTimes map[tc.CacheName]time.Time,
 	lastStatDurationsThreadsafe threadsafe.DurationMap,
 	unpolledCaches threadsafe.UnpolledCaches,
-	mc tc.LegacyTrafficMonitorConfigMap,
+	mc tc.TrafficMonitorConfigMap,
 	precomputedData map[tc.CacheName]cache.PrecomputedData,
 	lastResults map[tc.CacheName]cache.Result,
 	localStates peer.CRStatesThreadsafe,
@@ -294,7 +294,7 @@ func processStatResults(
 		// Don't add errored maxes or precomputed DSStats
 		if result.Error == nil {
 			// max and precomputed always contain the latest result from each cache
-			statMaxKbpses.AddMax(result)
+			statMaxKbpses[result.ID] = uint64(result.PrecomputedData.MaxKbps)
 			// if we failed to compute the OutBytes, keep the outbytes of the last result.
 			if result.PrecomputedData.OutBytes == 0 {
 				result.PrecomputedData.OutBytes = precomputedData[tc.CacheName(result.ID)].OutBytes
diff --git a/traffic_monitor/poller/monitorconfig.go b/traffic_monitor/poller/monitorconfig.go
index 4b5e90f..8f2e830 100644
--- a/traffic_monitor/poller/monitorconfig.go
+++ b/traffic_monitor/poller/monitorconfig.go
@@ -31,12 +31,12 @@ import (
 
 type MonitorCfg struct {
 	CDN string
-	Cfg tc.LegacyTrafficMonitorConfigMap
+	Cfg tc.TrafficMonitorConfigMap
 }
 
 type MonitorConfigPoller struct {
-	Session          towrap.ITrafficOpsSession
-	SessionChannel   chan towrap.ITrafficOpsSession
+	Session          towrap.TrafficOpsSessionThreadsafe
+	SessionChannel   chan towrap.TrafficOpsSessionThreadsafe
 	ConfigChannel    chan MonitorCfg
 	OpsConfigChannel chan handler.OpsConfig
 	Interval         time.Duration
@@ -49,7 +49,7 @@ type MonitorConfigPoller struct {
 func NewMonitorConfig(interval time.Duration) MonitorConfigPoller {
 	return MonitorConfigPoller{
 		Interval:       interval,
-		SessionChannel: make(chan towrap.ITrafficOpsSession),
+		SessionChannel: make(chan towrap.TrafficOpsSessionThreadsafe),
 		// ConfigChannel MUST have a buffer size 1, to make the nonblocking writeConfig work
 		ConfigChannel:    make(chan MonitorCfg, 1),
 		OpsConfigChannel: make(chan handler.OpsConfig),
@@ -104,7 +104,7 @@ func (p MonitorConfigPoller) Poll() {
 			tick.Stop()
 			tick = time.NewTicker(p.Interval)
 		case <-tick.C:
-			if p.Session == nil || p.OpsConfig.CdnName == "" {
+			if !p.Session.Initialized() || p.OpsConfig.CdnName == "" {
 				log.Warnln("MonitorConfigPoller: skipping this iteration, Session is nil")
 				continue
 			}
diff --git a/traffic_monitor/static/style.css b/traffic_monitor/static/style.css
index 59bc487..e7c7e52 100644
--- a/traffic_monitor/static/style.css
+++ b/traffic_monitor/static/style.css
@@ -44,11 +44,11 @@ tbody tr:nth-child(odd) {
 	background: #fff;
 }
 .error {
-	background-color: #f00;
+	background-color: #f00!important;
 }
 
 .warning {
-	background-color: #f80;
+	background-color: #f80!important;
 }
 
 #cache-states td:nth-child(n+4),
diff --git a/traffic_monitor/threadsafe/cacheavailablestatus.go b/traffic_monitor/threadsafe/cacheavailablestatus.go
index 2a9cec2..6122628 100644
--- a/traffic_monitor/threadsafe/cacheavailablestatus.go
+++ b/traffic_monitor/threadsafe/cacheavailablestatus.go
@@ -22,30 +22,33 @@ package threadsafe
 import (
 	"sync"
 
-	"github.com/apache/trafficcontrol/lib/go-tc"
 	"github.com/apache/trafficcontrol/traffic_monitor/cache"
 )
 
-// CacheAvailableStatus wraps a map of cache available statuses to be safe for multiple reader goroutines and one writer.
+// CacheAvailableStatus wraps a map of cache available statuses to be safe for
+// multiple reader goroutines and one writer.
 type CacheAvailableStatus struct {
 	caches *cache.AvailableStatuses
 	m      *sync.RWMutex
 }
 
-// NewCacheAvailableStatus creates and returns a new CacheAvailableStatus, initializing internal pointer values.
+// NewCacheAvailableStatus creates and returns a new CacheAvailableStatus,
+// initializing internal pointer values.
 func NewCacheAvailableStatus() CacheAvailableStatus {
-	c := cache.AvailableStatuses(map[tc.CacheName]map[string]cache.AvailableStatus{})
+	c := cache.AvailableStatuses(map[string]cache.AvailableStatus{})
 	return CacheAvailableStatus{m: &sync.RWMutex{}, caches: &c}
 }
 
-// Get returns the internal map of cache statuses. The returned map MUST NOT be modified. If modification is necessary, copy.
+// Get returns the internal map of cache statuses. The returned map MUST NOT be
+// modified. If modification is necessary, copy.
 func (o *CacheAvailableStatus) Get() cache.AvailableStatuses {
 	o.m.RLock()
 	defer o.m.RUnlock()
 	return *o.caches
 }
 
-// Set sets the internal map of cache availability. This MUST NOT be called by multiple goroutines.
+// Set sets the internal map of cache availability. This MUST NOT be called by
+// multiple goroutines.
 func (o *CacheAvailableStatus) Set(v cache.AvailableStatuses) {
 	o.m.Lock()
 	*o.caches = v
diff --git a/traffic_monitor/threadsafe/cachemaxkbpses.go b/traffic_monitor/threadsafe/cachemaxkbpses.go
index 8bdae30..a750d0c 100644
--- a/traffic_monitor/threadsafe/cachemaxkbpses.go
+++ b/traffic_monitor/threadsafe/cachemaxkbpses.go
@@ -22,34 +22,33 @@ package threadsafe
 import (
 	"sync"
 
-	"github.com/apache/trafficcontrol/lib/go-tc"
 	"github.com/apache/trafficcontrol/traffic_monitor/cache"
 )
 
-// CacheAvailableStatus wraps a map of cache available statuses to be safe for
-// multiple reader goroutines and one writer.
+// CacheKbpses wraps a map of cache kbps measurements to be safe for multiple
+// reader goroutines and one writer.
 type CacheKbpses struct {
 	v *cache.Kbpses
 	m *sync.RWMutex
 }
 
-// NewCacheAvailableStatus creates and returns a new CacheAvailableStatus,
+// NewCacheKbpses creates and returns a new, empty CacheKbpses,
 // initializing internal pointer values.
 func NewCacheKbpses() CacheKbpses {
-	v := cache.Kbpses(map[tc.CacheName]int64{})
+	v := cache.Kbpses{}
 	return CacheKbpses{m: &sync.RWMutex{}, v: &v}
 }
 
-// Get returns the internal map of cache statuses. The returned map MUST NOT be
-// modified. If modification is necessary, copy.
+// Get returns the internal map of cache kbps measurements. The returned map
+// MUST NOT be modified. If modification is necessary, copy.
 func (o *CacheKbpses) Get() cache.Kbpses {
 	o.m.RLock()
 	defer o.m.RUnlock()
 	return *o.v
 }
 
-// Set sets the internal map of cache availability. This MUST NOT be called by
-// multiple goroutines.
+// Set sets the internal map of cache kbps measurements. This MUST NOT be called
+// by multiple goroutines.
 func (o *CacheKbpses) Set(v cache.Kbpses) {
 	o.m.Lock()
 	*o.v = v
diff --git a/traffic_monitor/threadsafe/monitorconfig.go b/traffic_monitor/threadsafe/monitorconfig.go
index d06f336..70ffcaa 100644
--- a/traffic_monitor/threadsafe/monitorconfig.go
+++ b/traffic_monitor/threadsafe/monitorconfig.go
@@ -27,24 +27,24 @@ import (
 
 // TrafficMonitorConfigMapThreadsafe encapsulates a LegacyTrafficMonitorConfigMap safe for multiple readers and a single writer.
 type TrafficMonitorConfigMap struct {
-	monitorConfig *tc.LegacyTrafficMonitorConfigMap
+	monitorConfig *tc.TrafficMonitorConfigMap
 	m             *sync.RWMutex
 }
 
 // NewTrafficMonitorConfigMap returns an encapsulated LegacyTrafficMonitorConfigMap safe for multiple readers and a single writer.
 func NewTrafficMonitorConfigMap() TrafficMonitorConfigMap {
-	return TrafficMonitorConfigMap{monitorConfig: &tc.LegacyTrafficMonitorConfigMap{}, m: &sync.RWMutex{}}
+	return TrafficMonitorConfigMap{monitorConfig: &tc.TrafficMonitorConfigMap{}, m: &sync.RWMutex{}}
 }
 
 // Get returns the LegacyTrafficMonitorConfigMap. Callers MUST NOT modify, it is not threadsafe for mutation. If mutation is necessary, call CopyTrafficMonitorConfigMap().
-func (t *TrafficMonitorConfigMap) Get() tc.LegacyTrafficMonitorConfigMap {
+func (t *TrafficMonitorConfigMap) Get() tc.TrafficMonitorConfigMap {
 	t.m.RLock()
 	defer t.m.RUnlock()
 	return *t.monitorConfig
 }
 
 // Set sets the LegacyTrafficMonitorConfigMap. This is only safe for one writer. This MUST NOT be called by multiple threads.
-func (t *TrafficMonitorConfigMap) Set(c tc.LegacyTrafficMonitorConfigMap) {
+func (t *TrafficMonitorConfigMap) Set(c tc.TrafficMonitorConfigMap) {
 	t.m.Lock()
 	*t.monitorConfig = c
 	t.m.Unlock()
diff --git a/traffic_monitor/threadsafe/resultstathistory.go b/traffic_monitor/threadsafe/resultstathistory.go
index 5835cd7..521a827 100644
--- a/traffic_monitor/threadsafe/resultstathistory.go
+++ b/traffic_monitor/threadsafe/resultstathistory.go
@@ -24,6 +24,7 @@ import (
 	"errors"
 	"fmt"
 	"net/url"
+	"strings"
 	"sync"
 	"time"
 
@@ -35,6 +36,14 @@ import (
 	jsoniter "github.com/json-iterator/go"
 )
 
+// InterfaceStatNames is a "set" of the names of all the statistics that may be
+// found on a network interface of a cache server.
+const (
+	InterfaceStatNameBytesIn  = "inBytes"
+	InterfaceStatNameBytesOut = "outBytes"
+	InterfaceStatNameSpeed    = "speed"
+)
+
 // ResultStatHistory provides safe access for multiple goroutines readers and a single writer to a stored HistoryHistory object.
 // This could be made lock-free, if the performance was necessary
 type ResultInfoHistory struct {
@@ -55,55 +64,213 @@ func (h *ResultInfoHistory) Get() cache.ResultInfoHistory {
 	return *h.history
 }
 
-// Set sets the internal ResultInfoHistory. This is only safe for one thread of execution. This MUST NOT be called from multiple threads.
+// Set sets the internal ResultInfoHistory. This is only safe for one thread of
+// execution. This MUST NOT be called from multiple threads.
 func (h *ResultInfoHistory) Set(v cache.ResultInfoHistory) {
 	h.m.Lock()
 	*h.history = v
 	h.m.Unlock()
 }
 
-type ResultStatHistory struct{ *sync.Map } // map[tc.CacheName]map[interfaceName]ResultStatValHistory
+// ResultStatHistory is a thread-safe mapping of cache server hostnames to
+// CacheStatHistory objects containing statistics for those cache servers.
+type ResultStatHistory struct{ *sync.Map } // map[string]CacheStatHistory
 
+// NewResultStatHistory constructs a new, empty ResultStatHistory.
 func NewResultStatHistory() ResultStatHistory {
 	return ResultStatHistory{&sync.Map{}}
 }
 
-func (h ResultStatHistory) LoadOrStore(cache tc.CacheName) map[string]ResultStatValHistory {
+// LoadOrStore returns the stored CacheStatHistory for the given cache server
+// hostname if it has already been stored. If it has not already been stored, a
+// new, empty CacheStatHistory object is created, stored under the given
+// hostname, and returned.
+func (h ResultStatHistory) LoadOrStore(hostname string) CacheStatHistory {
 	// TODO change to use sync.Pool?
-	v, loaded := h.Map.LoadOrStore(cache, NewResultStatValHistory())
-	if !loaded {
-		v = map[string]ResultStatValHistory{}
-	}
-	if rv, ok := v.(ResultStatValHistory); ok {
-		v = map[string]ResultStatValHistory{tc.CacheInterfacesAggregate: rv}
+	v, _ := h.Map.LoadOrStore(hostname, NewCacheStatHistory())
+	rv, ok := v.(CacheStatHistory)
+	if !ok {
+		log.Errorf("Failed to load or store stat history for '%s': invalid stored type.", hostname)
+		return NewCacheStatHistory()
 	}
-	return v.(map[string]ResultStatValHistory)
+
+	return rv
 }
 
-// Range behaves like sync.Map.Range. It calls f for every value in the map; if f returns false, the iteration is stopped.
-func (h ResultStatHistory) Range(f func(cache tc.CacheName, interfaceName string, val ResultStatValHistory) bool) {
+// Range behaves like sync.Map.Range. It calls f for every value in the map; if
+// f returns false, the iteration is stopped.
+func (h ResultStatHistory) Range(f func(cacheName string, val CacheStatHistory) bool) {
 	h.Map.Range(func(k, v interface{}) bool {
-		i, ok := v.(map[string]ResultStatValHistory)
+		i, ok := v.(CacheStatHistory)
 		if !ok {
-			log.Warnln("Cannot umarshal result stat val history")
+			log.Warnf("Non-CacheStatHistory object (%T) found in ResultStatHistory during Range.", v)
 			return true
 		}
-		for a, b := range i {
-			if !f(k.(tc.CacheName), a, b) {
-				return false
-			}
+		cacheName, ok := k.(string)
+		if !ok {
+			log.Warnf("Non-string object (%T) found as key in ResultStatHistory during Range.", k)
+			return true
 		}
-		return true
+		return f(cacheName, i)
 	})
 }
 
-// ResultStatValHistory is threadsafe for one writer. Specifically, because a CompareAndSwap is not provided, it's not possible to Load and Store without a race condition.
-// If multiple writers were necessary, it wouldn't be difficult to add a CompareAndSwap, internally storing an atomically-accessed pointer to the slice.
+// interfaceStat is just a convenience structure used only for passing data
+// about a single statistic for a network interface into
+// compareAndAppendStatForInterface.
+type interfaceStat struct {
+	InterfaceName string
+	Stat          interface{}
+	StatName      string
+	Time          time.Time
+}
+
+// compareAndAppendStatForInterface is a little helper function used to compare
+// a single stat for a single network interface to its historical values and do
+// the appropriate appending and management of the history to ensure it never
+// exceeds `limit`.
+func compareAndAppendStatForInterface(history []cache.ResultStatVal, errs strings.Builder, limit uint64, stat interfaceStat) []cache.ResultStatVal {
+	if history == nil {
+		history = make([]cache.ResultStatVal, 0, limit)
+	}
+
+	ok, err := newStatEqual(history, stat.Stat)
+	if err != nil {
+		errs.WriteString(stat.InterfaceName)
+		errs.Write([]byte(": cannot add stat "))
+		errs.WriteString(stat.StatName)
+		errs.Write([]byte(": "))
+		errs.WriteString(err.Error())
+		errs.Write([]byte("; "))
+	} else if ok {
+		history[0].Time = stat.Time
+		history[0].Span++
+	} else {
+		if uint64(len(history)) > limit {
+			history = history[:limit]
+		} else if uint64(len(history)) < limit {
+			history = append(history, cache.ResultStatVal{})
+		}
+
+		for i := len(history) - 1; i >= 1; i-- {
+			history[i] = history[i-1]
+		}
+		history[0] = cache.ResultStatVal{
+			Val:  stat.Stat,
+			Time: stat.Time,
+			Span: 1,
+		}
+	}
+	return history
+}
+
+// Add adds the given result to the stored statistics history, keeping only up
+// to `limit` number of records for any given stat (oldest records will be
+// removed to make way for new ones if the limit would otherwise be exceeded).
+//
+// If `limit` is zero, it will be treated as though it were one instead.
+func (a ResultStatHistory) Add(r cache.Result, limit uint64) error {
+	var errStrs strings.Builder
+	cacheHistory := a.LoadOrStore(r.ID)
+	if limit == 0 {
+		log.Warnln("ResultStatHistory.Add got limit 0 - setting to 1")
+		limit = 1
+	}
+
+	for statName, statVal := range r.Miscellaneous {
+		statHistory := cacheHistory.Stats.Load(statName)
+		if statHistory == nil {
+			statHistory = make([]cache.ResultStatVal, 0, limit)
+		}
+
+		ok, err := newStatEqual(statHistory, statVal)
+		// If the new stat value is the same as the last, update the time and
+		// increment the span. Span is the number of polls the latest value has
+		// been the same, and hence the length of time it's been the same is
+		// span*pollInterval.
+		if err != nil {
+			errStrs.Write([]byte("cannot add stat "))
+			errStrs.WriteString(statName)
+			errStrs.Write([]byte(": "))
+			errStrs.WriteString(err.Error())
+			errStrs.Write([]byte("; "))
+		} else if ok {
+			statHistory[0].Time = r.Time
+			statHistory[0].Span++
+		} else {
+			if uint64(len(statHistory)) > limit {
+				statHistory = statHistory[:limit]
+			} else if uint64(len(statHistory)) < limit {
+				statHistory = append(statHistory, cache.ResultStatVal{})
+			}
+
+			for i := len(statHistory) - 1; i >= 1; i-- {
+				statHistory[i] = statHistory[i-1]
+			}
+			statHistory[0] = cache.ResultStatVal{
+				Val:  statVal,
+				Time: r.Time,
+				Span: 1,
+			}
+		}
+		cacheHistory.Stats.Store(statName, statHistory)
+
+	}
+
+	stat := interfaceStat{
+		Time: r.Time,
+	}
+	for interfaceName, inf := range r.Interfaces() {
+		statHistory, ok := cacheHistory.Interfaces[interfaceName]
+		if !ok {
+			statHistory = NewResultStatValHistory()
+			cacheHistory.Interfaces[interfaceName] = statHistory
+		}
+
+		speedHistory := statHistory.Load(InterfaceStatNameSpeed)
+
+		stat.InterfaceName = interfaceName
+		stat.Stat = inf.Speed
+		stat.StatName = InterfaceStatNameSpeed
+
+		speedHistory = compareAndAppendStatForInterface(speedHistory, errStrs, limit, stat)
+		statHistory.Store(InterfaceStatNameSpeed, speedHistory)
+
+		outHistory := statHistory.Load(InterfaceStatNameBytesOut)
+
+		stat.Stat = inf.BytesOut
+		stat.StatName = InterfaceStatNameBytesOut
+
+		outHistory = compareAndAppendStatForInterface(outHistory, errStrs, limit, stat)
+		statHistory.Store(InterfaceStatNameBytesOut, outHistory)
+
+		inHistory := statHistory.Load(InterfaceStatNameBytesIn)
+
+		stat.Stat = inf.BytesIn
+		stat.StatName = InterfaceStatNameBytesIn
+
+		inHistory = compareAndAppendStatForInterface(inHistory, errStrs, limit, stat)
+		statHistory.Store(InterfaceStatNameBytesIn, inHistory)
+	}
+
+	if errStrs.Len() > 0 {
+		errStr := errStrs.String()
+		return errors.New("some stats could not be added: " + errStr[:len(errStr)-2])
+	}
+	return nil
+}
+
+// ResultStatValHistory is thread-safe for one writer. Specifically, because a
+// CompareAndSwap is not provided, it's not possible to Load and Store without
+// a race condition. If multiple writers were necessary, it wouldn't be
+// difficult to add a CompareAndSwap, internally storing an atomically-accessed
+// pointer to the slice.
 type ResultStatValHistory struct{ *sync.Map } //  map[string][]ResultStatVal
 
 func NewResultStatValHistory() ResultStatValHistory { return ResultStatValHistory{&sync.Map{}} }
 
-// Load returns the []ResultStatVal for the given stat. If the given stat does not exist, nil is returned.
+// Load returns the []ResultStatVal for the given stat. If the given stat does
+// not exist, nil is returned.
 func (h ResultStatValHistory) Load(stat string) []cache.ResultStatVal {
 	i, ok := h.Map.Load(stat)
 	if !ok {
@@ -112,99 +279,92 @@ func (h ResultStatValHistory) Load(stat string) []cache.ResultStatVal {
 	return i.([]cache.ResultStatVal)
 }
 
-// Range behaves like sync.Map.Range. It calls f for every value in the map; if f returns false, the iteration is stopped.
+// Range behaves like sync.Map.Range. It calls f for every value in the map; if
+// f returns false, the iteration is stopped.
 func (h ResultStatValHistory) Range(f func(stat string, val []cache.ResultStatVal) bool) {
 	h.Map.Range(func(k, v interface{}) bool {
 		return f(k.(string), v.([]cache.ResultStatVal))
 	})
 }
 
-// Store stores the given []ResultStatVal in the ResultStatValHistory for the given stat. Store is threadsafe for only one writer.
-// Specifically, if there are multiple writers, there's a race, that one writer could Load(), another writer could Store() underneath it,
-// and the first writer would then Store() having lost values.
-// To safely use ResultStatValHistory with multiple writers, a CompareAndSwap function would have to be added.
+// Store stores the given []ResultStatVal in the ResultStatValHistory for the
+// given stat. Store is thread-safe for only one writer. Specifically, if there
+// are multiple writers, there's a race, that one writer could Load(), another
+// writer could Store() underneath it, and the first writer would then Store()
+// having lost values. To safely use ResultStatValHistory with multiple writers,
+// a CompareAndSwap method would have to be added.
 func (h ResultStatValHistory) Store(stat string, vals []cache.ResultStatVal) {
 	h.Map.Store(stat, vals)
 }
 
-func (a ResultStatHistory) Add(r cache.Result, limit uint64) error {
-	errStrs := ""
-	resultHistory := a.LoadOrStore(tc.CacheName(r.ID))
-	if limit == 0 {
-		log.Warnln("ResultStatHistory.Add got limit 0 - setting to 1")
-		limit = 1
-	}
-
-	for statName, statVal := range r.Miscellaneous {
-		for interfaceName, _ := range resultHistory {
-			statHistory := resultHistory[interfaceName].Load(statName)
-			if len(statHistory) == 0 {
-				statHistory = make([]cache.ResultStatVal, 0, limit) // initialize to the limit, to avoid multiple allocations. TODO put in .Load(statName, defaultSize)?
-			}
-
-			ok, err := newStatEqual(statHistory, statVal)
-
-			// If the new stat value is the same as the last, update the time and increment the span. Span is the number of polls the latest value has been the same, and hence the length of time it's been the same is span*pollInterval.
-			if err != nil {
-				errStrs += "cannot add stat " + statName + ": " + err.Error() + "; "
-			} else if ok {
-				statHistory[0].Time = r.Time
-				statHistory[0].Span++
-			} else {
-				resultVal := cache.ResultStatVal{
-					Val:  statVal,
-					Time: r.Time,
-					Span: 1,
-				}
-
-				if len(statHistory) > int(limit) {
-					statHistory = statHistory[:int(limit)]
-				} else if len(statHistory) < int(limit) {
-					statHistory = append(statHistory, cache.ResultStatVal{})
-				}
-				// shift all values to the right, in order to put the new val at the beginning. Faster than allocating memory again
-				for i := len(statHistory) - 1; i >= 1; i-- {
-					statHistory[i] = statHistory[i-1]
-				}
-				statHistory[0] = resultVal // new result at the beginning
-			}
-			resultHistory[interfaceName].Store(statName, statHistory)
-		}
-	}
+// CacheStatHistory is the type of a single record in a ResultStatHistory map.
+// It contains interface statistics as well as historical statistics for each
+// of a cache server's polled interfaces.
+type CacheStatHistory struct {
+	// Interfaces is a map of the names of network interfaces that have been
+	// polled for this cache server to historical collections of their polled
+	// statistics.
+	Interfaces map[string]ResultStatValHistory
+	// Stats is a historical collection of all of the cache server's generic
+	// (non-interface-dependent) statistics.
+	Stats ResultStatValHistory
+}
 
-	if errStrs != "" {
-		return errors.New("some stats could not be added: " + errStrs[:len(errStrs)-2])
+// NewCacheStatHistory constructs a new empty CacheStatHistory.
+func NewCacheStatHistory() CacheStatHistory {
+	return CacheStatHistory{
+		Interfaces: make(map[string]ResultStatValHistory),
+		Stats:      NewResultStatValHistory(),
 	}
-	return nil
 }
 
-// newStatEqual Returns whether the given stat is equal to the latest stat in history. If len(history)==0, this returns false without error. If the given stat is not a JSON primitive (string, number, bool), this returns an error. We explicitly refuse to compare arrays and objects, for performance.
+// newStatEqual returns whether the given stat is equal to the latest stat in
+// history. If len(history)==0, this returns false without error. If the given
+// stat is not a JSON primitive (string, number, bool), this returns an error.
+// We explicitly refuse to compare arrays and objects, for performance.
 func newStatEqual(history []cache.ResultStatVal, stat interface{}) (bool, error) {
 	if len(history) == 0 {
 		return false, nil // if there's no history, it's "not equal", i.e. store this new history
 	}
 	switch stat.(type) {
-	case string:
-	case float64:
 	case bool:
+	case float64:
+	case int64:
+	case string:
+	case uint64:
 	default:
 		return false, fmt.Errorf("incomparable stat type %T", stat)
 	}
 	switch history[0].Val.(type) {
-	case string:
-	case float64:
 	case bool:
+	case float64:
+	case int64:
+	case string:
+	case uint64:
 	default:
 		return false, fmt.Errorf("incomparable history stat type %T", stat)
 	}
 	return stat == history[0].Val, nil
 }
 
-// StatsMarshall encodes the stats in JSON, encoding up to historyCount of each stat. If statsToUse is empty, all stats are encoded; otherwise, only the given stats are encoded. If wildcard is true, stats which contain the text in each statsToUse are returned, instead of exact stat names. If cacheType is not CacheTypeInvalid, only stats for the given type are returned. If hosts is not empty, only the given hosts are returned.
-func StatsMarshall(statResultHistory ResultStatHistory, statInfo cache.ResultInfoHistory, combinedStates tc.CRStates, monitorConfig tc.LegacyTrafficMonitorConfigMap, statMaxKbpses cache.Kbpses, filter cache.Filter, params url.Values) ([]byte, error) {
+// StatsMarshall encodes the stats in JSON, encoding up to historyCount of each
+// stat. If statsToUse is empty, all stats are encoded; otherwise, only the
+// given stats are encoded. If `wildcard` is true, stats which contain the text
+// in each statsToUse are returned, instead of exact stat names. If cacheType is
+// not CacheTypeInvalid, only stats for the given type are returned. If hosts is
+// not empty, only the given hosts are returned.
+func StatsMarshall(
+	statResultHistory ResultStatHistory,
+	statInfo cache.ResultInfoHistory,
+	combinedStates tc.CRStates,
+	monitorConfig tc.TrafficMonitorConfigMap,
+	statMaxKbpses cache.Kbpses,
+	filter cache.Filter,
+	params url.Values,
+) ([]byte, error) {
 	stats := cache.Stats{
 		CommonAPIData: srvhttp.GetCommonAPIData(params, time.Now()),
-		Caches:        map[tc.CacheName]map[string]map[string][]cache.ResultStatVal{},
+		Caches:        map[string]cache.ServerStats{},
 	}
 
 	computedStats := cache.ComputedStats()
@@ -216,27 +376,54 @@ func StatsMarshall(statResultHistory ResultStatHistory, statInfo cache.ResultInf
 			continue
 		}
 
-		cacheStatResultHistory := statResultHistory.LoadOrStore(id)
-		for interfaceName, interfaceHistory := range cacheStatResultHistory {
+		cacheId := string(id)
+
+		cacheStatResultHistory := statResultHistory.LoadOrStore(cacheId)
+		if _, ok := stats.Caches[cacheId]; !ok {
+			stats.Caches[cacheId] = cache.ServerStats{
+				Interfaces: make(map[string]map[string][]cache.ResultStatVal),
+				Stats:      make(map[string][]cache.ResultStatVal),
+			}
+		}
+
+		cacheStatResultHistory.Stats.Range(func(stat string, vals []cache.ResultStatVal) bool {
+			stat = "ats." + stat // legacy reasons
+			if !filter.UseStat(stat) {
+				return true
+			}
+
+			var historyCount uint64 = 1
+			for _, val := range vals {
+				if !filter.WithinStatHistoryMax(historyCount) {
+					break
+				}
+				if _, ok := stats.Caches[cacheId].Stats[stat]; !ok {
+					stats.Caches[cacheId].Stats[stat] = []cache.ResultStatVal{val}
+				} else {
+					stats.Caches[cacheId].Stats[stat] = append(stats.Caches[cacheId].Stats[stat], val)
+				}
+				historyCount += val.Span
+			}
+
+			return true
+		})
+
+		for interfaceName, interfaceHistory := range cacheStatResultHistory.Interfaces {
 			interfaceHistory.Range(func(stat string, vals []cache.ResultStatVal) bool {
-				stat = "ats." + stat // TM1 prefixes ATS stats with 'ats.'
-				if !filter.UseStat(stat) {
+				if !filter.UseInterfaceStat(stat) {
 					return true
 				}
-				historyCount := 1
-				if _, ok := stats.Caches[id]; !ok {
-					stats.Caches[id] = map[string]map[string][]cache.ResultStatVal{}
-				}
+
+				var historyCount uint64 = 1
 				for _, val := range vals {
 					if !filter.WithinStatHistoryMax(historyCount) {
 						break
 					}
-					if _, ok := stats.Caches[id][interfaceName]; !ok {
-						stats.Caches[id][interfaceName] = map[string][]cache.ResultStatVal{}
+					if _, ok := stats.Caches[cacheId].Interfaces[interfaceName]; !ok {
+						stats.Caches[cacheId].Interfaces[interfaceName] = map[string][]cache.ResultStatVal{}
 					}
-					stats.Caches[id][interfaceName][stat] = append(stats.Caches[id][interfaceName][stat], val)
-					// Todo add for each interface?
-					historyCount += int(val.Span)
+					stats.Caches[cacheId].Interfaces[interfaceName][stat] = append(stats.Caches[cacheId].Interfaces[interfaceName][stat], val)
+					historyCount += val.Span
 				}
 				return true
 			})
@@ -253,12 +440,9 @@ func StatsMarshall(statResultHistory ResultStatHistory, statInfo cache.ResultInf
 		}
 
 		for i, resultInfo := range statInfo[id] {
-			if !filter.WithinStatHistoryMax(i + 1) {
+			if !filter.WithinStatHistoryMax(uint64(i) + 1) {
 				break
 			}
-			if _, ok := stats.Caches[id]; !ok {
-				stats.Caches[id] = map[string]map[string][]cache.ResultStatVal{}
-			}
 
 			t := resultInfo.Time
 
@@ -266,19 +450,12 @@ func StatsMarshall(statResultHistory ResultStatHistory, statInfo cache.ResultInf
 				if !filter.UseStat(stat) {
 					continue
 				}
-				if _, ok := stats.Caches[id][tc.CacheInterfacesAggregate]; !ok {
-					stats.Caches[id][tc.CacheInterfacesAggregate] = map[string][]cache.ResultStatVal{}
-				}
-				stats.Caches[id][tc.CacheInterfacesAggregate][stat] = append(stats.Caches[id][tc.CacheInterfacesAggregate][stat],
-					cache.ResultStatVal{Val: statValF(resultInfo, serverInfo, serverProfile, combinedStatesCache), Time: t, Span: 1})
-				// Need to actually handle interfaces, needs vitals to be refactored
-				for infName, _ := range resultInfo.Statistics.Interfaces {
-					if _, ok := stats.Caches[id][infName]; !ok {
-						stats.Caches[id][infName] = map[string][]cache.ResultStatVal{}
-					}
-					stats.Caches[id][infName][stat] = append(stats.Caches[id][infName][stat],
-						cache.ResultStatVal{Val: statValF(resultInfo, serverInfo, serverProfile, combinedStatesCache), Time: t, Span: 1})
+				rv := cache.ResultStatVal{
+					Span: 1,
+					Time: t,
+					Val:  statValF(resultInfo, serverInfo, serverProfile, combinedStatesCache),
 				}
+				stats.Caches[cacheId].Stats[stat] = append(stats.Caches[cacheId].Stats[stat], rv)
 			}
 		}
 	}
diff --git a/traffic_monitor/threadsafe/resultstathistory_test.go b/traffic_monitor/threadsafe/resultstathistory_test.go
index f85a7e2..38fd9d1 100644
--- a/traffic_monitor/threadsafe/resultstathistory_test.go
+++ b/traffic_monitor/threadsafe/resultstathistory_test.go
@@ -119,15 +119,19 @@ func randBool() bool {
 type DummyFilterNever struct {
 }
 
-func (f DummyFilterNever) UseStat(name string) bool {
+func (DummyFilterNever) UseStat(string) bool {
 	return false
 }
 
-func (f DummyFilterNever) UseCache(name tc.CacheName) bool {
+func (DummyFilterNever) UseInterfaceStat(string) bool {
 	return false
 }
 
-func (f DummyFilterNever) WithinStatHistoryMax(i int) bool {
+func (DummyFilterNever) UseCache(tc.CacheName) bool {
+	return false
+}
+
+func (DummyFilterNever) WithinStatHistoryMax(uint64) bool {
 	return false
 }
 
@@ -137,14 +141,11 @@ func TestStatsMarshall(t *testing.T) {
 	filter := DummyFilterNever{}
 	params := url.Values{}
 	beforeStatsMarshall := time.Now()
-	bytes, err := StatsMarshall(statHist, infHist, tc.CRStates{}, tc.LegacyTrafficMonitorConfigMap{}, cache.Kbpses{}, filter, params)
+	bytes, err := StatsMarshall(statHist, infHist, tc.CRStates{}, tc.TrafficMonitorConfigMap{}, cache.Kbpses{}, filter, params)
 	afterStatsMarshall := time.Now()
 	if err != nil {
 		t.Fatalf("StatsMarshall return expected nil err, actual err: %v", err)
 	}
-	// if len(bytes) > 0 {
-	// 	t.Errorf("expected empty bytes, actual: %v", string(bytes))
-	// }
 
 	stats := cache.Stats{}
 	json := jsoniter.ConfigFastest // TODO make configurable
@@ -173,7 +174,7 @@ func TestSystemComputedStats(t *testing.T) {
 
 	for stat, function := range stats {
 		if strings.HasPrefix(stat, "system.") {
-			computedStat := function(cache.ResultInfo{}, tc.LegacyTrafficServer{}, tc.TMProfile{}, tc.IsAvailable{})
+			computedStat := function(cache.ResultInfo{}, tc.TrafficServer{}, tc.TMProfile{}, tc.IsAvailable{})
 			_, err := newStatEqual([]cache.ResultStatVal{{Val: float64(0)}}, computedStat)
 			if err != nil {
 				t.Errorf("expected no errors from newStatEqual: %s", err)
@@ -181,3 +182,97 @@ func TestSystemComputedStats(t *testing.T) {
 		}
 	}
 }
+
+func TestcompareAndAppendStatForInterface(t *testing.T) {
+	var errs strings.Builder
+	var limit uint64 = 1
+	stat := interfaceStat{
+		InterfaceName: "test",
+		Stat:          uint64(5),
+		StatName:      "test",
+		Time:          time.Now(),
+	}
+
+	history := compareAndAppendStatForInterface(nil, errs, limit, stat)
+	if errs.Len() > 0 {
+		t.Errorf("Unexpected errors comparing previously non-existent interface stat: %s", errs.String())
+	}
+	if len(history) == 0 {
+		t.Fatal("Empty history after comparing previously non-existent interface stat")
+	}
+	if len(history) > 1 {
+		t.Fatalf("Too many stats returned from comparing previously non-existent interface stat: %d", len(history))
+	}
+
+	result := history[0]
+	if result.Span != 1 {
+		t.Errorf("Incorrect span comparing previously non-existent interface stat; want: 1, got: %d", result.Span)
+	}
+
+	if result.Time != stat.Time {
+		t.Errorf("Incorrect time comparing previously non-existent interface stat; want: %v, got: %v", stat.Time, result.Time)
+	}
+
+	if v, ok := result.Val.(uint64); !ok {
+		t.Errorf("Incorrect value type from comparing previously non-existent interface stat; want: uint64, got: %T", result.Val)
+	} else if v != stat.Stat {
+		t.Errorf("Incorrect value from comparing previously non-existent interface stat; want: %d, got: %d", stat.Stat, v)
+	}
+
+	errs.Reset()
+
+	history = compareAndAppendStatForInterface(history, errs, limit, stat)
+	if errs.Len() > 0 {
+		t.Errorf("Unexpected errors comparing previously non-existent interface stat: %s", errs.String())
+	}
+	if len(history) == 0 {
+		t.Fatal("Empty history after comparing previously non-existent interface stat")
+	}
+	if len(history) > 1 {
+		t.Fatalf("Too many stats returned from comparing previously non-existent interface stat: %d", len(history))
+	}
+
+	result = history[0]
+	if result.Span != 2 {
+		t.Errorf("Incorrect span comparing previously non-existent interface stat; want: 2, got: %d", result.Span)
+	}
+
+	if result.Time != stat.Time {
+		t.Errorf("Incorrect time comparing previously non-existent interface stat; want: %v, got: %v", stat.Time, result.Time)
+	}
+
+	if v, ok := result.Val.(uint64); !ok {
+		t.Errorf("Incorrect value type from comparing previously non-existent interface stat; want: uint64, got: %T", result.Val)
+	} else if v != stat.Stat {
+		t.Errorf("Incorrect value from comparing previously non-existent interface stat; want: %d, got: %d", stat.Stat, v)
+	}
+
+	errs.Reset()
+	stat.Stat = uint64(6)
+
+	history = compareAndAppendStatForInterface(history, errs, limit, stat)
+	if errs.Len() > 0 {
+		t.Errorf("Unexpected errors comparing previously non-existent interface stat: %s", errs.String())
+	}
+	if len(history) == 0 {
+		t.Fatal("Empty history after comparing previously non-existent interface stat")
+	}
+	if len(history) > 1 {
+		t.Fatalf("Too many stats returned from comparing previously non-existent interface stat: %d", len(history))
+	}
+
+	result = history[0]
+	if result.Span != 1 {
+		t.Errorf("Incorrect span comparing previously non-existent interface stat; want: 1, got: %d", result.Span)
+	}
+
+	if result.Time != stat.Time {
+		t.Errorf("Incorrect time comparing previously non-existent interface stat; want: %v, got: %v", stat.Time, result.Time)
+	}
+
+	if v, ok := result.Val.(uint64); !ok {
+		t.Errorf("Incorrect value type from comparing previously non-existent interface stat; want: uint64, got: %T", result.Val)
+	} else if v != stat.Stat {
+		t.Errorf("Incorrect value from comparing previously non-existent interface stat; want: %d, got: %d", stat.Stat, v)
+	}
+}
diff --git a/traffic_monitor/todata/todata.go b/traffic_monitor/todata/todata.go
index 1c8c12e..0148a03 100644
--- a/traffic_monitor/todata/todata.go
+++ b/traffic_monitor/todata/todata.go
@@ -142,7 +142,7 @@ type CRConfig struct {
 
 // Fetch gets the CRConfig from Traffic Ops, creates the TOData maps, and atomically sets the TOData.
 // TODO since the session is threadsafe, each TOData get func below could be put in a goroutine, if performance mattered
-func (d TODataThreadsafe) Fetch(to towrap.ITrafficOpsSession, cdn string) error {
+func (d TODataThreadsafe) Fetch(to towrap.TrafficOpsSessionThreadsafe, cdn string) error {
 	if _, err := to.CRConfigRaw(cdn); err != nil {
 		return fmt.Errorf("Error getting CRconfig from Traffic Ops: %v", err)
 	}
@@ -150,7 +150,7 @@ func (d TODataThreadsafe) Fetch(to towrap.ITrafficOpsSession, cdn string) error
 }
 
 // Update updates the TOData data with the last fetched CDN
-func (d TODataThreadsafe) Update(to towrap.ITrafficOpsSession, cdn string) error {
+func (d TODataThreadsafe) Update(to towrap.TrafficOpsSessionThreadsafe, cdn string) error {
 	crConfigBytes, _, err := to.LastCRConfig(cdn)
 	if err != nil {
 		return fmt.Errorf("Error getting last CRConfig: %v", err)
diff --git a/traffic_monitor/tools/testcaches/.gitignore b/traffic_monitor/tools/testcaches/.gitignore
new file mode 100644
index 0000000..d733e3a
--- /dev/null
+++ b/traffic_monitor/tools/testcaches/.gitignore
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, 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.
+#
+testcaches
diff --git a/traffic_monitor/towrap/towrap.go b/traffic_monitor/towrap/towrap.go
index 33c8a58..b3ec070 100644
--- a/traffic_monitor/towrap/towrap.go
+++ b/traffic_monitor/towrap/towrap.go
@@ -1,3 +1,5 @@
+// Package towrap wraps two versions of Traffic Ops clients to give up-to-date
+// information, possibly using legacy API versions.
 package towrap
 
 /*
@@ -23,6 +25,7 @@ import (
 	"errors"
 	"fmt"
 	"io/ioutil"
+	"net/url"
 	"os"
 	"strconv"
 	"sync"
@@ -31,56 +34,47 @@ import (
 	"github.com/apache/trafficcontrol/lib/go-log"
 	"github.com/apache/trafficcontrol/lib/go-tc"
 	"github.com/apache/trafficcontrol/traffic_monitor/config"
-	client "github.com/apache/trafficcontrol/traffic_ops/v2-client"
+	legacyClient "github.com/apache/trafficcontrol/traffic_ops/v2-client"
+	client "github.com/apache/trafficcontrol/traffic_ops/v3-client"
 
 	jsoniter "github.com/json-iterator/go"
 )
 
-// ITrafficOpsSession provides an interface to the Traffic Ops client, so it may be wrapped or mocked.
-type ITrafficOpsSession interface {
-	CRConfigRaw(cdn string) ([]byte, error)
-	LastCRConfig(cdn string) ([]byte, time.Time, error)
-	TrafficMonitorConfigMap(cdn string) (*tc.LegacyTrafficMonitorConfigMap, error)
-	Set(session *client.Session)
-	CRConfigHistory() []CRConfigStat
-	BackupFileExists() bool
-}
-
 const localHostIP = "127.0.0.1"
 
-var ErrNilSession = fmt.Errorf("nil session")
+// ErrNilSession is the error returned by operations performed on a nil session.
+var ErrNilSession = errors.New("nil session")
 
-// TODO rename CRConfigCacheObj
+// ByteTime is a structure for associating a set of raw data with some CDN
+// Snapshot statistics, and a certain time.
 type ByteTime struct {
 	bytes []byte
 	time  time.Time
 	stats *tc.CRConfigStats
 }
 
+// ByteMapCache is a thread-access-safe map of cache server hostnames to
+// ByteTime structures.
 type ByteMapCache struct {
 	cache *map[string]ByteTime
 	m     *sync.RWMutex
 }
 
-func (s TrafficOpsSessionThreadsafe) BackupFileExists() bool {
-	if _, err := os.Stat(s.CRConfigBackupFile); !os.IsNotExist(err) {
-		if _, err = os.Stat(s.TMConfigBackupFile); !os.IsNotExist(err) {
-			return true
-		}
-	}
-	return false
-}
-
+// NewByteMapCache constructs a new, empty ByteMapCache.
 func NewByteMapCache() ByteMapCache {
 	return ByteMapCache{m: &sync.RWMutex{}, cache: &map[string]ByteTime{}}
 }
 
+// Set sets the entry given by 'key' to a new ByteTime structure with the given
+// raw data ('newBytes') and the given statistics ('stats') at the current time.
 func (c ByteMapCache) Set(key string, newBytes []byte, stats *tc.CRConfigStats) {
 	c.m.Lock()
 	defer c.m.Unlock()
 	(*c.cache)[key] = ByteTime{bytes: newBytes, stats: stats, time: time.Now()}
 }
 
+// Get retrieves the raw data, associated time, and statistics of the entry
+// given by 'key'.
 func (c ByteMapCache) Get(key string) ([]byte, time.Time, *tc.CRConfigStats) {
 	c.m.RLock()
 	defer c.m.RUnlock()
@@ -91,28 +85,67 @@ func (c ByteMapCache) Get(key string) ([]byte, time.Time, *tc.CRConfigStats) {
 	}
 }
 
+func (s TrafficOpsSessionThreadsafe) BackupFileExists() bool {
+	if _, err := os.Stat(s.CRConfigBackupFile); !os.IsNotExist(err) {
+		if _, err = os.Stat(s.TMConfigBackupFile); !os.IsNotExist(err) {
+			return true
+		}
+	}
+	return false
+}
+
+// CRConfigStat represents a set of statistics from a CDN Snapshot requested at
+// a particular time.
+type CRConfigStat struct {
+	// Err contains any error that may have occurred when obtaining the
+	// statistics.
+	Err error `json:"error"`
+	// ReqAddr is the network address from which the statistics were requested.
+	ReqAddr string `json:"request_address"`
+	// ReqTime is the time at which the request for statistics was made.
+	ReqTime time.Time `json:"request_time"`
+	// Stats contains the actual statistics.
+	Stats tc.CRConfigStats `json:"stats"`
+}
+
+// CopyCRConfigStat makes a deep copy of a slice of CRConfigStats.
+func CopyCRConfigStat(old []CRConfigStat) []CRConfigStat {
+	newStats := make([]CRConfigStat, len(old))
+	copy(newStats, old)
+	return newStats
+}
+
 // CRConfigHistoryThreadsafe stores history in a circular buffer.
 type CRConfigHistoryThreadsafe struct {
-	hist  *[]CRConfigStat
-	m     *sync.RWMutex
-	limit *uint64
-	len   *uint64
-	pos   *uint64
+	hist   *[]CRConfigStat
+	m      *sync.RWMutex
+	limit  *uint64
+	length *uint64
+	pos    *uint64
 }
 
+// NewCRConfigHistoryThreadsafe constructs a new, empty
+// CRConfigHistoryThreadsafe - this is the ONLY way to safely create a
+// CRConfigHistoryThreadsafe, using the zero value of the structure will cause
+// all operations to encounter segmentation faults, and there is no way to
+// preempt this.
+//
+// 'limit' indicates the size of the circular buffer - effectively the number of
+// entries it will be capable of storing.
 func NewCRConfigHistoryThreadsafe(limit uint64) CRConfigHistoryThreadsafe {
 	hist := make([]CRConfigStat, limit, limit)
-	len := uint64(0)
+	length := uint64(0)
 	pos := uint64(0)
-	return CRConfigHistoryThreadsafe{hist: &hist, m: &sync.RWMutex{}, limit: &limit, len: &len, pos: &pos}
+	return CRConfigHistoryThreadsafe{hist: &hist, m: &sync.RWMutex{}, limit: &limit, length: &length, pos: &pos}
 }
 
-// Add adds the given stat to the history. Does not add new additions with the same remote address and CRConfig Date as the previous.
+// Add adds the given stat to the history. Does not add new additions with the
+// same remote address and CRConfig Date as the previous.
 func (h CRConfigHistoryThreadsafe) Add(i *CRConfigStat) {
 	h.m.Lock()
 	defer h.m.Unlock()
 
-	if *h.len != 0 {
+	if *h.length != 0 {
 		last := (*h.hist)[(*h.pos-1)%*h.limit]
 		datesEqual := (i.Stats.DateUnixSeconds == nil && last.Stats.DateUnixSeconds == nil) || (i.Stats.DateUnixSeconds != nil && last.Stats.DateUnixSeconds != nil && *i.Stats.DateUnixSeconds == *last.Stats.DateUnixSeconds)
 		cdnsEqual := (i.Stats.CDNName == nil && last.Stats.CDNName == nil) || (i.Stats.CDNName != nil && last.Stats.CDNName != nil && *i.Stats.CDNName == *last.Stats.CDNName)
@@ -124,59 +157,114 @@ func (h CRConfigHistoryThreadsafe) Add(i *CRConfigStat) {
 
 	(*h.hist)[*h.pos] = *i
 	*h.pos = (*h.pos + 1) % *h.limit
-	if *h.len < *h.limit {
-		*h.len++
+	if *h.length < *h.limit {
+		*h.length++
 	}
 }
 
+// Get retrieves the stored history of CRConfigStat entries.
 func (h CRConfigHistoryThreadsafe) Get() []CRConfigStat {
 	h.m.RLock()
 	defer h.m.RUnlock()
-	if *h.len < *h.limit {
-		return CopyCRConfigStat((*h.hist)[:*h.len])
+	if *h.length < *h.limit {
+		return CopyCRConfigStat((*h.hist)[:*h.length])
 	}
-	new := make([]CRConfigStat, *h.limit)
-	copy(new, (*h.hist)[*h.pos:])
-	copy(new[*h.len-*h.pos:], (*h.hist)[:*h.pos])
-	return new
+	newStats := make([]CRConfigStat, *h.limit)
+	copy(newStats, (*h.hist)[*h.pos:])
+	copy(newStats[*h.length-*h.pos:], (*h.hist)[:*h.pos])
+	return newStats
 }
 
-func CopyCRConfigStat(old []CRConfigStat) []CRConfigStat {
-	new := make([]CRConfigStat, len(old))
-	copy(new, old)
-	return new
-}
-
-type CRConfigStat struct {
-	ReqTime time.Time        `json:"request_time"`
-	ReqAddr string           `json:"request_address"`
-	Stats   tc.CRConfigStats `json:"stats"`
-	Err     error            `json:"error"`
+// Len gives the number of currently stored items in the buffer.
+//
+// An uninitialized buffer has zero length.
+func (h CRConfigHistoryThreadsafe) Len() uint64 {
+	if h.length == nil {
+		return 0
+	}
+	return *h.length
 }
 
-// TrafficOpsSessionThreadsafe provides access to the Traffic Ops client safe for multiple goroutines. This fulfills the ITrafficOpsSession interface.
+// TrafficOpsSessionThreadsafe provides access to the Traffic Ops client safe
+// for multiple goroutines. This fulfills the ITrafficOpsSession interface.
 type TrafficOpsSessionThreadsafe struct {
 	session            **client.Session // pointer-to-pointer, because we're given a pointer from the Traffic Ops package, and we don't want to copy it.
+	legacySession      **legacyClient.Session
 	m                  *sync.Mutex
 	lastCRConfig       ByteMapCache
 	crConfigHist       CRConfigHistoryThreadsafe
+	useLegacy          bool
 	CRConfigBackupFile string
 	TMConfigBackupFile string
 }
 
-// NewTrafficOpsSessionThreadsafe returns a new threadsafe TrafficOpsSessionThreadsafe wrapping the given `Session`.
-func NewTrafficOpsSessionThreadsafe(s *client.Session, crConfigHistoryLimit uint64, cfg config.Config) TrafficOpsSessionThreadsafe {
-	return TrafficOpsSessionThreadsafe{session: &s, m: &sync.Mutex{}, lastCRConfig: NewByteMapCache(), crConfigHist: NewCRConfigHistoryThreadsafe(crConfigHistoryLimit), CRConfigBackupFile: cfg.CRConfigBackupFile, TMConfigBackupFile: cfg.TMConfigBackupFile}
+// NewTrafficOpsSessionThreadsafe returns a new threadsafe
+// TrafficOpsSessionThreadsafe wrapping the given `Session`.
+func NewTrafficOpsSessionThreadsafe(s *client.Session, ls *legacyClient.Session, histLimit uint64, cfg config.Config) TrafficOpsSessionThreadsafe {
+	return TrafficOpsSessionThreadsafe{
+		CRConfigBackupFile: cfg.CRConfigBackupFile,
+		crConfigHist:       NewCRConfigHistoryThreadsafe(histLimit),
+		lastCRConfig:       NewByteMapCache(),
+		m:                  &sync.Mutex{},
+		session:            &s,
+		legacySession:      &ls,
+		TMConfigBackupFile: cfg.TMConfigBackupFile,
+		useLegacy:          false,
+	}
+}
+
+// Initialized tells whether or not the TrafficOpsSessionThreadsafe has been
+// properly initialized (by calling 'Update').
+func (s TrafficOpsSessionThreadsafe) Initialized() bool {
+	if s.useLegacy {
+		return s.legacySession != nil && *s.legacySession != nil
+	}
+	return s.session != nil && *s.session != nil
 }
 
-// Set sets the internal Traffic Ops session. This is safe for multiple goroutines, being aware they will race.
-func (s TrafficOpsSessionThreadsafe) Set(session *client.Session) {
+// Update updates the TrafficOpsSessionThreadsafe's connection information with
+// the provided information. It's safe for calling by multiple goroutines, being
+// aware that they will race.
+func (s *TrafficOpsSessionThreadsafe) Update(
+	url string,
+	username string,
+	password string,
+	insecure bool,
+	userAgent string,
+	useCache bool,
+	timeout time.Duration,
+) error {
+	if s == nil {
+		return errors.New("cannot update nil session")
+	}
 	s.m.Lock()
 	defer s.m.Unlock()
-	*s.session = session
+
+	session, _, err := client.LoginWithAgent(url, username, password, insecure, userAgent, useCache, timeout)
+	if err != nil {
+		log.Errorf("Error logging in using up-to-date client: %v", err)
+		legacySession, _, err := legacyClient.LoginWithAgent(url, username, password, insecure, userAgent, useCache, timeout)
+		if err != nil || legacySession == nil {
+			err = fmt.Errorf("logging in using legacy client: %v", err)
+			return err
+		}
+		*s.legacySession = legacySession
+		s.useLegacy = true
+	} else {
+		*s.session = session
+		s.useLegacy = false
+	}
+
+	return nil
 }
 
-// getThreadsafeSession is used internally to get a copy of the session pointer, or nil if it doesn't exist. This should not be used outside TrafficOpsSessionThreadsafe, and never stored, because part of the purpose of TrafficOpsSessionThreadsafe is to store a pointer to the Session pointer, so it can be updated by one goroutine and immediately used by another. This should only be called immediately before using the session, since someone else may update it concurrently.
+// getThreadsafeSession is used internally to get a copy of the session pointer,
+// or nil if it doesn't exist. This should not be used outside
+// TrafficOpsSessionThreadsafe, and never stored, because part of the purpose of
+// rafficOpsSessionThreadsafe is to store a pointer to the Session pointer, so
+// it can be updated by one goroutine and immediately used by another. This
+// should only be called immediately before using the session, since someone
+// else may update it concurrently.
 func (s TrafficOpsSessionThreadsafe) get() *client.Session {
 	s.m.Lock()
 	defer s.m.Unlock()
@@ -186,11 +274,28 @@ func (s TrafficOpsSessionThreadsafe) get() *client.Session {
 	return *s.session
 }
 
+func (s TrafficOpsSessionThreadsafe) getLegacy() *legacyClient.Session {
+	s.m.Lock()
+	defer s.m.Unlock()
+	if s.legacySession == nil || *s.legacySession == nil {
+		return nil
+	}
+	return *s.legacySession
+}
+
+// CRConfigHistory gets all of the stored, historical data about CRConfig
+// Snapshots' Stats sections.
 func (s TrafficOpsSessionThreadsafe) CRConfigHistory() []CRConfigStat {
 	return s.crConfigHist.Get()
 }
 
+// CRConfigValid checks if the passed tc.CRConfig structure is valid, and
+// ensures that it is from the same CDN as the last CRConfig Snapshot, as well
+// as that it is newer than the last CRConfig Snapshot.
 func (s *TrafficOpsSessionThreadsafe) CRConfigValid(crc *tc.CRConfig, cdn string) error {
+	if crc == nil {
+		return errors.New("CRConfig is nil")
+	}
 	if crc.Stats.CDNName == nil {
 		return errors.New("CRConfig.Stats.CDN missing")
 	}
@@ -198,7 +303,8 @@ func (s *TrafficOpsSessionThreadsafe) CRConfigValid(crc *tc.CRConfig, cdn string
 		return errors.New("CRConfig.Stats.Date missing")
 	}
 
-	// Note this intentionally takes intended CDN, rather than trusting crc.Stats
+	// Note this intentionally takes intended CDN, rather than trusting
+	// crc.Stats
 	lastCrc, lastCrcTime, lastCrcStats := s.lastCRConfig.Get(cdn)
 
 	if lastCrc == nil {
@@ -221,64 +327,87 @@ func (s *TrafficOpsSessionThreadsafe) CRConfigValid(crc *tc.CRConfig, cdn string
 	return nil
 }
 
-
-// CRConfigRaw returns the CRConfig from the Traffic Ops. This is safe for multiple goroutines.
+// CRConfigRaw returns the CRConfig from the Traffic Ops. This is safe for
+// multiple goroutines.
 func (s TrafficOpsSessionThreadsafe) CRConfigRaw(cdn string) ([]byte, error) {
 
-	ss := s.get()
-
 	var remoteAddr string
+	var err error
+	var data []byte
 
-	if ss == nil {
-		return nil, ErrNilSession
+	if s.useLegacy {
+		ss := s.getLegacy()
+		if ss == nil {
+			return nil, ErrNilSession
+		}
+		b, reqInf, e := ss.GetCRConfig(cdn)
+		err = e
+		data = b
+		remoteAddr = reqInf.RemoteAddr.String()
+	} else {
+		ss := s.get()
+		if ss == nil {
+			return nil, ErrNilSession
+		}
+		b, reqInf, e := ss.GetCRConfig(cdn)
+		err = e
+		data = b
+		remoteAddr = reqInf.RemoteAddr.String()
 	}
 
-	b, reqInf, err := ss.GetCRConfig(cdn)
 	if err == nil {
-		remoteAddr = reqInf.RemoteAddr.String()
-		ioutil.WriteFile(s.CRConfigBackupFile, b, 0644)
+		ioutil.WriteFile(s.CRConfigBackupFile, data, 0644)
 	} else {
 		if s.BackupFileExists() {
-			b, err = ioutil.ReadFile(s.CRConfigBackupFile)
+			data, err = ioutil.ReadFile(s.CRConfigBackupFile)
 			if err != nil {
-				err = errors.New("file Read Error: " + err.Error())
-				return nil, err
+				return nil, fmt.Errorf("file Read Error: %v", err)
 			}
 			remoteAddr = localHostIP
 			log.Errorln("Error getting CRConfig from traffic_ops, backup file exists, reading from file")
 			err = nil
 		} else {
-			return nil, ErrNilSession
+			return nil, fmt.Errorf("Failed to get CRConfig from Traffic Ops (%v), and there is no backup file", err)
 		}
 	}
 
-	hist := &CRConfigStat{time.Now(), remoteAddr, tc.CRConfigStats{}, err}
+	hist := &CRConfigStat{
+		Err:     err,
+		ReqAddr: remoteAddr,
+		ReqTime: time.Now(),
+		Stats:   tc.CRConfigStats{},
+	}
 	defer s.crConfigHist.Add(hist)
 
+	// TODO: per the above logic, I don't think this is possible
 	if err != nil {
-		return b, err
+		return data, err
 	}
 
 	crc := &tc.CRConfig{}
 	json := jsoniter.ConfigFastest
-	if err = json.Unmarshal(b, crc); err != nil {
+	if err = json.Unmarshal(data, crc); err != nil {
 		err = errors.New("invalid JSON: " + err.Error())
 		hist.Err = err
-		return b, err
+		return data, err
 	}
 	hist.Stats = crc.Stats
 
 	if err = s.CRConfigValid(crc, cdn); err != nil {
 		err = errors.New("invalid CRConfig: " + err.Error())
 		hist.Err = err
-		return b, err
+		return data, err
 	}
 
-	s.lastCRConfig.Set(cdn, b, &crc.Stats)
-	return b, nil
+	s.lastCRConfig.Set(cdn, data, &crc.Stats)
+	return data, nil
 }
 
-// LastCRConfig returns the last CRConfig requested from CRConfigRaw, and the time it was returned. This is designed to be used in conjunction with a poller which regularly calls CRConfigRaw. If no last CRConfig exists, because CRConfigRaw has never been called successfully, this calls CRConfigRaw once to try to get the CRConfig from Traffic Ops.
+// LastCRConfig returns the last CRConfig requested from CRConfigRaw, and the
+// time it was returned. This is designed to be used in conjunction with a
+// poller which regularly calls CRConfigRaw. If no last CRConfig exists, because
+// CRConfigRaw has never been called successfully, this calls CRConfigRaw once
+// to try to get the CRConfig from Traffic Ops.
 func (s TrafficOpsSessionThreadsafe) LastCRConfig(cdn string) ([]byte, time.Time, error) {
 	crConfig, crConfigTime, _ := s.lastCRConfig.Get(cdn)
 	if crConfig == nil {
@@ -288,18 +417,51 @@ func (s TrafficOpsSessionThreadsafe) LastCRConfig(cdn string) ([]byte, time.Time
 	return crConfig, crConfigTime, nil
 }
 
-// TrafficMonitorConfigMapRaw returns the Traffic Monitor config map from the Traffic Ops, directly from the monitoring.json endpoint. This is not usually what is needed, rather monitoring needs the snapshotted CRConfig data, which is filled in by `LegacyTrafficMonitorConfigMap`. This is safe for multiple goroutines.
-func (s TrafficOpsSessionThreadsafe) trafficMonitorConfigMapRaw(cdn string) (*tc.LegacyTrafficMonitorConfigMap, error) {
-	var configMap *tc.LegacyTrafficMonitorConfigMap
+func (s TrafficOpsSessionThreadsafe) fetchTMConfig(cdn string) (*tc.TrafficMonitorConfig, error) {
 	ss := s.get()
 	if ss == nil {
 		return nil, ErrNilSession
 	}
 
-	tmConfig, _, err := ss.GetTrafficMonitorConfig(cdn)
+	m, _, e := ss.GetTrafficMonitorConfig(cdn)
+	return m, e
+}
+
+func (s TrafficOpsSessionThreadsafe) fetchLegacyTMConfig(cdn string) (*tc.TrafficMonitorConfig, error) {
+	ss := s.getLegacy()
+	if ss == nil {
+		return nil, ErrNilSession
+	}
+
+	m, _, e := ss.GetTrafficMonitorConfig(cdn)
+	return m.Upgrade(), e
+}
+
+// trafficMonitorConfigMapRaw returns the Traffic Monitor config map from the
+// Traffic Ops, directly from the monitoring.json endpoint. This is not usually
+// what is needed, rather monitoring needs the snapshotted CRConfig data, which
+// is filled in by `LegacyTrafficMonitorConfigMap`. This is safe for multiple
+// goroutines.
+func (s TrafficOpsSessionThreadsafe) trafficMonitorConfigMapRaw(cdn string) (*tc.TrafficMonitorConfigMap, error) {
+	var config *tc.TrafficMonitorConfig
+	var configMap *tc.TrafficMonitorConfigMap
+	var err error
+
+	if s.useLegacy {
+		config, err = s.fetchLegacyTMConfig(cdn)
+	} else {
+		config, err = s.fetchTMConfig(cdn)
+	}
+
+	if config == nil {
+		if err != nil {
+			return nil, fmt.Errorf("getting Traffic Monitor configuration map: %v", err)
+		}
+		return nil, errors.New("nil configMap after fetching")
+	}
 
 	if err == nil {
-		configMap, err = tc.LegacyTrafficMonitorTransformToMap(tmConfig)
+		configMap, err = tc.TrafficMonitorTransformToMap(config)
 	}
 
 	if err != nil {
@@ -315,14 +477,15 @@ func (s TrafficOpsSessionThreadsafe) trafficMonitorConfigMapRaw(cdn string) (*tc
 
 		log.Errorln("Error getting configMap from traffic_ops, backup file exists, reading from file")
 		json := jsoniter.ConfigFastest
+		var tmConfig tc.TrafficMonitorConfig
 		if err := json.Unmarshal(b, &tmConfig); err != nil {
 			return nil, errors.New("unmarhsalling backup file monitoring.json: " + err.Error())
 		}
-		return tc.LegacyTrafficMonitorTransformToMap(tmConfig)
+		return tc.TrafficMonitorTransformToMap(&tmConfig)
 	}
 
 	json := jsoniter.ConfigFastest
-	data, err := json.Marshal(*tmConfig)
+	data, err := json.Marshal(*config)
 	if err == nil {
 		ioutil.WriteFile(s.TMConfigBackupFile, data, 0644)
 	}
@@ -330,8 +493,9 @@ func (s TrafficOpsSessionThreadsafe) trafficMonitorConfigMapRaw(cdn string) (*tc
 	return configMap, err
 }
 
-// LegacyTrafficMonitorConfigMap returns the Traffic Monitor config map from the Traffic Ops. This is safe for multiple goroutines.
-func (s TrafficOpsSessionThreadsafe) TrafficMonitorConfigMap(cdn string) (*tc.LegacyTrafficMonitorConfigMap, error) {
+// TrafficMonitorConfigMap returns the Traffic Monitor config map from the
+// Traffic Ops. This is safe for multiple goroutines.
+func (s TrafficOpsSessionThreadsafe) TrafficMonitorConfigMap(cdn string) (*tc.TrafficMonitorConfigMap, error) {
 	mc, err := s.trafficMonitorConfigMapRaw(cdn)
 	if err != nil {
 		return nil, fmt.Errorf("getting monitor config map: %v", err)
@@ -356,71 +520,124 @@ func (s TrafficOpsSessionThreadsafe) TrafficMonitorConfigMap(cdn string) (*tc.Le
 	return mc, nil
 }
 
-func CreateMonitorConfig(crConfig tc.CRConfig, mc *tc.LegacyTrafficMonitorConfigMap) (*tc.LegacyTrafficMonitorConfigMap, error) {
-	// Dump the "live" monitoring.json servers, and populate with the "snapshotted" CRConfig
-	mc.TrafficServer = map[string]tc.LegacyTrafficServer{}
-	for name, srv := range crConfig.ContentServers {
-		s := tc.LegacyTrafficServer{}
-		if srv.Profile != nil {
-			s.Profile = *srv.Profile
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing Profile field\n", name)
-		}
-		if srv.Ip != nil {
-			s.IP = *srv.Ip
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing IP field\n", name)
-		}
-		if srv.ServerStatus != nil {
-			s.ServerStatus = string(*srv.ServerStatus)
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing Status field\n", name)
-		}
-		if srv.CacheGroup != nil {
-			s.CacheGroup = *srv.CacheGroup
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing CacheGroup field\n", name)
-		}
-		if srv.Ip6 != nil {
-			s.IP6 = *srv.Ip6
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing IP6 field\n", name)
-		}
-		if srv.Port != nil {
-			s.Port = *srv.Port
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing Port field\n", name)
-		}
-		s.HostName = name
-		if srv.Fqdn != nil {
-			s.FQDN = *srv.Fqdn
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing FQDN field\n", name)
-		}
-		if srv.InterfaceName != nil {
-			s.InterfaceName = *srv.InterfaceName
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing InterfaceName field\n", name)
-		}
-		if srv.ServerType != nil {
-			s.Type = *srv.ServerType
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing Type field\n", name)
-		}
-		if srv.HashId != nil {
-			s.HashID = *srv.HashId
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing HashId field\n", name)
+func (s TrafficOpsSessionThreadsafe) fetchServerByHostname(hostName string) (tc.ServerNullable, error) {
+	ss := s.get()
+	if ss == nil {
+		return tc.ServerNullable{}, ErrNilSession
+	}
+
+	params := url.Values{}
+	params.Set("hostName", hostName)
+	resp, _, err := ss.GetServersWithHdr(&params, nil)
+	if err != nil {
+		return tc.ServerNullable{}, fmt.Errorf("fetching server by hostname '%s': %v", hostName, err)
+	}
+
+	respLen := len(resp.Response)
+	if respLen < 1 {
+		return tc.ServerNullable{}, fmt.Errorf("no server '%s' found in Traffic Ops", hostName)
+	}
+
+	var server tc.ServerNullable
+	var num int
+	found := false
+	for i, srv := range resp.Response {
+		num = i
+		if srv.CDNName != nil && srv.HostName != nil && *srv.HostName == hostName {
+			server = srv
+			found = true
+			break
 		}
-		if srv.HttpsPort != nil {
-			s.HTTPSPort = *srv.HttpsPort
-		} else {
-			log.Warnf("Creating monitor config: CRConfig server %s missing HttpsPort field\n", name)
+	}
+	if !found {
+		return tc.ServerNullable{}, fmt.Errorf("either no server '%s' found in Traffic Ops, or none by that hostName had non-nil CDN", hostName)
+	}
+
+	if respLen > 1 {
+		log.Warnf("Getting monitor server by hostname '%s' returned %d servers - selecting #%d", hostName, respLen, num)
+	}
+
+	return server, nil
+}
+
+func (s TrafficOpsSessionThreadsafe) fetchLegacyServerByHostname(hostName string) (tc.ServerNullable, error) {
+	ss := s.getLegacy()
+	if ss == nil {
+		return tc.ServerNullable{}, ErrNilSession
+	}
+
+	resp, _, err := ss.GetServerByHostName(hostName)
+	if err != nil {
+		return tc.ServerNullable{}, fmt.Errorf("fetching legacy server by hostname '%s': %v", hostName, err)
+	}
+
+	respLen := len(resp)
+	if respLen < 1 {
+		return tc.ServerNullable{}, fmt.Errorf("no server '%s' found in Traffic Ops", hostName)
+	}
+
+	var server tc.ServerNullableV2
+	var num int
+	found := false
+	for i, srv := range resp {
+		num = i
+		if srv.CDNName != "" && srv.HostName == hostName {
+			server = srv.ToNullable()
+			found = true
+			break
 		}
-		mc.TrafficServer[name] = s
+
+	}
+	if !found {
+		return tc.ServerNullable{}, fmt.Errorf("either no server '%s' found in Traffic Ops, or none by that hostName had non-empty CDN", hostName)
+	}
+	if respLen > 1 {
+		log.Warnf("Getting monitor server by hostname '%s' returned %d servers - selecting #%d", hostName, respLen, num)
 	}
 
-	// Dump the "live" monitoring.json monitors, and populate with the "snapshotted" CRConfig
+	ret, err := server.Upgrade()
+	if err != nil {
+		return ret, fmt.Errorf("coercing legacy server to new format: %v", err)
+	}
+
+	return ret, nil
+}
+
+// MonitorCDN returns the name of the CDN of a Traffic Monitor with the given
+// hostName.
+func (s TrafficOpsSessionThreadsafe) MonitorCDN(hostName string) (string, error) {
+	var server tc.ServerNullable
+	var err error
+
+	if s.useLegacy {
+		server, err = s.fetchLegacyServerByHostname(hostName)
+	} else {
+		server, err = s.fetchServerByHostname(hostName)
+	}
+
+	if err != nil {
+		return "", fmt.Errorf("getting monitor CDN: %v", err)
+	}
+
+	// nil-dereference checks done already in each 'fetch' method; they'll just
+	// return an error in that case
+	return *server.CDNName, nil
+}
+
+// CreateMonitorConfig modifies the passed TrafficMonitorConfigMap to add the
+// Traffic Monitors and Delivery Services found in a CDN Snapshot, and wipe out
+// all of those that already existed in the configuration map.
+func CreateMonitorConfig(crConfig tc.CRConfig, mc *tc.TrafficMonitorConfigMap) (*tc.TrafficMonitorConfigMap, error) {
+	// For unknown reasons, this function used to overwrite the passed set of
+	// TrafficServer objects. That was problematic, tc.CRConfig structures don't
+	// contain the same amount of information about their "equivalent"
+	// ContentServers.
+	// TODO: This is still overwriting TM instances found in the monitoring
+	// config - why? It's also doing that for Delivery Services, but that's
+	// necessary until issue #3528 is resolved.
+
+	// Dump the "live" monitoring.json monitors, and populate with the
+	// "snapshotted" CRConfig
 	mc.TrafficMonitor = map[string]tc.TrafficMonitor{}
 	for name, mon := range crConfig.Monitors {
 		// monitorProfile = *mon.Profile
@@ -464,13 +681,15 @@ func CreateMonitorConfig(crConfig tc.CRConfig, mc *tc.LegacyTrafficMonitorConfig
 		mc.TrafficMonitor[name] = m
 	}
 
-	// Dump the "live" monitoring.json DeliveryServices, and populate with the "snapshotted" CRConfig
-	// But keep using the monitoring.json thresholds, because they're not in the CRConfig.
+	// Dump the "live" monitoring.json DeliveryServices, and populate with the
+	// "snapshotted" CRConfig but keep using the monitoring.json thresholds,
+	// because they're not in the CRConfig.
 	rawDeliveryServices := mc.DeliveryService
 	mc.DeliveryService = map[string]tc.TMDeliveryService{}
 	for name, _ := range crConfig.DeliveryServices {
 		if rawDS, ok := rawDeliveryServices[name]; ok {
-			// use the raw DS if it exists, because the CRConfig doesn't have thresholds or statuses
+			// use the raw DS if it exists, because the CRConfig doesn't have
+			// thresholds or statuses
 			mc.DeliveryService[name] = rawDS
 		} else {
 			mc.DeliveryService[name] = tc.TMDeliveryService{
diff --git a/traffic_ops/traffic_ops_golang/crconfig/servers_test.go b/traffic_ops/traffic_ops_golang/crconfig/servers_test.go
index 8579c2b..1e37341 100644
--- a/traffic_ops/traffic_ops_golang/crconfig/servers_test.go
+++ b/traffic_ops/traffic_ops_golang/crconfig/servers_test.go
@@ -463,8 +463,8 @@ func TestGetAllServersNonService(t *testing.T) {
 	getServerParamsExpected := ExpectedGetServerParams()
 	MockGetServerParams(mock, getServerParamsExpected, cdn)
 
-	expected := ExpectedGetAllServers(getServerParamsExpected, false, false)
-	MockGetAllServers(mock, expected, cdn, false, false)
+	expected := ExpectedGetAllServers(getServerParamsExpected, true, false)
+	MockGetAllServers(mock, expected, cdn, true, false)
 	mock.ExpectCommit()
 
 	dbCtx, _ := context.WithTimeout(context.TODO(), time.Duration(10)*time.Second)
diff --git a/traffic_ops/traffic_ops_golang/deliveryservice/capacity.go b/traffic_ops/traffic_ops_golang/deliveryservice/capacity.go
index fda184e..1805608 100644
--- a/traffic_ops/traffic_ops_golang/deliveryservice/capacity.go
+++ b/traffic_ops/traffic_ops_golang/deliveryservice/capacity.go
@@ -134,15 +134,18 @@ func getCapacity(tx *sql.Tx, ds tc.DeliveryServiceName, cdn tc.CDNName) (Capacit
 const StatNameKBPS = "kbps"
 const StatNameMaxKBPS = "maxKbps"
 
-func addCapacity(cap CapData, ds tc.DeliveryServiceName, cacheStats tmcache.Stats, crStates tc.CRStates, crConfig tc.CRConfig, thresholds map[string]float64) CapData {
-	for cacheName, interfaces := range cacheStats.Caches {
-		if _, ok := interfaces[tc.CacheInterfacesAggregate]; !ok {
-			log.Warnf("No %s interface on cache server %s", tc.CacheInterfacesAggregate, cacheName)
-			continue
-		}
-		cache, ok := crConfig.ContentServers[string(cacheName)]
+func addCapacity(
+	cap CapData,
+	ds tc.DeliveryServiceName,
+	cacheStats tmcache.Stats,
+	crStates tc.CRStates,
+	crConfig tc.CRConfig,
+	thresholds map[string]float64,
+) CapData {
+	for cacheName, statsCache := range cacheStats.Caches {
+		cache, ok := crConfig.ContentServers[cacheName]
 		if !ok {
-			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + string(cacheName) + "' in CacheStats but not CRConfig, skipping")
+			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + cacheName + "' in CacheStats but not CRConfig, skipping")
 			continue
 		}
 
@@ -153,32 +156,32 @@ func addCapacity(cap CapData, ds tc.DeliveryServiceName, cacheStats tmcache.Stat
 			continue
 		}
 
-		stat := interfaces[tc.CacheInterfacesAggregate]
+		stat := statsCache.Stats
 		if len(stat[StatNameKBPS]) < 1 || len(stat[StatNameMaxKBPS]) < 1 {
-			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + string(cacheName) + "' CacheStats has no kbps or maxKbps, skipping")
+			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + cacheName + "' CacheStats has no kbps or maxKbps, skipping")
 			continue
 		}
 
 		kbps, err := statToFloat(stat[StatNameKBPS][0].Val)
 		if err != nil {
-			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + string(cacheName) + "' CacheStats kbps is not a number, skipping")
+			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + cacheName + "' CacheStats kbps is not a number, skipping")
 			continue
 		}
 		maxKBPS, err := statToFloat(stat[StatNameMaxKBPS][0].Val)
 		if err != nil {
-			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + string(cacheName) + "' CacheStats maxKps is not a number, skipping")
+			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + cacheName + "' CacheStats maxKps is not a number, skipping")
 			continue
 		}
 		if cache.ServerStatus == nil {
-			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + string(cacheName) + "' CRConfig Status is nil, skipping")
+			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + cacheName + "' CRConfig Status is nil, skipping")
 			continue
 		}
 		if cache.Profile == nil {
-			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + string(cacheName) + "' CRConfig Profile is nil, skipping")
+			log.Warnln("Getting delivery service capacity: delivery service '" + string(ds) + "' cache '" + cacheName + "' CRConfig Profile is nil, skipping")
 			continue
 		}
 		if tc.CacheStatus(*cache.ServerStatus) == tc.CacheStatusReported || tc.CacheStatus(*cache.ServerStatus) == tc.CacheStatusOnline {
-			if crStates.Caches[cacheName].IsAvailable {
+			if crStates.Caches[tc.CacheName(cacheName)].IsAvailable {
 				cap.Available += kbps
 			} else {
 				cap.Unavailable += kbps