You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@trafficcontrol.apache.org by GitBox <gi...@apache.org> on 2020/04/22 17:55:45 UTC

[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #4633: Flexible Topologies in Traffic Ops and Traffic Portal

ocket8888 commented on a change in pull request #4633:
URL: https://github.com/apache/trafficcontrol/pull/4633#discussion_r413124653



##########
File path: docs/source/api/v2/topologies.rst
##########
@@ -0,0 +1,586 @@
+..
+..
+.. Licensed 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.
+..
+
+.. _to-api-topologies:
+
+***********************
+``topologies``
+***********************

Review comment:
       underline/overline too long

##########
File path: docs/source/api/v2/topologies.rst
##########
@@ -0,0 +1,586 @@
+..
+..
+.. Licensed 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.
+..
+
+.. _to-api-topologies:
+
+***********************
+``topologies``
+***********************
+
+``GET``
+=======
+Retrieves :term:`Topologies`.
+
+:Auth. Required: Yes
+:Roles Required: "read-only"
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+-----------------------------------------------------+
+	| Name | Required | Description                                         |
+	+======+==========+=====================================================+
+	| name | no       | Return the :term:`Topology` with this name          |
+	+------+----------+-----------------------------------------------------+
+
+.. code-block:: http
+	:caption: Request Example
+
+	GET /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max

Review comment:
       you appear to be using tabs for alignment in here which is causing the alignment to be dependent on your tabstops. Just use tabs for indentation, spaces for alignment.

##########
File path: docs/source/api/v2/topologies.rst
##########
@@ -0,0 +1,586 @@
+..
+..
+.. Licensed 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.
+..
+
+.. _to-api-topologies:
+
+***********************
+``topologies``
+***********************
+
+``GET``
+=======
+Retrieves :term:`Topologies`.
+
+:Auth. Required: Yes
+:Roles Required: "read-only"
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+-----------------------------------------------------+
+	| Name | Required | Description                                         |
+	+======+==========+=====================================================+
+	| name | no       | Return the :term:`Topology` with this name          |
+	+------+----------+-----------------------------------------------------+
+
+.. code-block:: http
+	:caption: Request Example
+
+	GET /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology

Review comment:
       You need a blank line between an item in a list and the beginning of a sub-list, or it'll wind up rendering the entire sub-list as part of the argument to the field-list item.
   
   i.e. it'll look like this:
   ![image](https://user-images.githubusercontent.com/6013378/80008088-74e5fb00-8484-11ea-8c98-654bdb5f4ae4.png)
   

##########
File path: docs/source/api/v2/topologies.rst
##########
@@ -0,0 +1,586 @@
+..
+..
+.. Licensed 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.
+..
+
+.. _to-api-topologies:
+
+***********************
+``topologies``
+***********************
+
+``GET``
+=======
+Retrieves :term:`Topologies`.
+
+:Auth. Required: Yes
+:Roles Required: "read-only"
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+-----------------------------------------------------+
+	| Name | Required | Description                                         |
+	+======+==========+=====================================================+
+	| name | no       | Return the :term:`Topology` with this name          |
+	+------+----------+-----------------------------------------------------+
+
+.. code-block:: http
+	:caption: Request Example
+
+	GET /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/1.1 200 OK
+	Access-Control-Allow-Credentials: true
+	Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie
+	Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE
+	Access-Control-Allow-Origin: *
+	Content-Encoding: gzip
+	Content-Type: application/json
+	Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:22:32 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: lF4MCJCinuQWz0flLAAZBrzbuPVsHrNn2BtTozRZojEjGpm76IsXBQK5QOwSwBoHac+D0C1Z3p7M8kdjcfgIIg==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:22:32 GMT
+	Content-Length: 205
+
+	{
+		"response": [
+			{
+				"description": "This is my topology",
+				"name": "my-topology",
+				"nodes": [
+					{
+						"cachegroup": "edge1",
+						"parents": [
+							7
+						]
+					},
+					{
+						"cachegroup": "edge2",
+						"parents": [
+							7,
+							8
+						]
+					},
+					{
+						"cachegroup": "edge3",
+						"parents": [
+							8,
+							9
+						]
+					},
+					{
+						"cachegroup": "edge4",
+						"parents": [
+							9
+						]
+					},
+					{
+						"cachegroup": "mid1",
+						"parents": []
+					},
+					{
+						"cachegroup": "mid2",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid3",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid4",
+						"parents": [
+							5
+						]
+					},
+					{
+						"cachegroup": "mid5",
+						"parents": [
+							5,
+							6
+						]
+					},
+					{
+						"cachegroup": "mid6",
+						"parents": [
+							6
+						]
+					}
+				],
+				"lastUpdated": "2020-04-13 17:12:34+00"
+			}
+		]
+	}
+
+``POST``
+========
+Create a new :term:`Topology`.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type:  Object
+
+Request Structure
+-----------------
+:description:		A short sentence that describes the topology.
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max

Review comment:
       Same comments as the above field-list

##########
File path: docs/source/api/v2/topologies.rst
##########
@@ -0,0 +1,586 @@
+..
+..
+.. Licensed 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.
+..
+
+.. _to-api-topologies:
+
+***********************
+``topologies``
+***********************
+
+``GET``
+=======
+Retrieves :term:`Topologies`.
+
+:Auth. Required: Yes
+:Roles Required: "read-only"
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+-----------------------------------------------------+
+	| Name | Required | Description                                         |
+	+======+==========+=====================================================+
+	| name | no       | Return the :term:`Topology` with this name          |
+	+------+----------+-----------------------------------------------------+
+
+.. code-block:: http
+	:caption: Request Example
+
+	GET /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/1.1 200 OK
+	Access-Control-Allow-Credentials: true
+	Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie
+	Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE
+	Access-Control-Allow-Origin: *
+	Content-Encoding: gzip
+	Content-Type: application/json
+	Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:22:32 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: lF4MCJCinuQWz0flLAAZBrzbuPVsHrNn2BtTozRZojEjGpm76IsXBQK5QOwSwBoHac+D0C1Z3p7M8kdjcfgIIg==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:22:32 GMT
+	Content-Length: 205
+
+	{
+		"response": [
+			{
+				"description": "This is my topology",
+				"name": "my-topology",
+				"nodes": [
+					{
+						"cachegroup": "edge1",
+						"parents": [
+							7
+						]
+					},
+					{
+						"cachegroup": "edge2",
+						"parents": [
+							7,
+							8
+						]
+					},
+					{
+						"cachegroup": "edge3",
+						"parents": [
+							8,
+							9
+						]
+					},
+					{
+						"cachegroup": "edge4",
+						"parents": [
+							9
+						]
+					},
+					{
+						"cachegroup": "mid1",
+						"parents": []
+					},
+					{
+						"cachegroup": "mid2",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid3",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid4",
+						"parents": [
+							5
+						]
+					},
+					{
+						"cachegroup": "mid5",
+						"parents": [
+							5,
+							6
+						]
+					},
+					{
+						"cachegroup": "mid6",
+						"parents": [
+							6
+						]
+					}
+				],
+				"lastUpdated": "2020-04-13 17:12:34+00"
+			}
+		]
+	}
+
+``POST``
+========
+Create a new :term:`Topology`.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type:  Object
+
+Request Structure
+-----------------
+:description:		A short sentence that describes the topology.
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Request Example
+
+	POST /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+	Content-Length: 924
+
+	{
+		"name": "my-topology",
+		"description": "This is my topology",
+		"nodes": [
+			{
+				"cachegroup": "edge1",
+				"parents": [
+					7
+				]
+			},
+			{
+				"cachegroup": "edge2",
+				"parents": [
+					7,
+					8
+				]
+			},
+			{
+				"cachegroup": "edge3",
+				"parents": [
+					8,
+					9
+				]
+			},
+			{
+				"cachegroup": "edge4",
+				"parents": [
+					9
+				]
+			},
+			{
+				"cachegroup": "mid1",
+				"parents": []
+			},
+			{
+				"cachegroup": "mid2",
+				"parents": [
+					4
+				]
+			},
+			{
+				"cachegroup": "mid3",
+				"parents": [
+					4
+				]
+			},
+			{
+				"cachegroup": "mid4",
+				"parents": [
+					5
+				]
+			},
+			{
+				"cachegroup": "mid5",
+				"parents": [
+					5,
+					6
+				]
+			},
+			{
+				"cachegroup": "mid6",
+				"parents": [
+					6
+				]
+			}
+		]
+	}
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/1.1 200 OK
+	Access-Control-Allow-Credentials: true
+	Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie
+	Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE
+	Access-Control-Allow-Origin: *
+	Content-Encoding: gzip
+	Content-Type: application/json
+	Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:12:34 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: ftNcDRjYCDMkQM+o/szayKZriQZHGpcT0vNY0HpKgy88i0pXeEEeLGbUPh6LXtK7TvL76EgGECTzvCkcm+2LVA==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:12:34 GMT
+	Content-Length: 239
+
+	{
+		"alerts": [
+			{
+				"text": "topology was created.",
+				"level": "success"
+			}
+		],
+		"response": {
+			"description": "This is my topology",
+			"name": "my-topology",
+			"nodes": [
+				{
+					"cachegroup": "edge1",
+					"parents": [
+						7
+					]
+				},
+				{
+					"cachegroup": "edge2",
+					"parents": [
+						7,
+						8
+					]
+				},
+				{
+					"cachegroup": "edge3",
+					"parents": [
+						8,
+						9
+					]
+				},
+				{
+					"cachegroup": "edge4",
+					"parents": [
+						9
+					]
+				},
+				{
+					"cachegroup": "mid1",
+					"parents": []
+				},
+				{
+					"cachegroup": "mid2",
+					"parents": [
+						4
+					]
+				},
+				{
+					"cachegroup": "mid3",
+					"parents": [
+						4
+					]
+				},
+				{
+					"cachegroup": "mid4",
+					"parents": [
+						5
+					]
+				},
+				{
+					"cachegroup": "mid5",
+					"parents": [
+						5,
+						6
+					]
+				},
+				{
+					"cachegroup": "mid6",
+					"parents": [
+						6
+					]
+				}
+			],
+			"lastUpdated": "2020-04-13 17:12:34+00"
+		}
+	}
+
+``PUT``
+=======
+Updates a specific :term:`Topology`
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+---------------------------------------------------------+
+	| Name | Required | Description                                             |
+	+======+==========+=========================================================+
+	| name | yes      | The name of the :term:`Topology` to be updated          |
+	+------+----------+---------------------------------------------------------+
+
+:description:		A short sentence that describes the topology.
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Request Example
+
+	PUT /api/2.0/topologies?name=my-topology HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+	Content-Length: 853
+
+	{
+		"name": "my-topology",
+		"description": "The description is updated, too",
+		"nodes": [
+			{
+				"cachegroup": "edge1",
+				"parents": [
+					6
+				]
+			},
+			{
+				"cachegroup": "edge2",
+				"parents": [
+					6,
+					7
+				]
+			},
+			{
+				"cachegroup": "edge3",
+				"parents": [
+					7,
+					8
+				]
+			},
+			{
+				"cachegroup": "edge4",
+				"parents": [
+					8
+				]
+			},
+			{
+				"cachegroup": "mid2",
+				"parents": []
+			},
+			{
+				"cachegroup": "mid3",
+				"parents": []
+			},
+			{
+				"cachegroup": "mid4",
+				"parents": [
+					4
+				]
+			},
+			{
+				"cachegroup": "mid5",
+				"parents": [
+					4,
+					5
+				]
+			},
+			{
+				"cachegroup": "mid6",
+				"parents": [
+					5
+				]
+			}
+		]
+	}
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max

Review comment:
       Same comments as the (directly) above field-list.

##########
File path: docs/source/api/v2/topologies.rst
##########
@@ -0,0 +1,586 @@
+..
+..
+.. Licensed 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.
+..
+
+.. _to-api-topologies:
+
+***********************
+``topologies``
+***********************
+
+``GET``
+=======
+Retrieves :term:`Topologies`.
+
+:Auth. Required: Yes
+:Roles Required: "read-only"
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+-----------------------------------------------------+
+	| Name | Required | Description                                         |
+	+======+==========+=====================================================+
+	| name | no       | Return the :term:`Topology` with this name          |
+	+------+----------+-----------------------------------------------------+
+
+.. code-block:: http
+	:caption: Request Example
+
+	GET /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/1.1 200 OK
+	Access-Control-Allow-Credentials: true
+	Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie
+	Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE
+	Access-Control-Allow-Origin: *
+	Content-Encoding: gzip
+	Content-Type: application/json
+	Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:22:32 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: lF4MCJCinuQWz0flLAAZBrzbuPVsHrNn2BtTozRZojEjGpm76IsXBQK5QOwSwBoHac+D0C1Z3p7M8kdjcfgIIg==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:22:32 GMT
+	Content-Length: 205
+
+	{
+		"response": [
+			{
+				"description": "This is my topology",
+				"name": "my-topology",
+				"nodes": [
+					{
+						"cachegroup": "edge1",
+						"parents": [
+							7
+						]
+					},
+					{
+						"cachegroup": "edge2",
+						"parents": [
+							7,
+							8
+						]
+					},
+					{
+						"cachegroup": "edge3",
+						"parents": [
+							8,
+							9
+						]
+					},
+					{
+						"cachegroup": "edge4",
+						"parents": [
+							9
+						]
+					},
+					{
+						"cachegroup": "mid1",
+						"parents": []
+					},
+					{
+						"cachegroup": "mid2",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid3",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid4",
+						"parents": [
+							5
+						]
+					},
+					{
+						"cachegroup": "mid5",
+						"parents": [
+							5,
+							6
+						]
+					},
+					{
+						"cachegroup": "mid6",
+						"parents": [
+							6
+						]
+					}
+				],
+				"lastUpdated": "2020-04-13 17:12:34+00"
+			}
+		]
+	}
+
+``POST``
+========
+Create a new :term:`Topology`.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type:  Object
+
+Request Structure
+-----------------
+:description:		A short sentence that describes the topology.
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Request Example
+
+	POST /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+	Content-Length: 924
+
+	{
+		"name": "my-topology",
+		"description": "This is my topology",
+		"nodes": [
+			{
+				"cachegroup": "edge1",
+				"parents": [
+					7
+				]
+			},
+			{
+				"cachegroup": "edge2",
+				"parents": [
+					7,
+					8
+				]
+			},
+			{
+				"cachegroup": "edge3",
+				"parents": [
+					8,
+					9
+				]
+			},
+			{
+				"cachegroup": "edge4",
+				"parents": [
+					9
+				]
+			},
+			{
+				"cachegroup": "mid1",
+				"parents": []
+			},
+			{
+				"cachegroup": "mid2",
+				"parents": [
+					4
+				]
+			},
+			{
+				"cachegroup": "mid3",
+				"parents": [
+					4
+				]
+			},
+			{
+				"cachegroup": "mid4",
+				"parents": [
+					5
+				]
+			},
+			{
+				"cachegroup": "mid5",
+				"parents": [
+					5,
+					6
+				]
+			},
+			{
+				"cachegroup": "mid6",
+				"parents": [
+					6
+				]
+			}
+		]
+	}
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/1.1 200 OK
+	Access-Control-Allow-Credentials: true
+	Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie
+	Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE
+	Access-Control-Allow-Origin: *
+	Content-Encoding: gzip
+	Content-Type: application/json
+	Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:12:34 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: ftNcDRjYCDMkQM+o/szayKZriQZHGpcT0vNY0HpKgy88i0pXeEEeLGbUPh6LXtK7TvL76EgGECTzvCkcm+2LVA==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:12:34 GMT
+	Content-Length: 239
+
+	{
+		"alerts": [
+			{
+				"text": "topology was created.",
+				"level": "success"
+			}
+		],
+		"response": {
+			"description": "This is my topology",
+			"name": "my-topology",
+			"nodes": [
+				{
+					"cachegroup": "edge1",
+					"parents": [
+						7
+					]
+				},
+				{
+					"cachegroup": "edge2",
+					"parents": [
+						7,
+						8
+					]
+				},
+				{
+					"cachegroup": "edge3",
+					"parents": [
+						8,
+						9
+					]
+				},
+				{
+					"cachegroup": "edge4",
+					"parents": [
+						9
+					]
+				},
+				{
+					"cachegroup": "mid1",
+					"parents": []
+				},
+				{
+					"cachegroup": "mid2",
+					"parents": [
+						4
+					]
+				},
+				{
+					"cachegroup": "mid3",
+					"parents": [
+						4
+					]
+				},
+				{
+					"cachegroup": "mid4",
+					"parents": [
+						5
+					]
+				},
+				{
+					"cachegroup": "mid5",
+					"parents": [
+						5,
+						6
+					]
+				},
+				{
+					"cachegroup": "mid6",
+					"parents": [
+						6
+					]
+				}
+			],
+			"lastUpdated": "2020-04-13 17:12:34+00"
+		}
+	}
+
+``PUT``
+=======
+Updates a specific :term:`Topology`
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+---------------------------------------------------------+
+	| Name | Required | Description                                             |
+	+======+==========+=========================================================+
+	| name | yes      | The name of the :term:`Topology` to be updated          |
+	+------+----------+---------------------------------------------------------+
+
+:description:		A short sentence that describes the topology.
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max

Review comment:
       same comments as the above field-list.
   
   Also, you don't need to _always_ link "Topology" to the glossary, but I'd encourage you to consistently write it in Title Case so that it's clear that it's still referring to that term and not merely the concept of topology.

##########
File path: docs/source/api/v2/topologies.rst
##########
@@ -0,0 +1,586 @@
+..
+..
+.. Licensed 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.
+..
+
+.. _to-api-topologies:
+
+***********************
+``topologies``
+***********************
+
+``GET``
+=======
+Retrieves :term:`Topologies`.
+
+:Auth. Required: Yes
+:Roles Required: "read-only"
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+-----------------------------------------------------+
+	| Name | Required | Description                                         |
+	+======+==========+=====================================================+
+	| name | no       | Return the :term:`Topology` with this name          |
+	+------+----------+-----------------------------------------------------+
+
+.. code-block:: http
+	:caption: Request Example
+
+	GET /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/1.1 200 OK
+	Access-Control-Allow-Credentials: true
+	Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie
+	Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE
+	Access-Control-Allow-Origin: *
+	Content-Encoding: gzip
+	Content-Type: application/json
+	Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:22:32 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: lF4MCJCinuQWz0flLAAZBrzbuPVsHrNn2BtTozRZojEjGpm76IsXBQK5QOwSwBoHac+D0C1Z3p7M8kdjcfgIIg==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:22:32 GMT
+	Content-Length: 205
+
+	{
+		"response": [
+			{
+				"description": "This is my topology",
+				"name": "my-topology",
+				"nodes": [
+					{
+						"cachegroup": "edge1",
+						"parents": [
+							7
+						]
+					},
+					{
+						"cachegroup": "edge2",
+						"parents": [
+							7,
+							8
+						]
+					},
+					{
+						"cachegroup": "edge3",
+						"parents": [
+							8,
+							9
+						]
+					},
+					{
+						"cachegroup": "edge4",
+						"parents": [
+							9
+						]
+					},
+					{
+						"cachegroup": "mid1",
+						"parents": []
+					},
+					{
+						"cachegroup": "mid2",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid3",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid4",
+						"parents": [
+							5
+						]
+					},
+					{
+						"cachegroup": "mid5",
+						"parents": [
+							5,
+							6
+						]
+					},
+					{
+						"cachegroup": "mid6",
+						"parents": [
+							6
+						]
+					}
+				],
+				"lastUpdated": "2020-04-13 17:12:34+00"
+			}
+		]
+	}
+
+``POST``
+========
+Create a new :term:`Topology`.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type:  Object
+
+Request Structure
+-----------------
+:description:		A short sentence that describes the topology.
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Request Example
+
+	POST /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+	Content-Length: 924
+
+	{
+		"name": "my-topology",
+		"description": "This is my topology",
+		"nodes": [
+			{
+				"cachegroup": "edge1",
+				"parents": [
+					7
+				]
+			},
+			{
+				"cachegroup": "edge2",
+				"parents": [
+					7,
+					8
+				]
+			},
+			{
+				"cachegroup": "edge3",
+				"parents": [
+					8,
+					9
+				]
+			},
+			{
+				"cachegroup": "edge4",
+				"parents": [
+					9
+				]
+			},
+			{
+				"cachegroup": "mid1",
+				"parents": []
+			},
+			{
+				"cachegroup": "mid2",
+				"parents": [
+					4
+				]
+			},
+			{
+				"cachegroup": "mid3",
+				"parents": [
+					4
+				]
+			},
+			{
+				"cachegroup": "mid4",
+				"parents": [
+					5
+				]
+			},
+			{
+				"cachegroup": "mid5",
+				"parents": [
+					5,
+					6
+				]
+			},
+			{
+				"cachegroup": "mid6",
+				"parents": [
+					6
+				]
+			}
+		]
+	}
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max

Review comment:
       same comments as above field-list

##########
File path: docs/source/api/v2/topologies.rst
##########
@@ -0,0 +1,586 @@
+..
+..
+.. Licensed 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.
+..
+
+.. _to-api-topologies:
+
+***********************
+``topologies``
+***********************
+
+``GET``
+=======
+Retrieves :term:`Topologies`.
+
+:Auth. Required: Yes
+:Roles Required: "read-only"
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+-----------------------------------------------------+
+	| Name | Required | Description                                         |
+	+======+==========+=====================================================+
+	| name | no       | Return the :term:`Topology` with this name          |
+	+------+----------+-----------------------------------------------------+
+
+.. code-block:: http
+	:caption: Request Example
+
+	GET /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+
+Response Structure
+------------------
+:description:		A short sentence that describes the topology.
+:lastUpdated:		The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/1.1 200 OK
+	Access-Control-Allow-Credentials: true
+	Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie
+	Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE
+	Access-Control-Allow-Origin: *
+	Content-Encoding: gzip
+	Content-Type: application/json
+	Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:22:32 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: lF4MCJCinuQWz0flLAAZBrzbuPVsHrNn2BtTozRZojEjGpm76IsXBQK5QOwSwBoHac+D0C1Z3p7M8kdjcfgIIg==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:22:32 GMT
+	Content-Length: 205
+
+	{
+		"response": [
+			{
+				"description": "This is my topology",
+				"name": "my-topology",
+				"nodes": [
+					{
+						"cachegroup": "edge1",
+						"parents": [
+							7
+						]
+					},
+					{
+						"cachegroup": "edge2",
+						"parents": [
+							7,
+							8
+						]
+					},
+					{
+						"cachegroup": "edge3",
+						"parents": [
+							8,
+							9
+						]
+					},
+					{
+						"cachegroup": "edge4",
+						"parents": [
+							9
+						]
+					},
+					{
+						"cachegroup": "mid1",
+						"parents": []
+					},
+					{
+						"cachegroup": "mid2",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid3",
+						"parents": [
+							4
+						]
+					},
+					{
+						"cachegroup": "mid4",
+						"parents": [
+							5
+						]
+					},
+					{
+						"cachegroup": "mid5",
+						"parents": [
+							5,
+							6
+						]
+					},
+					{
+						"cachegroup": "mid6",
+						"parents": [
+							6
+						]
+					}
+				],
+				"lastUpdated": "2020-04-13 17:12:34+00"
+			}
+		]
+	}
+
+``POST``
+========
+Create a new :term:`Topology`.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type:  Object
+
+Request Structure
+-----------------
+:description:		A short sentence that describes the topology.
+:name:				The name of the topology. This can only be letters, numbers, and dashes.
+:nodes:				An array of nodes in the topology
+    :cachegroup:	The name of a topology
+    :parents:		The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max
+
+.. code-block:: http
+	:caption: Request Example
+
+	POST /api/2.0/topologies HTTP/1.1
+	User-Agent: python-requests/2.23.0
+	Accept-Encoding: gzip, deflate
+	Accept: */*
+	Connection: keep-alive
+	Cookie: mojolicious=...
+	Content-Length: 924

Review comment:
       If you don't include a `Content-Type` header, Pygments won't be able to set the highlighting context for the payload and falls back on "plain text".

##########
File path: docs/source/glossary.rst
##########
@@ -390,6 +390,10 @@ Glossary
 	Tenancies
 		Users are grouped into :dfn:`Tenants` (or :dfn:`Tenancies`) to segregate ownership of and permissions over :term:`Delivery Services` and their resources. To be clear, the notion of :dfn:`Tenancy` **only** applies within the context of :term:`Delivery Services` and does **not** apply permissions restrictions to any other aspect of Traffic Control.
 
+	Topology
+	Topologies
+		A structure composed of :term:`Cache Groups` and parent relationships, which is assignable to one or more :term:`Delivery Services`

Review comment:
       Missing period at the end of a sentence.

##########
File path: lib/go-tc/topologies.go
##########
@@ -0,0 +1,44 @@
+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.
+ */
+
+type Topology struct {
+	Description string         `json:"description" db:"description"`
+	Name        string         `json:"name" db:"name"`
+	Nodes       *[]*TopologyNode `json:"nodes"`

Review comment:
       needs `gofmt`

##########
File path: traffic_ops/app/db/migrations/20200422101648_create_topology_tables.sql
##########
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+
+-- +goose Up
+-- SQL in section 'Up' is executed when this migration is applied
+CREATE TABLE topology (
+    name text PRIMARY KEY,
+    description text NOT NULL,
+    last_updated timestamp with time zone DEFAULT now() NOT NULL
+);
+DROP TRIGGER IF EXISTS on_update_current_timestamp ON topology;
+CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON topology FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated();
+
+CREATE TABLE topology_cachegroup (
+    id SERIAL PRIMARY KEY,

Review comment:
       Most (all?) of our current numeric ids are `bigserial`, and it looks like later you have a foreign key constraint indexing this using a `bigint` so `bigserial` feels like a more appropriate type (even if it would hopefully never matter).

##########
File path: lib/go-tc/topologies.go
##########
@@ -0,0 +1,44 @@
+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.
+ */
+
+type Topology struct {
+	Description string         `json:"description" db:"description"`
+	Name        string         `json:"name" db:"name"`
+	Nodes       *[]*TopologyNode `json:"nodes"`
+	LastUpdated *TimeNoMod     `json:"lastUpdated" db:"last_updated"`
+}
+
+type TopologyNode struct {
+	Id          int        `json:"-" db:"id"`
+	Cachegroup  string     `json:"cachegroup" db:"cachegroup"`
+	Parents     []int      `json:"parents"`
+	LastUpdated *TimeNoMod `json:"-" db:"last_updated"`
+}
+
+type TopologyResponse struct {
+	Response Topology `json:"response"`
+	Alerts
+}
+
+type TopologiesResponse struct {
+	Response []Topology `json:"response"`
+	Alerts
+}

Review comment:
       You wanna GoDoc these? I won't make you, but as long as I have other comments thought I'd bring it up.

##########
File path: traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go
##########
@@ -379,6 +379,23 @@ func (cg *TOCacheGroup) deleteCoordinate(coordinateID int) error {
 	return nil
 }
 
+func GetCacheGroupByName(name string, apiInfo *api.APIInfoImpl) (*tc.CacheGroupNullable, error) {
+	originalParams := apiInfo.ReqInfo.Params
+	apiInfo.ReqInfo.Params = map[string]string{"name": name}
+	cacheGroup := TOCacheGroup{APIInfoImpl: *apiInfo}
+	result, userErr, sysErr, _ := cacheGroup.Read()
+	apiInfo.ReqInfo.Params = originalParams
+	if userErr != nil || sysErr != nil {
+		return nil, util.JoinErrs([]error{userErr, sysErr})

Review comment:
       You're returning potentially sensitive internal errors to clients by doing this.

##########
File path: traffic_ops/traffic_ops_golang/login/register.go
##########
@@ -208,7 +208,7 @@ func RegisterUser(w http.ResponseWriter, r *http.Request) {
 
 	var role string
 	var tenant string
-	user, exists, err := dbhelpers.GetUserByEmail(req.Email.Address.Address, inf.Tx.Tx)
+	user, exists, err := dbhelpers.GetUserByEmail(inf.Tx, req.Email.Address.Address)

Review comment:
       I don't think you meant to change this either

##########
File path: traffic_portal/app/src/assets/css/angular-ui-tree_2.22.6.css
##########
@@ -0,0 +1,146 @@
+.angular-ui-tree-empty {
+    border:1px dashed #bbb;
+    min-height:100px;
+    background-color:#e5e5e5;
+    background-image:-webkit-linear-gradient(45deg,#fff 25%,transparent 0,transparent 75%,#fff 0,#fff),-webkit-linear-gradient(45deg,#fff 25%,transparent 0,transparent 75%,#fff 0,#fff);
+    background-image:linear-gradient(45deg,#fff 25%,transparent 0,transparent 75%,#fff 0,#fff),linear-gradient(45deg,#fff 25%,transparent 0,transparent 75%,#fff 0,#fff);
+    background-size:60px 60px;
+    background-position:0 0,30px 30px;
+    pointer-events:none
+}
+.angular-ui-tree-nodes {
+    position:relative;
+    margin:0;
+    padding:0;
+    list-style:none
+}
+
+.angular-ui-tree-nodes .angular-ui-tree-nodes {
+    padding-left:30px;
+}
+
+.angular-ui-tree-node, .angular-ui-tree-placeholder {
+    position:relative;
+    margin:0;
+    padding:0;
+    min-height:20px;
+    line-height:20px
+}
+
+.angular-ui-tree-hidden {
+    display:none
+}
+
+.angular-ui-tree-placeholder {
+    margin:5px 0;
+    padding:0;
+    min-height:30px
+}
+
+.angular-ui-tree-handle {
+    cursor:move;
+    text-decoration:none;
+    font-weight:700;
+    box-sizing:border-box;
+    min-height:20px;
+    line-height:20px
+}
+
+.angular-ui-tree-drag {
+    position:absolute;
+    pointer-events:none;
+    z-index:999;
+    opacity:.8
+}
+
+.btn {
+    margin-right: 8px;
+    margin-bottom: 0;
+}
+
+.angular-ui-tree-handle {
+    background: #fff0f0;
+    border: 1px solid #dae2ea;
+    color: #7c9eb2;
+    padding: 10px 10px;
+}
+
+.angular-ui-tree-handle:hover {
+    color: #155724 !important;
+    background-color: #D4EDD7 !important;
+    border-color: #c3e6cb !important;
+}
+
+.angular-ui-tree-placeholder {
+    background: #f0f9ff;
+    border: 2px dashed #bed2db;
+    -webkit-box-sizing: border-box;
+    -moz-box-sizing: border-box;
+    box-sizing: border-box;
+}
+
+tr.angular-ui-tree-empty {
+    height: 100px
+}
+
+.group-title {
+    background-color: #687074 !important;
+    color: #FFF !important;
+}
+
+.tree-node {
+    border: 1px solid #dae2ea;
+    background: #f8faff;
+    color: #7c9eb2;
+}
+
+.nodrop {
+    background-color: #f2dede;
+}
+
+.tree-node-content {
+    height: 45px;
+    text-align: center;
+    font-size: 14px;
+    margin-bottom: 10px;
+}
+
+.tree-handle {
+    padding: 10px;
+    background: #428bca;
+    color: #FFF;
+    margin-right: 10px;
+}
+
+.angular-ui-tree-handle:hover {}
+
+.angular-ui-tree-placeholder {
+    background: #f0f9ff;
+    border: 2px dashed #bed2db;
+    -webkit-box-sizing: border-box;
+    -moz-box-sizing: border-box;
+    box-sizing: border-box;
+}
+
+.node circle {
+    fill: #999;
+}
+
+.node text {
+    font: 10px sans-serif;
+}
+
+.node--internal circle {
+    fill: #555;
+}
+
+.node--internal text {
+    text-shadow: 0 1px 0 #fff, 0 -1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff;
+}
+
+.link {
+    fill: none;
+    stroke: #555;
+    stroke-opacity: 0.4;
+    stroke-width: 1.5px;
+}

Review comment:
       This is library code? Or you wrote it yourself?
   
   It doesn't have a license header so I'm assuming the former. Can it be minified?

##########
File path: traffic_ops/traffic_ops_golang/deliveryservice/letsencryptcert.go
##########
@@ -374,77 +330,7 @@ func GetLetsEncryptCertificates(cfg *config.Config, req tc.DeliveryServiceLetsEn
 	}
 	tx2.Commit()
 
-	if foundPreviousAccount {
-		api.CreateChangeLogRawTx(api.ApiChange, "DS: "+*req.DeliveryService+", ID: "+strconv.Itoa(dsID)+", ACTION: Added SSL keys with Lets Encrypt", currentUser, logTx)
-		return nil
-	}
-
-	userKeyDer := x509.MarshalPKCS1PrivateKey(userPrivateKey)
-	if userKeyDer == nil {
-		log.Errorf("marshalling private key: nil der")
-		api.CreateChangeLogRawTx(api.ApiChange, "DS: "+*req.DeliveryService+", ID: "+strconv.Itoa(dsID)+", ACTION: FAILED to add SSL keys with Lets Encrypt", currentUser, logTx)
-		return errors.New("marshalling private key: nil der")
-	}
-	userKeyBuf := bytes.Buffer{}
-	if err := pem.Encode(&userKeyBuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: userKeyDer}); err != nil {
-		log.Errorf("pem-encoding private key: " + err.Error())
-		api.CreateChangeLogRawTx(api.ApiChange, "DS: "+*req.DeliveryService+", ID: "+strconv.Itoa(dsID)+", ACTION: FAILED to add SSL keys with Lets Encrypt", currentUser, logTx)
-		return errors.New("pem-encoding private key: " + err.Error())
-	}
-	userKeyPem := userKeyBuf.Bytes()
-	err = storeLEAccountInfo(userTx, myUser.Email, string(userKeyPem), myUser.Registration.URI)
-	if err != nil {
-		log.Errorf("storing user account info: " + err.Error())
-		api.CreateChangeLogRawTx(api.ApiChange, "DS: "+*req.DeliveryService+", ID: "+strconv.Itoa(dsID)+", ACTION: FAILED to add SSL keys with Lets Encrypt", currentUser, logTx)
-		return errors.New("storing user account info: " + err.Error())
-	}
-
 	api.CreateChangeLogRawTx(api.ApiChange, "DS: "+*req.DeliveryService+", ID: "+strconv.Itoa(dsID)+", ACTION: Added SSL keys with Lets Encrypt", currentUser, logTx)
 
 	return nil
 }
-
-func getStoredLetsEncryptInfo(tx *sql.Tx, email string) (*LEInfo, error) {
-	leInfo := LEInfo{}
-	selectQuery := `SELECT email, private_key, uri FROM lets_encrypt_account WHERE email = $1 LIMIT 1`
-	if err := tx.QueryRow(selectQuery, email).Scan(&leInfo.Email, &leInfo.Key, &leInfo.URI); err != nil {
-		if err == sql.ErrNoRows {
-			return nil, nil
-		}
-		return nil, errors.New("getting lets encrypt account record: " + err.Error())
-	}
-
-	decodedKeyBlock, _ := pem.Decode([]byte(leInfo.Key))
-	decodedKey, err := x509.ParsePKCS1PrivateKey(decodedKeyBlock.Bytes)
-	if err != nil {
-		return nil, errors.New("decoding private key for user account")
-	}
-	leInfo.PrivateKey = *decodedKey
-
-	return &leInfo, nil
-}
-
-func storeLEAccountInfo(tx *sql.Tx, email string, privateKey string, uri string) error {
-	q := `INSERT INTO lets_encrypt_account (email, private_key, uri) VALUES ($1, $2, $3)`
-	response, err := tx.Exec(q, email, privateKey, uri)
-	if err != nil {
-		return err
-	}
-
-	rows, err := response.RowsAffected()
-	if err != nil {
-		return err
-	}
-	if rows == 0 {
-		return errors.New("zero rows affected when inserting Let's Encrypt account information")
-	}
-
-	return nil
-}
-
-type LEInfo struct {
-	Email      string `db:"email"`
-	Key        string `db:"private_key"`
-	URI        string `db:"uri"`
-	PrivateKey rsa.PrivateKey
-}

Review comment:
       I don't think you meant to change this file.

##########
File path: traffic_ops/traffic_ops_golang/invalidationjobs/invalidationjobs.go
##########
@@ -220,7 +220,7 @@ func (job *InvalidationJob) Read() ([]interface{}, error, error, int) {
 	if err != nil {
 		return nil, nil, fmt.Errorf("getting accessible tenants for user - %v", err), http.StatusInternalServerError
 	}
-	if len(where) > 0 {
+	if len(where) == 0 {

Review comment:
       This shouldn't be reversed

##########
File path: traffic_ops/app/db/migrations/20200422101648_create_topology_tables.sql
##########
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+
+-- +goose Up
+-- SQL in section 'Up' is executed when this migration is applied
+CREATE TABLE topology (
+    name text PRIMARY KEY,
+    description text NOT NULL,
+    last_updated timestamp with time zone DEFAULT now() NOT NULL
+);
+DROP TRIGGER IF EXISTS on_update_current_timestamp ON topology;
+CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON topology FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated();
+
+CREATE TABLE topology_cachegroup (
+    id SERIAL PRIMARY KEY,
+    topology text NOT NULL,
+    cachegroup text NOT NULL,
+    last_updated timestamp with time zone DEFAULT now() NOT NULL,
+	CONSTRAINT topology_cachegroup_cachegroup_fkey FOREIGN KEY (cachegroup) REFERENCES cachegroup(name) ON UPDATE CASCADE ON DELETE RESTRICT,
+	CONSTRAINT topology_cachegroup_topology_fkey FOREIGN KEY (topology) REFERENCES topology(name) ON UPDATE CASCADE ON DELETE CASCADE,
+    CONSTRAINT unique_topology_cachegroup UNIQUE (topology, cachegroup)
+);
+CREATE INDEX topology_cachegroup_cachegroup_fkey ON topology_cachegroup USING btree (cachegroup);
+CREATE INDEX topology_cachegroup_topology_fkey ON topology_cachegroup USING btree (topology);
+DROP TRIGGER IF EXISTS on_update_current_timestamp ON topology_cachegroup;
+CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON topology_cachegroup FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated();
+
+CREATE TABLE topology_cachegroup_parents (
+    child bigint NOT NULL,
+    parent bigint NOT NULL,
+    rank integer NOT NULL,
+    last_updated timestamp with time zone DEFAULT now() NOT NULL,
+    CONSTRAINT topology_cachegroup_parents_rank_check CHECK (rank = 1 OR rank = 2),
+	CONSTRAINT topology_cachegroup_parents_child_fkey FOREIGN KEY (child) REFERENCES topology_cachegroup(id) ON UPDATE CASCADE ON DELETE CASCADE,
+	CONSTRAINT topology_cachegroup_parents_parent_fkey FOREIGN KEY (parent) REFERENCES topology_cachegroup(id) ON UPDATE CASCADE ON DELETE CASCADE,
+    CONSTRAINT unique_child_rank UNIQUE (child, rank),
+    CONSTRAINT unique_child_parent UNIQUE (child, parent)

Review comment:
       Is it not true that if a composite (A, B) must be unique and a composite (A, C) must also be unique then that implies that the composite (A, B, C) must be unique?

##########
File path: traffic_ops/traffic_ops_golang/deliveryservicesregexes/deliveryservicesregexes.go
##########
@@ -112,15 +112,14 @@ JOIN type as rt ON r.type = rt.id
 	if err != nil {
 		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("getting accessible tenants for user - %v", err))
 	}
-	if len(where) > 0 {
+	if len(where) == 0 {

Review comment:
       This definitely shouldn't be reversed

##########
File path: traffic_ops/client/topology.go
##########
@@ -0,0 +1,123 @@
+/*
+
+   Licensed 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.
+*/
+
+package client
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"net"
+	"net/http"
+	"net/url"
+)
+
+const (
+	API_TOPOLOGIES = apiBase + "/topologies"
+)
+
+// CreateTopology creates a topology and returns the response.
+func (to *Session) CreateTopology(top tc.Topology) (*tc.TopologyResponse, ReqInf, error) {
+	var remoteAddr net.Addr
+	reqBody, err := json.Marshal(top)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return nil, reqInf, err
+	}
+	resp, remoteAddr, err := to.request(http.MethodPost, API_TOPOLOGIES, reqBody)
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer resp.Body.Close()
+	var topResp tc.TopologyResponse
+	if err = json.NewDecoder(resp.Body).Decode(&topResp); err != nil {
+		return nil, reqInf, err
+	}
+	return &topResp, reqInf, nil
+}
+
+// GetTopologies returns all topologies.
+func (to *Session) GetTopologies() ([]tc.Topology, ReqInf, error) {
+	resp, remoteAddr, err := to.request(http.MethodGet, API_TOPOLOGIES, nil)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer resp.Body.Close()
+
+	var data tc.TopologiesResponse
+	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+		return nil, reqInf, err
+	}
+
+	return data.Response, reqInf, nil
+}
+
+// GetTopology returns the given topology by name.
+func (to *Session) GetTopology(name string) (*tc.Topology, ReqInf, error) {
+	reqUrl := fmt.Sprintf("%s?name=%s", API_TOPOLOGIES, url.QueryEscape(name))
+	resp, remoteAddr, err := to.request(http.MethodGet, reqUrl, nil)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer resp.Body.Close()
+
+	var data tc.TopologiesResponse
+	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+		return nil, reqInf, err
+	}
+
+	if len(data.Response) == 1 {
+		return &data.Response[0], reqInf, nil
+	}
+	return nil, reqInf, fmt.Errorf("expected one topology in response, instead got: %+v", data.Response)
+}
+
+// Update a Topology by ID

Review comment:
       missing period

##########
File path: traffic_ops/client/topology.go
##########
@@ -0,0 +1,123 @@
+/*
+
+   Licensed 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.
+*/
+
+package client
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"net"
+	"net/http"
+	"net/url"
+)
+
+const (
+	API_TOPOLOGIES = apiBase + "/topologies"
+)
+
+// CreateTopology creates a topology and returns the response.
+func (to *Session) CreateTopology(top tc.Topology) (*tc.TopologyResponse, ReqInf, error) {
+	var remoteAddr net.Addr
+	reqBody, err := json.Marshal(top)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return nil, reqInf, err
+	}
+	resp, remoteAddr, err := to.request(http.MethodPost, API_TOPOLOGIES, reqBody)
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer resp.Body.Close()
+	var topResp tc.TopologyResponse
+	if err = json.NewDecoder(resp.Body).Decode(&topResp); err != nil {
+		return nil, reqInf, err
+	}
+	return &topResp, reqInf, nil
+}
+
+// GetTopologies returns all topologies.
+func (to *Session) GetTopologies() ([]tc.Topology, ReqInf, error) {
+	resp, remoteAddr, err := to.request(http.MethodGet, API_TOPOLOGIES, nil)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer resp.Body.Close()
+
+	var data tc.TopologiesResponse
+	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+		return nil, reqInf, err
+	}
+
+	return data.Response, reqInf, nil
+}
+
+// GetTopology returns the given topology by name.
+func (to *Session) GetTopology(name string) (*tc.Topology, ReqInf, error) {
+	reqUrl := fmt.Sprintf("%s?name=%s", API_TOPOLOGIES, url.QueryEscape(name))
+	resp, remoteAddr, err := to.request(http.MethodGet, reqUrl, nil)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer resp.Body.Close()
+
+	var data tc.TopologiesResponse
+	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+		return nil, reqInf, err
+	}
+
+	if len(data.Response) == 1 {
+		return &data.Response[0], reqInf, nil
+	}
+	return nil, reqInf, fmt.Errorf("expected one topology in response, instead got: %+v", data.Response)
+}
+
+// Update a Topology by ID
+func (to *Session) UpdateTopologyByID(id int, pl tc.Topology) (tc.Alerts, ReqInf, error) {
+
+	var remoteAddr net.Addr
+	reqBody, err := json.Marshal(pl)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return tc.Alerts{}, reqInf, err
+	}
+	route := fmt.Sprintf("%s/%d", API_TOPOLOGIES, id)
+	resp, remoteAddr, err := to.request(http.MethodPut, route, reqBody)
+	if err != nil {
+		return tc.Alerts{}, reqInf, err
+	}
+	defer resp.Body.Close()
+	var alerts tc.Alerts
+	err = json.NewDecoder(resp.Body).Decode(&alerts)
+	return alerts, reqInf, nil

Review comment:
       shouldn't ignore that error; should be returned instead of `nil`

##########
File path: traffic_ops/traffic_ops_golang/server/servers_test.go
##########
@@ -178,199 +178,6 @@ func TestGetServersByCachegroup(t *testing.T) {
 
 }
 
-func TestGetMidServers(t *testing.T) {
-	mockDB, mock, err := sqlmock.New()
-	if err != nil {
-		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
-	}
-	defer mockDB.Close()
-
-	db := sqlx.NewDb(mockDB, "sqlmock")
-	defer db.Close()
-
-	testServers := getTestServers()
-	testServers = testServers[0:2]
-
-	testServers[1].Cachegroup = "parentCacheGroup"
-	testServers[1].CachegroupID = 2
-	testServers[1].Type = "MID"
-
-	cols := test.ColsFromStructByTag("db", tc.Server{})
-	rows := sqlmock.NewRows(cols)
-
-	for _, ts := range testServers {
-		rows = rows.AddRow(
-			ts.Cachegroup,
-			ts.CachegroupID,
-			ts.CDNID,
-			ts.CDNName,
-			ts.DomainName,
-			ts.GUID,
-			ts.HostName,
-			ts.HTTPSPort,
-			ts.ID,
-			ts.ILOIPAddress,
-			ts.ILOIPGateway,
-			ts.ILOIPNetmask,
-			ts.ILOPassword,
-			ts.ILOUsername,
-			ts.InterfaceMtu,
-			ts.InterfaceName,
-			ts.IP6Address,
-			ts.IP6IsService,
-			ts.IP6Gateway,
-			ts.IPAddress,
-			ts.IPIsService,
-			ts.IPNetmask,
-			ts.IPGateway,
-			ts.LastUpdated,
-			ts.MgmtIPAddress,
-			ts.MgmtIPGateway,
-			ts.MgmtIPNetmask,
-			ts.OfflineReason,
-			ts.PhysLocation,
-			ts.PhysLocationID,
-			ts.Profile,
-			ts.ProfileDesc,
-			ts.ProfileID,
-			ts.Rack,
-			ts.RevalPending,
-			ts.RouterHostName,
-			ts.RouterPortName,
-			ts.Status,
-			ts.StatusID,
-			ts.TCPPort,
-			ts.Type,
-			ts.TypeID,
-			ts.UpdPending,
-			ts.XMPPID,
-			ts.XMPPPasswd,
-		)
-	}
-	mock.ExpectBegin()
-	mock.ExpectQuery("SELECT").WillReturnRows(rows)
-	v := map[string]string{}
-
-	user := auth.CurrentUser{}
-
-	servers, userErr, sysErr, errCode := getServers(v, db.MustBegin(), &user)
-
-	if userErr != nil || sysErr != nil {
-		t.Errorf("getServers expected: no errors, actual: %v %v with status: %s", userErr, sysErr, http.StatusText(errCode))
-	}
-
-	cols2 := test.ColsFromStructByTag("db", tc.Server{})
-	rows2 := sqlmock.NewRows(cols2)
-
-	cgs := []tc.CacheGroup{}
-	testCG1 := tc.CacheGroup{
-		ID:                          1,
-		Name:                        "Cachegroup",
-		ShortName:                   "cg1",
-		Latitude:                    38.7,
-		Longitude:                   90.7,
-		ParentCachegroupID:          2,
-		SecondaryParentCachegroupID: 2,
-		LocalizationMethods: []tc.LocalizationMethod{
-			tc.LocalizationMethodDeepCZ,
-			tc.LocalizationMethodCZ,
-			tc.LocalizationMethodGeo,
-		},
-		Type:        "EDGE_LOC",
-		TypeID:      6,
-		LastUpdated: tc.TimeNoMod{Time: time.Now()},
-		Fallbacks: []string{
-			"cachegroup2",
-			"cachegroup3",
-		},
-		FallbackToClosest: true,
-	}
-	cgs = append(cgs, testCG1)
-	testCG2 := tc.CacheGroup{
-		ID:                          2,
-		Name:                        "parentCacheGroup",
-		ShortName:                   "pg1",
-		Latitude:                    38.7,
-		Longitude:                   90.7,
-		ParentCachegroupID:          1,
-		SecondaryParentCachegroupID: 1,
-		LocalizationMethods: []tc.LocalizationMethod{
-			tc.LocalizationMethodDeepCZ,
-			tc.LocalizationMethodCZ,
-			tc.LocalizationMethodGeo,
-		},
-		Type:        "MID_LOC",
-		TypeID:      7,
-		LastUpdated: tc.TimeNoMod{Time: time.Now()},
-	}
-	cgs = append(cgs, testCG2)
-
-	ts := servers[1]
-	rows2 = rows2.AddRow(
-		ts.Cachegroup,
-		ts.CachegroupID,
-		ts.CDNID,
-		ts.CDNName,
-		ts.DomainName,
-		ts.GUID,
-		ts.HostName,
-		ts.HTTPSPort,
-		ts.ID,
-		ts.ILOIPAddress,
-		ts.ILOIPGateway,
-		ts.ILOIPNetmask,
-		ts.ILOPassword,
-		ts.ILOUsername,
-		ts.InterfaceMtu,
-		ts.InterfaceName,
-		ts.IP6Address,
-		ts.IP6IsService,
-		ts.IP6Gateway,
-		ts.IPAddress,
-		ts.IPIsService,
-		ts.IPNetmask,
-		ts.IPGateway,
-		ts.LastUpdated,
-		ts.MgmtIPAddress,
-		ts.MgmtIPGateway,
-		ts.MgmtIPNetmask,
-		ts.OfflineReason,
-		ts.PhysLocation,
-		ts.PhysLocationID,
-		ts.Profile,
-		ts.ProfileDesc,
-		ts.ProfileID,
-		ts.Rack,
-		ts.RevalPending,
-		ts.RouterHostName,
-		ts.RouterPortName,
-		ts.Status,
-		ts.StatusID,
-		ts.TCPPort,
-		ts.Type,
-		ts.TypeID,
-		ts.UpdPending,
-		ts.XMPPID,
-		ts.XMPPPasswd,
-	)
-
-	mock.ExpectBegin()
-	mock.ExpectQuery("SELECT").WillReturnRows(rows2)
-	mid, userErr, sysErr, errCode := getMidServers(servers, db.MustBegin())
-
-	if userErr != nil || sysErr != nil {
-		t.Fatalf("getMidServers expected: no errors, actual: %v %v with status: %s", userErr, sysErr, http.StatusText(errCode))
-	}
-	if len(mid) != 1 {
-		t.Fatalf("getMidServers expected: len(mid) == 1, actual: %v", len(mid))
-	}
-	if mid[0].Type != "MID" || *(mid[0].CachegroupID) != 2 || *(mid[0].Cachegroup) != "parentCacheGroup" {
-		t.Fatalf("getMidServers expected: Type == MID, actual: %v", mid[0].Type)
-		t.Fatalf("getMidServers expected: CachegroupID == 2, actual: %v", *(mid[0].CachegroupID))
-		t.Fatalf("getMidServers expected: Cachegroup == parentCacheGroup, actual: %v", *(mid[0].Cachegroup))
-	}
-}
-
 type SortableServers []tc.Server
 
 func (s SortableServers) Len() int {

Review comment:
       I don't think you meant to change this file

##########
File path: traffic_portal/app/src/common/api/TopologyService.js
##########
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+
+var TopologyService = function($http, ENV, locationUtils, messageModel) {
+
+	this.getTopologies = function(queryParams) {
+		return $http.get(ENV.api['root'] + 'topologies', { params: queryParams }).then(
+			function(result) {
+				return result.data.response;
+			},
+			function(err) {
+				throw err;
+			}
+		);
+	};
+
+	this.createTopology = function(topology) {
+		return $http.post(ENV.api['root'] + 'topologies', topology).then(
+			function(result) {
+				return result;
+			},
+			function(err) {
+				messageModel.setMessages(err.data.alerts, false);
+				throw err;
+			}
+		);
+	};
+
+	this.updateTopology = function(topology) {
+		return $http.put(ENV.api['root'] + 'topologies?name=' + topology.name, topology).then(

Review comment:
       don't forget to `encodeURIComponent` that URI component

##########
File path: traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go
##########
@@ -740,7 +758,6 @@ func scanUserRow(row *sql.Row) (tc.User, bool, error) {
 		&u.FullName,
 		&u.GID,
 		&u.ID,
-		&u.LastUpdated,
 		&u.NewUser,
 		&u.PhoneNumber,
 		&u.PostalCode,

Review comment:
       I don't think you meant to change this file

##########
File path: traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go
##########
@@ -341,21 +270,20 @@ func getDSSummary(client *influx.Client, conf *tc.TrafficDSStatsConfig, db strin
 		})
 	ts, err := getSummary(db, q, client)
 	if err != nil || ts == nil {
-		return nil, nil, nil, err
+		return nil, err
 	}
 
-	var totalKB *float64
-	var totalTXN *float64
-	value := float64(ts.Count*60) * ts.Average
+	s.TrafficStatsSummary = *ts
+
+	value := float64(s.Count*60) * s.Average
 	if conf.MetricType == "kbps" {
-		// TotalBytes is actually in units of kB....
-		value /= 8
-		totalKB = &value
+		value /= 1000
+		s.TotalBytes = &value
 	} else {
-		totalTXN = &value
+		s.TotalTransactions = &value
 	}
 
-	return ts, totalKB, totalTXN, nil
+	return &s, nil
 }
 
 func dsTenantIDFromXMLID(xmlid string, tx *sql.Tx) (bool, uint, error) {

Review comment:
       This file should not have been changed

##########
File path: traffic_control/clients/python/trafficops/tosession.py
##########
@@ -21,6 +21,8 @@
 # Core Modules
 import logging
 import sys
+from requests import Response
+from typing import Any, Dict, List, Tuple, Union

Review comment:
       Python is dead
   
   Long live Python

##########
File path: traffic_ops/traffic_ops_golang/topology/topologies.go
##########
@@ -0,0 +1,475 @@
+package topology
+
+/*
+ * 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 (
+	"errors"
+	"fmt"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-tc/tovalidate"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/cachegroup"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+	validation "github.com/go-ozzo/ozzo-validation"
+	"github.com/lib/pq"
+	"net/http"
+)
+
+type TOTopology struct {
+	api.APIInfoImpl `json:"-"`
+	tc.Topology
+}
+
+func (topology *TOTopology) DeleteQueryBase() string {
+	return deleteQueryBase()
+}
+
+func (topology *TOTopology) ParamColumns() map[string]dbhelpers.WhereColumnInfo {
+	return map[string]dbhelpers.WhereColumnInfo{
+		"name":        dbhelpers.WhereColumnInfo{"t.name", nil},
+		"description": dbhelpers.WhereColumnInfo{"t.description", nil},
+		"lastUpdated": dbhelpers.WhereColumnInfo{"t.last_updated", nil},
+	}
+}
+
+func (topology *TOTopology) DeleteKeyOptions() map[string]dbhelpers.WhereColumnInfo {
+	return topology.ParamColumns()
+}
+
+func (topology *TOTopology) SetLastUpdated(time tc.TimeNoMod) { topology.LastUpdated = &time }
+
+func (topology TOTopology) GetKeyFieldsInfo() []api.KeyFieldInfo {
+	return []api.KeyFieldInfo{{"name", api.GetStringKey}}
+}
+
+func (topology *TOTopology) GetType() string {
+	return "topology"
+}
+
+func (topology *TOTopology) Validate() error {
+	nameRule := validation.NewStringRule(tovalidate.IsAlphanumericUnderscoreDash, "must consist of only alphanumeric, dash, or underscore characters.")
+	rules := validation.Errors{}
+	rules["name"] = validation.Validate(topology.Name, validation.Required, nameRule)
+
+	nodeCount := len(*topology.Nodes)
+	if nodeCount < 1 {
+		rules["length"] = fmt.Errorf("must provide 1 or more node, %v found", nodeCount)
+	}
+	cacheGroups := make([]*tc.CacheGroupNullable, nodeCount)
+	var err error
+	cacheGroupsExist := true
+	for index := 0; index < nodeCount; index++ {
+		node := (*topology.Nodes)[index]
+		rules[fmt.Sprintf("node %v parents size", index)] = validation.Validate((*node).Parents, validation.Length(0, 2))
+		rules[fmt.Sprintf("node %v duplicate parents", index)] = checkForDuplicateParents(topology.Nodes, index)
+		rules[fmt.Sprintf("node %v self parent", index)] = checkForSelfParents(topology.Nodes, index)
+		if cacheGroups[index], err = cachegroup.GetCacheGroupByName((*node).Cachegroup, &topology.APIInfoImpl); err != nil {
+			rules[fmt.Sprintf("cachegroup %v not found", index)] = fmt.Errorf("error getting cachegroup %v: %v", (*node).Cachegroup, err.Error())
+			cacheGroupsExist = false
+		}
+	}
+	rules["duplicate cachegroup name"] = checkUniqueCacheGroupNames(topology.Nodes)
+
+	if cacheGroupsExist {
+		for index := 0; index < nodeCount; index++ {
+			rules[fmt.Sprintf("parent '%v' edge type", (*topology.Nodes)[index].Cachegroup)] = checkForEdgeParents(topology.Nodes, &cacheGroups, index)
+		}
+
+		for _, leafMid := range *checkForLeafMids(topology.Nodes, &cacheGroups) {
+			rules[fmt.Sprintf("node %v leaf mid", (*leafMid).Cachegroup)] = fmt.Errorf("cachegroup %v's type is %v; it cannot be a leaf (it must have at least 1 child)", (*leafMid).Cachegroup, tc.MidCacheGroupType)
+		}
+	}
+	rules["topology cycles"] = checkForCycles(topology.Nodes)
+
+	errs := tovalidate.ToErrors(rules)
+	return util.JoinErrs(errs)
+}
+
+// Implementation of the Identifier, Validator interface functions
+func (topology TOTopology) GetKeys() (map[string]interface{}, bool) {
+	return map[string]interface{}{"name": topology.Name}, true
+}
+
+func (topology *TOTopology) SetKeys(keys map[string]interface{}) {
+	topology.Name, _ = keys["name"].(string)
+}
+
+func (topology *TOTopology) GetAuditName() string {
+	return topology.Name
+}
+
+func (topology *TOTopology) Create() (error, error, int) {
+	tx := topology.APIInfo().Tx.Tx
+	err := tx.QueryRow(insertQuery(), topology.Name, topology.Description).Scan(&topology.Name, &topology.Description, &topology.LastUpdated)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		return userErr, sysErr, errCode

Review comment:
       seems like you could just `return api.ParseDBError(err)`

##########
File path: traffic_ops/app/db/migrations/20200422101648_create_topology_tables.sql
##########
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+
+-- +goose Up
+-- SQL in section 'Up' is executed when this migration is applied
+CREATE TABLE topology (
+    name text PRIMARY KEY,
+    description text NOT NULL,
+    last_updated timestamp with time zone DEFAULT now() NOT NULL
+);
+DROP TRIGGER IF EXISTS on_update_current_timestamp ON topology;
+CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON topology FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated();
+
+CREATE TABLE topology_cachegroup (
+    id SERIAL PRIMARY KEY,
+    topology text NOT NULL,
+    cachegroup text NOT NULL,
+    last_updated timestamp with time zone DEFAULT now() NOT NULL,
+	CONSTRAINT topology_cachegroup_cachegroup_fkey FOREIGN KEY (cachegroup) REFERENCES cachegroup(name) ON UPDATE CASCADE ON DELETE RESTRICT,
+	CONSTRAINT topology_cachegroup_topology_fkey FOREIGN KEY (topology) REFERENCES topology(name) ON UPDATE CASCADE ON DELETE CASCADE,
+    CONSTRAINT unique_topology_cachegroup UNIQUE (topology, cachegroup)

Review comment:
       I thought the point of IDs was because we didn't necessarily want this? So that a cachegroup could appear twice in the same topology?
   
   If not, why not just make this unique composite the primary key?

##########
File path: traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js
##########
@@ -46,6 +46,10 @@ var DialogSelectController = function(params, collection, $scope, $uibModalInsta
 		}
 
 		$scope.key = $scope.params.key || 'id';
+
+		$scope.required = ($scope.params.required !== undefined) ? $scope.params.required : true;
+
+		$scope.selectedItemKeyValue = ($scope.params.selectedItemKeyValue === undefined || !$scope.params.selectedItemKeyValue) ? null : $scope.params.selectedItemKeyValue;

Review comment:
       I believe this can be done as
   ```javascript
   $scope.selectedItemKey = $scope.params.selectedItemKeyValue || null;
   ```

##########
File path: traffic_portal/app/src/common/modules/table/topologyCacheGroups/table.selectTopologyCacheGroups.tpl.html
##########
@@ -0,0 +1,55 @@
+<!--
+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.
+-->
+
+
+
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
+    <h3 class="modal-title">Add Cache Groups to {{(parent.cachegroup) ? parent.cachegroup : 'ROOT'}}</h3>
+</div>
+<div class="modal-body">
+    <table id="availableCacheGroupsTable" class="table responsive-utilities jambo_table" style="table-layout:fixed; width:100%;">
+        <thead>
+        <tr class="headings">
+            <th style="padding-left: 10px;"><input id="selectAllCB" type="checkbox" ng-click="selectAll($event)"></th>
+            <th>Name</th>
+            <th>Type</th>
+            <th>Latitude</th>
+            <th>Longitude</th>
+            <th style="text-align: right;">Actions</th>
+        </tr>
+        </thead>
+        <tbody>
+        <tr id="{{::cg.id}}" title="{{cg.used ? 'Cache group already used in topology' : ''}}" class="cg-row" ng-class="{ 'success': cg.selected && !cg.used, 'active': cg.used }" ng-repeat="cg in ::cacheGroups" ng-click="onChange(cg)">
+            <td><input type="checkbox" ng-model="cg.selected" ng-disabled="cg.used"></td>
+            <td data-search="^{{::cg.name}}$">{{::cg.name}}</td>
+            <td data-search="^{{::cg.typeName}}$">{{::cg.typeName}}</td>
+            <td data-search="^{{::cg.latitude}}$">{{::cg.latitude}}</td>
+            <td data-search="^{{::cg.longitude}}$">{{::cg.longitude}}</td>
+            <td style="text-align: right;">
+                <a class="link action-link" title="View Cache Group Servers" ng-click="viewCacheGroupServers(cg, $event)">View Servers</a>
+            </td>
+        </tr>
+        </tbody>
+    </table>
+</div>
+<div class="modal-footer">
+    <button class="btn btn-link" ng-click="cancel()">cancel</button>
+    <button class="btn btn-primary" ng-click="submit()">Submit</button>
+</div>

Review comment:
       Same as above RE: `<footer>` and button `type`

##########
File path: traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/table.topologyCacheGroupServers.tpl.html
##########
@@ -0,0 +1,48 @@
+<!--
+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.
+-->
+
+
+
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
+    <h3 class="modal-title">Servers assigned to {{::cacheGroupName}}</h3>
+</div>
+<div class="modal-body">
+    <table id="topologyCacheGroupServersTable" class="table responsive-utilities jambo_table" style="table-layout:fixed; width:100%;">
+        <thead>
+        <tr class="headings">
+            <th>Host</th>
+            <th>Domain</th>
+            <th>CDN</th>
+            <th>Status</th>
+        </tr>
+        </thead>
+        <tbody>
+        <tr ng-repeat="s in ::cacheGroupServers">
+            <td data-search="^{{::s.hostName}}$">{{::s.hostName}}</td>
+            <td data-search="^{{::s.domainName}}$">{{::s.domainName}}</td>
+            <td data-search="^{{::s.cdnName}}$">{{::s.cdnName}}</td>
+            <td data-search="^{{::s.status}}$">{{::s.status}}</td>
+        </tr>
+        </tbody>
+    </table>
+</div>
+<div class="modal-footer">
+    <button class="btn btn-primary" ng-click="cancel()">Close</button>

Review comment:
       Same as above RE: button `type`

##########
File path: traffic_portal/app/src/common/modules/navigation/navigation.tpl.html
##########
@@ -52,6 +52,7 @@
                 </li>
                 <li class="side-menu-category"><a href="javascript:void(0);"><i class="fa fa-sm fa-chevron-right"></i> Topology</span></a>
                     <ul class="nav child_menu" style="display: none">
+<!--                        <li class="side-menu-category-item" ng-if="hasCapability('topologies-read')" ng-class="{'current-page': isState('trafficPortal.private.topologies')}"><a href="/#!/topologies">Topologies</a></li>-->

Review comment:
       Do we want this commented out?

##########
File path: traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/table.topologyCacheGroupServers.tpl.html
##########
@@ -0,0 +1,48 @@
+<!--
+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.
+-->
+
+
+
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
+    <h3 class="modal-title">Servers assigned to {{::cacheGroupName}}</h3>
+</div>
+<div class="modal-body">
+    <table id="topologyCacheGroupServersTable" class="table responsive-utilities jambo_table" style="table-layout:fixed; width:100%;">
+        <thead>
+        <tr class="headings">
+            <th>Host</th>
+            <th>Domain</th>
+            <th>CDN</th>
+            <th>Status</th>
+        </tr>
+        </thead>
+        <tbody>
+        <tr ng-repeat="s in ::cacheGroupServers">
+            <td data-search="^{{::s.hostName}}$">{{::s.hostName}}</td>
+            <td data-search="^{{::s.domainName}}$">{{::s.domainName}}</td>
+            <td data-search="^{{::s.cdnName}}$">{{::s.cdnName}}</td>
+            <td data-search="^{{::s.status}}$">{{::s.status}}</td>
+        </tr>
+        </tbody>
+    </table>
+</div>
+<div class="modal-footer">
+    <button class="btn btn-primary" ng-click="cancel()">Close</button>
+</div>

Review comment:
       If you want a footer you could use a [`<footer>` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/footer)

##########
File path: traffic_portal/app/src/common/api/TopologyService.js
##########
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+
+var TopologyService = function($http, ENV, locationUtils, messageModel) {
+
+	this.getTopologies = function(queryParams) {
+		return $http.get(ENV.api['root'] + 'topologies', { params: queryParams }).then(
+			function(result) {
+				return result.data.response;
+			},
+			function(err) {
+				throw err;
+			}
+		);
+	};
+
+	this.createTopology = function(topology) {
+		return $http.post(ENV.api['root'] + 'topologies', topology).then(
+			function(result) {
+				return result;
+			},
+			function(err) {
+				messageModel.setMessages(err.data.alerts, false);
+				throw err;
+			}
+		);
+	};
+
+	this.updateTopology = function(topology) {
+		return $http.put(ENV.api['root'] + 'topologies?name=' + topology.name, topology).then(
+			function(result) {
+				return result;
+			},
+			function(err) {
+				messageModel.setMessages(err.data.alerts, false);
+				throw err;
+			}
+		);
+	};
+
+	this.deleteTopology = function(topology) {
+		return $http.delete(ENV.api['root'] + "topologies?name=" + topology.name).then(

Review comment:
       same as above RE: encoding

##########
File path: traffic_portal/app/src/common/modules/table/topologyCacheGroups/TableSelectTopologyCacheGroupsController.js
##########
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ */
+
+var TableSelectTopologyCacheGroupsController = function(parent, topology, cacheGroups, usedCacheGroupNames, $scope, $uibModal, $uibModalInstance, serverService) {
+
+	let selectedCacheGroups = [],
+		usedCacheGroupCount = 0;
+
+	let addAll = function() {
+		markVisibleCacheGroups(true);
+	};
+
+	let removeAll = function() {
+		markVisibleCacheGroups(false);
+	};
+
+	let markVisibleCacheGroups = function(selected) {
+		let visibleCacheGroupIds = $('#availableCacheGroupsTable tr.cg-row').map(
+			function() {
+				return parseInt($(this).attr('id'));
+			}).get();
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			if (visibleCacheGroupIds.includes(cg.id)) {
+				cg['selected'] = selected;
+			}
+			return cg;
+		});
+		updateSelectedCount();
+	};
+
+	let decorateCacheGroups = function() {
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			let isUsed = usedCacheGroupNames.find(function(usedCacheGroupName) { return usedCacheGroupName === cg.name });
+			if (isUsed) {
+				cg['selected'] = true;
+				cg['used'] = true;
+				usedCacheGroupCount++;
+			}
+			return cg;
+		});
+	};
+
+	let updateSelectedCount = function() {
+		selectedCacheGroups = $scope.cacheGroups.filter(function(cg) { return cg['selected'] == true && !cg['used'] } );
+		$('div.selected-count').html('<strong><span class="text-success">' + selectedCacheGroups.length + ' selected</span><span> | ' + usedCacheGroupCount + ' already used in topology</span></strong>');
+	};
+
+	$scope.parent = parent;
+
+	$scope.cacheGroups = cacheGroups.filter(function(cg) {
+		// all cg types (ORG_LOC, MID_LOC, EDGE_LOC) can be added to the root of a topology
+		// but only EDGE_LOC and MID_LOC can be added farther down the topology tree
+		if (parent.type === 'ROOT') return (cg.typeName === 'EDGE_LOC' || cg.typeName === 'MID_LOC' || cg.typeName === 'ORG_LOC');
+		return (cg.typeName === 'EDGE_LOC' || cg.typeName === 'MID_LOC');
+	});
+
+	$scope.selectAll = function($event) {
+		let checkbox = $event.target;

Review comment:
       checkbox never modified, could be `const`

##########
File path: traffic_portal/app/src/common/modules/table/topologies/table.topologies.tpl.html
##########
@@ -0,0 +1,51 @@
+<!--
+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.
+-->
+
+<div class="x_panel">
+    <div class="x_title">
+        <ol class="breadcrumb pull-left">
+            <li class="active">Topologies</li>
+        </ol>
+        <div class="pull-right">
+            <button class="btn btn-primary" title="Create Topology" ng-click="createTopology()"><i class="fa fa-plus"></i></button>
+            <button class="btn btn-default" title="Refresh" ng-click="refresh()"><i class="fa fa-refresh"></i></button>

Review comment:
       Default button type is `submit`; these should be `button[type="button"]`

##########
File path: traffic_ops/traffic_ops_golang/server/servers.go
##########
@@ -335,11 +335,14 @@ func getMidServers(servers []tc.ServerNullable, tx *sqlx.Tx) ([]tc.ServerNullabl
 	edgeIDs := strings.Join(ids, ",")
 	// TODO: include secondary parent?
 	q := selectQuery() + `
-WHERE t.name = 'MID' AND s.cachegroup IN (
-SELECT cg.parent_cachegroup_id FROM cachegroup AS cg
-WHERE cg.id IN (
-SELECT s.cachegroup FROM server AS s
-WHERE s.id IN (` + edgeIDs + `)))
+WHERE s.id IN (
+	SELECT mid.id FROM server mid
+	JOIN cachegroup cg ON cg.id IN (
+		SELECT cg.parent_cachegroup_id
+		FROM server s
+		JOIN cachegroup cg ON cg.id = s.cachegroup
+		WHERE s.id IN (` + edgeIDs + `))
+	JOIN type t ON mid.type = (SELECT id FROM type WHERE name = 'MID'))

Review comment:
       I don't think you meant to change this

##########
File path: traffic_portal/app/src/common/service/utils/TopologyUtils.js
##########
@@ -0,0 +1,111 @@
+/*
+ * 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.
+ */
+
+var TopologyUtils = function() {
+
+	let normalizedTopology;
+
+	let flattenTopology = function(topologyTree, fromScratch) {
+		if (fromScratch) normalizedTopology.nodes = [];
+		topologyTree.forEach(function(node) {
+			if (node.cachegroup) {
+				normalizedTopology.nodes.push({
+					cachegroup: node.cachegroup,
+					parent: node.parent,
+					secParent: node.secParent,
+					parents: []
+				});
+			}
+			if (node.children && node.children.length > 0) {
+				flattenTopology(node.children, false);
+			}
+		});
+	};
+
+	let addNodeIndexes = function() {
+		normalizedTopology.nodes.forEach(function(currentNode) {
+			let parentNodeIndex = _.findIndex(normalizedTopology.nodes, function(node) { return currentNode.parent === node.cachegroup });
+			let secParentNodeIndex = _.findIndex(normalizedTopology.nodes, function(node) { return currentNode.secParent === node.cachegroup });
+			if (parentNodeIndex > -1) {
+				currentNode.parents.push(parentNodeIndex);
+				if (secParentNodeIndex > -1) {
+					currentNode.parents.push(secParentNodeIndex);
+				}
+			}
+		});
+	};
+
+	this.getNormalizedTopology = function(name, description, topologyTree) {
+		// build a normalized (flat) topology with parent indexes required for topology create/update
+		normalizedTopology = {
+			name: name,
+			description: description,
+			nodes: []
+		};
+		flattenTopology(topologyTree, true);
+		addNodeIndexes();
+		return normalizedTopology;
+	};
+
+	this.getTopologyTree = function(topology) {
+		let nodes = angular.copy(topology.nodes);
+		let roots = [], // topology items without parents (primary or secondary)
+			all = {};
+
+		nodes.forEach(function(node, index) {
+			all[index] = node;
+		});

Review comment:
       It looks like what you're trying to do is
   ```javascript
   const all = new Map(nodes.map((node,i)=>[i,node]));
   // or if webpack won't let you do anonymous "fat-arrow" functions:
   const all = new Map(nodes.map(function(node, i){return [i,node];}));
   ```
   
   That'll allow you to just iterate directly instead of using `Object.keys(all).forEach(function (guid) {` like:
   ```javascript
   for (const [k, v] of all) {
   	//...
   }
   ```

##########
File path: traffic_portal/app/src/common/modules/table/topologyCacheGroups/TableSelectTopologyCacheGroupsController.js
##########
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ */
+
+var TableSelectTopologyCacheGroupsController = function(parent, topology, cacheGroups, usedCacheGroupNames, $scope, $uibModal, $uibModalInstance, serverService) {
+
+	let selectedCacheGroups = [],
+		usedCacheGroupCount = 0;
+
+	let addAll = function() {
+		markVisibleCacheGroups(true);
+	};
+
+	let removeAll = function() {
+		markVisibleCacheGroups(false);
+	};
+
+	let markVisibleCacheGroups = function(selected) {
+		let visibleCacheGroupIds = $('#availableCacheGroupsTable tr.cg-row').map(
+			function() {
+				return parseInt($(this).attr('id'));
+			}).get();
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			if (visibleCacheGroupIds.includes(cg.id)) {
+				cg['selected'] = selected;
+			}
+			return cg;
+		});
+		updateSelectedCount();
+	};
+
+	let decorateCacheGroups = function() {
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			let isUsed = usedCacheGroupNames.find(function(usedCacheGroupName) { return usedCacheGroupName === cg.name });
+			if (isUsed) {
+				cg['selected'] = true;
+				cg['used'] = true;
+				usedCacheGroupCount++;
+			}
+			return cg;
+		});
+	};
+
+	let updateSelectedCount = function() {
+		selectedCacheGroups = $scope.cacheGroups.filter(function(cg) { return cg['selected'] == true && !cg['used'] } );
+		$('div.selected-count').html('<strong><span class="text-success">' + selectedCacheGroups.length + ' selected</span><span> | ' + usedCacheGroupCount + ' already used in topology</span></strong>');

Review comment:
       Manipulating HTML in a controller like this would ideally just be done with binding and an ng-if - why do it like this?

##########
File path: traffic_portal/app/src/common/modules/form/topology/FormTopologyController.js
##########
@@ -0,0 +1,258 @@
+/*
+ * 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.
+ */
+
+var FormTopologyController = function(topology, cacheGroups, $anchorScroll, $scope, $location, $uibModal, formUtils, locationUtils, topologyUtils, messageModel) {
+
+	let cacheGroupNamesInTopology = [];
+
+	let hydrateTopology = function() {
+		// add some needed fields to each cache group (aka node) of a topology
+		topology.nodes.forEach(function(node) {
+			let cacheGroup = cacheGroups.find( function(cg) { return cg.name === node.cachegroup} );
+			Object.assign(node, { id: cacheGroup.id, type: cacheGroup.typeName });
+		});
+	};
+
+	let removeSecParentReferences = function(topologyTree, secParentName) {
+		// when a cache group is removed, any references to the cache group as a secParent need to be removed
+		topologyTree.forEach(function(node) {
+			if (node.secParent && node.secParent === secParentName) {
+				node.secParent = '';
+			}
+			if (node.children && node.children.length > 0) {
+				removeSecParentReferences(node.children, secParentName);
+			}
+		});
+	};
+
+	// build a list of cache group names currently in the topology
+	let buildCacheGroupNamesInTopology = function(topologyTree, fromScratch) {
+		if (fromScratch) cacheGroupNamesInTopology = [];
+		topologyTree.forEach(function(node) {
+			if (node.cachegroup) {
+				cacheGroupNamesInTopology.push(node.cachegroup);
+			}
+			if (node.children && node.children.length > 0) {
+				buildCacheGroupNamesInTopology(node.children, false);
+			}
+		});
+	};
+
+	$scope.topology = topology;
+
+	$scope.topologyTree = [];
+
+	$scope.topologyTreeOptions = {
+		beforeDrop: function(evt) {
+			let node = evt.source.nodeScope.$modelValue,
+				parent = evt.dest.nodesScope.$parent.$modelValue;
+
+			if (!parent || !node) {
+				return false; // no dropping outside the toplogy tree and you need a node to drop
+			}
+
+			// ORG_LOC cannot have a parent
+			if (node.type === 'ORG_LOC' && parent.cachegroup) {
+				$anchorScroll(); // scrolls window to top
+				messageModel.setMessages([ { level: 'error', text: 'Cache groups of ORG_LOC type must not have a parent.' } ], false);
+				return false;
+			}
+
+			// EDGE_LOC cannot have children
+			if (parent.type === 'EDGE_LOC') {
+				$anchorScroll(); // scrolls window to top
+				messageModel.setMessages([ { level: 'error', text: 'Cache groups of EDGE_LOC type must not have children.' } ], false);
+				return false;
+			}
+
+			// update the parent and secParent fields of the node on successful drop
+			if (parent.cachegroup) {
+				node.parent = parent.cachegroup; // change the node parent based on where the node is dropped
+				if (node.parent === node.secParent) {
+					// node parent and secParent cannot be the same
+					node.secParent = "";
+				}
+			} else {
+				// the node was dropped at the root of the topology. no parents.
+				node.parent = "";
+				node.secParent = "";
+			}
+			return true;
+		}
+	};
+
+	$scope.navigateToPath = locationUtils.navigateToPath;
+
+	$scope.hasError = formUtils.hasError;
+
+	$scope.hasPropertyError = formUtils.hasPropertyError;
+
+	$scope.nodeLabel = function(node) {
+		if (!node.cachegroup) return 'TOPOLOGY';
+		return node.cachegroup;

Review comment:
       I think this could be `return node.cachegroup || 'TOPOLOGY';` without needing to use a weird one-line `if`

##########
File path: traffic_portal/app/src/common/modules/table/topologyCacheGroups/TableSelectTopologyCacheGroupsController.js
##########
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ */
+
+var TableSelectTopologyCacheGroupsController = function(parent, topology, cacheGroups, usedCacheGroupNames, $scope, $uibModal, $uibModalInstance, serverService) {
+
+	let selectedCacheGroups = [],
+		usedCacheGroupCount = 0;
+
+	let addAll = function() {
+		markVisibleCacheGroups(true);
+	};
+
+	let removeAll = function() {
+		markVisibleCacheGroups(false);
+	};
+
+	let markVisibleCacheGroups = function(selected) {
+		let visibleCacheGroupIds = $('#availableCacheGroupsTable tr.cg-row').map(
+			function() {
+				return parseInt($(this).attr('id'));
+			}).get();
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			if (visibleCacheGroupIds.includes(cg.id)) {
+				cg['selected'] = selected;
+			}
+			return cg;
+		});
+		updateSelectedCount();
+	};
+
+	let decorateCacheGroups = function() {
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			let isUsed = usedCacheGroupNames.find(function(usedCacheGroupName) { return usedCacheGroupName === cg.name });
+			if (isUsed) {
+				cg['selected'] = true;
+				cg['used'] = true;
+				usedCacheGroupCount++;
+			}
+			return cg;
+		});
+	};
+
+	let updateSelectedCount = function() {
+		selectedCacheGroups = $scope.cacheGroups.filter(function(cg) { return cg['selected'] == true && !cg['used'] } );
+		$('div.selected-count').html('<strong><span class="text-success">' + selectedCacheGroups.length + ' selected</span><span> | ' + usedCacheGroupCount + ' already used in topology</span></strong>');
+	};
+
+	$scope.parent = parent;
+
+	$scope.cacheGroups = cacheGroups.filter(function(cg) {
+		// all cg types (ORG_LOC, MID_LOC, EDGE_LOC) can be added to the root of a topology
+		// but only EDGE_LOC and MID_LOC can be added farther down the topology tree
+		if (parent.type === 'ROOT') return (cg.typeName === 'EDGE_LOC' || cg.typeName === 'MID_LOC' || cg.typeName === 'ORG_LOC');
+		return (cg.typeName === 'EDGE_LOC' || cg.typeName === 'MID_LOC');
+	});
+
+	$scope.selectAll = function($event) {
+		let checkbox = $event.target;
+		if (checkbox.checked) {
+			addAll();
+		} else {
+			removeAll();
+		}
+	};
+
+	$scope.onChange = function(cg) {
+		if (cg.used) return;
+
+		cg.selected = !cg.selected;
+		updateSelectedCount();
+	};
+
+	$scope.viewCacheGroupServers = function(cg, $event) {
+		if ($event) {
+			$event.stopPropagation(); // this kills the click event so it doesn't trigger anything else
+		}
+		$uibModal.open({
+			templateUrl: 'common/modules/table/topologyCacheGroupServers/table.topologyCacheGroupServers.tpl.html',
+			controller: 'TableTopologyCacheGroupServersController',
+			size: 'lg',
+			resolve: {
+				cacheGroupName: function() {
+					return cg.name;
+				},
+				cacheGroupServers: function(serverService) {
+					return serverService.getServers({ cachegroup: cg.id });
+				}
+			}
+		});
+	};
+
+	$scope.submit = function() {
+		// cache groups that are eligible to be a secondary parent include cache groups that are:
+		let eligibleSecParentCandidates = cacheGroups.filter(function(cg) {
+			return cg.typeName !== 'EDGE_LOC' && // not an edge_loc cache group
+				(parent.cachegroup && parent.cachegroup !== cg.name) && // not the primary parent cache group
+				usedCacheGroupNames.includes(cg.name); // a cache group that exists in the topology
+		});
+		if (eligibleSecParentCandidates.length === 0) {
+			$uibModalInstance.close({ selectedCacheGroups: selectedCacheGroups, parent: parent.cachegroup, secParent: '' });
+			return;
+		}
+		let params = {
+			title: 'Assign secondary parent?',
+			message: 'Would you like to assign a secondary parent to the following cache groups?<br><br>primary parent = ' + parent.cachegroup + '<br><br>'
+		};
+		params.message += selectedCacheGroups.map(function(cg) { return cg.name }).join('<br>') + '<br><br>';
+		let modalInstance = $uibModal.open({
+			templateUrl: 'common/modules/dialog/confirm/dialog.confirm.tpl.html',
+			controller: 'DialogConfirmController',
+			size: 'md',
+			resolve: {
+				params: function () {
+					return params;
+				}
+			}
+		});
+		modalInstance.result.then(function() {
+			// user wants to select a secondary parent
+			let params = {
+				title: 'Select a secondary parent',
+				message: 'Please select a secondary parent that is part of the ' + topology.name + ' topology',
+				key: 'name'
+			};
+			let modalInstance = $uibModal.open({
+				templateUrl: 'common/modules/dialog/select/dialog.select.tpl.html',
+				controller: 'DialogSelectController',
+				size: 'md',
+				resolve: {
+					params: function () {
+						return params;
+					},
+					collection: function() {
+						// cache groups that are eligible to be a secondary parent include cache groups that are:
+						return eligibleSecParentCandidates;
+					}
+				}
+			});
+			modalInstance.result.then(function(cg) {
+				// user selected a secondary parent
+				$uibModalInstance.close({ selectedCacheGroups: selectedCacheGroups, parent: parent.cachegroup, secParent: cg.name });
+			}, function () {
+				// user apparently changed their mind and doesn't want to select a secondary parent
+				$uibModalInstance.close({ selectedCacheGroups: selectedCacheGroups, parent: parent.cachegroup, secParent: '' });
+			});
+		}, function () {
+			// user doesn't want to select a secondary parent
+			$uibModalInstance.close({ selectedCacheGroups: selectedCacheGroups, parent: parent.cachegroup, secParent: '' });
+		});
+	};
+
+	$scope.cancel = function () {
+		$uibModalInstance.dismiss('cancel');
+	};
+
+	angular.element(document).ready(function () {
+		let availableCacheGroupsTable = $('#availableCacheGroupsTable').DataTable({
+			"scrollY": "60vh",
+			"paging": false,
+			"order": [[ 1, 'asc' ]],
+			"dom": '<"selected-count">frtip',
+			"columnDefs": [
+				{ 'orderable': false, 'targets': [0,5] },
+				{ "width": "5%", "targets": [ 0 ] },
+				{ "width": "35%", "targets": [ 1 ] },
+				{ "width": "15%", "targets": [ 2,3,4,5 ] }
+			],
+			"stateSave": false
+		});
+		availableCacheGroupsTable.on( 'search.dt', function () {
+			$("#selectAllCB").removeAttr("checked"); // uncheck the all box when filtering

Review comment:
       > `s/\$\(([^\)]+)\)/document\.querySelectorAll\(\1\)/g`
   
   This could really just be `document.getElementById('selectAllCB').removeAttribute("checked")`, but that's not really how you're supposed to check and un-check checkboxes, nor is it how you're supposed to manipulate attribute values in Angular. Instead, you should either bind it to a model value in the `$scope` or - slightly worse than that - bind the `checked` attribute to a scope boolean.

##########
File path: traffic_portal/app/src/common/modules/table/topologyCacheGroups/TableSelectTopologyCacheGroupsController.js
##########
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ */
+
+var TableSelectTopologyCacheGroupsController = function(parent, topology, cacheGroups, usedCacheGroupNames, $scope, $uibModal, $uibModalInstance, serverService) {
+
+	let selectedCacheGroups = [],
+		usedCacheGroupCount = 0;
+
+	let addAll = function() {
+		markVisibleCacheGroups(true);
+	};
+
+	let removeAll = function() {
+		markVisibleCacheGroups(false);
+	};
+
+	let markVisibleCacheGroups = function(selected) {
+		let visibleCacheGroupIds = $('#availableCacheGroupsTable tr.cg-row').map(
+			function() {
+				return parseInt($(this).attr('id'));
+			}).get();
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			if (visibleCacheGroupIds.includes(cg.id)) {
+				cg['selected'] = selected;
+			}
+			return cg;
+		});
+		updateSelectedCount();
+	};
+
+	let decorateCacheGroups = function() {
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			let isUsed = usedCacheGroupNames.find(function(usedCacheGroupName) { return usedCacheGroupName === cg.name });
+			if (isUsed) {
+				cg['selected'] = true;
+				cg['used'] = true;
+				usedCacheGroupCount++;
+			}
+			return cg;
+		});
+	};
+
+	let updateSelectedCount = function() {
+		selectedCacheGroups = $scope.cacheGroups.filter(function(cg) { return cg['selected'] == true && !cg['used'] } );
+		$('div.selected-count').html('<strong><span class="text-success">' + selectedCacheGroups.length + ' selected</span><span> | ' + usedCacheGroupCount + ' already used in topology</span></strong>');

Review comment:
       > `s/\$\(([^\)]+)\)/document\.querySelectorAll\(\1\)/g`

##########
File path: traffic_portal/app/src/common/modules/table/topologyCacheGroups/TableSelectTopologyCacheGroupsController.js
##########
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ */
+
+var TableSelectTopologyCacheGroupsController = function(parent, topology, cacheGroups, usedCacheGroupNames, $scope, $uibModal, $uibModalInstance, serverService) {
+
+	let selectedCacheGroups = [],
+		usedCacheGroupCount = 0;
+
+	let addAll = function() {
+		markVisibleCacheGroups(true);
+	};
+
+	let removeAll = function() {
+		markVisibleCacheGroups(false);
+	};
+
+	let markVisibleCacheGroups = function(selected) {
+		let visibleCacheGroupIds = $('#availableCacheGroupsTable tr.cg-row').map(
+			function() {
+				return parseInt($(this).attr('id'));
+			}).get();
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			if (visibleCacheGroupIds.includes(cg.id)) {
+				cg['selected'] = selected;
+			}
+			return cg;
+		});
+		updateSelectedCount();
+	};
+
+	let decorateCacheGroups = function() {
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			let isUsed = usedCacheGroupNames.find(function(usedCacheGroupName) { return usedCacheGroupName === cg.name });

Review comment:
       `isUsed` is never modified; could be `const`

##########
File path: traffic_portal/app/src/common/modules/form/topology/edit/FormEditTopologyController.js
##########
@@ -0,0 +1,73 @@
+/*
+ * 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.
+ */
+
+var FormEditTopologyController = function(topologies, cacheGroups, $scope, $controller, $uibModal, $anchorScroll, locationUtils, topologyService, messageModel, topologyUtils) {
+
+	// extends the FormTopologyController to inherit common methods
+	angular.extend(this, $controller('FormTopologyController', { topology: topologies[0], cacheGroups: cacheGroups, $scope: $scope }));
+
+	let deleteTopology = function(topology) {
+		topologyService.deleteTopology(topology)
+			.then(function() {
+				messageModel.setMessages([ { level: 'success', text: 'Topology deleted' } ], true);
+				locationUtils.navigateToPath('/topologies');
+			});
+	};
+
+	$scope.topologyName = angular.copy($scope.topology.name);
+
+	$scope.settings = {
+		isNew: false,
+		saveLabel: 'Update'
+	};
+
+	$scope.save = function(name, description, topologyTree) {
+		let normalizedTopology = topologyUtils.getNormalizedTopology(name, description, topologyTree);
+		topologyService.updateTopology(normalizedTopology).
+			then(function() {
+				messageModel.setMessages([ { level: 'success', text: 'Topology updated' } ], false);
+			});
+	};
+
+	$scope.confirmDelete = function(topology) {
+		let params = {
+			title: 'Delete Topology: ' + topology.name,
+			key: topology.name
+		};
+		let modalInstance = $uibModal.open({
+			templateUrl: 'common/modules/dialog/delete/dialog.delete.tpl.html',
+			controller: 'DialogDeleteController',
+			size: 'md',
+			resolve: {
+				params: function () {
+					return params;
+				}
+			}
+		});
+		modalInstance.result.then(function() {
+			deleteTopology(topology);
+		}, function () {
+			// do nothing
+		});

Review comment:
       doing nothing could swallow an exception, can we `console.error` or `console.warn` instead?




----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org