You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by ra...@apache.org on 2020/05/13 22:50:14 UTC

[trafficcontrol] branch master updated: Flexible Topologies in Traffic Ops and Traffic Portal (#4633)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 88f4f2a  Flexible Topologies in Traffic Ops and Traffic Portal (#4633)
88f4f2a is described below

commit 88f4f2aad0820ed81f4a57c0ed1bf2ff0b7436c1
Author: Zach Hoffman <za...@zrhoffman.net>
AuthorDate: Wed May 13 16:50:03 2020 -0600

    Flexible Topologies in Traffic Ops and Traffic Portal (#4633)
    
    * Topology Goose migrations
     - Create topology table
     - Create topology_cachegroup table
     - Create topology_cachegroup_parents table
     - Goose migration to add topology column to deliveryservice table
    
    * POST /topologies
    
    * Validation so far
    
    * Topologies endpoints for TO Python client
    
    * Topologies endpoint functions for Go client library
    
    * Create all nodes in a single query and all parents in a single query
    
    * adds toplogy views
    
    * adds ability to add cache groups to a top
    
    * adds fake origin layer
    
    * only allow org_loc to be at top of topology and moved some files around
    
    * adds the ability to see all servers assigned to a topology cg
    
    * GET /topologies
    
    * adds the ability to view servers for a cachgroup from the cg assignment view
    
    * adds the ability to select a 2nd parent cg
    
    * adds the ability to assign a second parent to nodes
    
    * some code cleanup
    
    * allows the selection of an item as well as determining whether a selection is required to submit the form
    
    * allows the selection of an item as well as determining whether a selection is required to submit the form
    
    * adds error message to node and cleanup
    
    * hooks up topology save in a fake way until api is ready
    
    * moves some more functionality into topologyutils
    
    * turning off create temporarily
    
    * makes a copy of the topology nodes to break references
    
    * added note to json section
    
    * removes uneccesary id and type from normalized topology
    
    * fixed but with root type
    
    * disables save button if topology has errors
    
    * fixes the select all button
    
    * fixes column widths in cg selection pane
    
    * adds the ability to create a topology
    
    * messing with some styles
    
    * changes top.desc to top.description and hooks up GET and POST topologies
    
    * adds MIT license info for angular-ui-tree component
    
    * moves tree css into own file
    
    * moves css back to correct spot
    
    * adds license info for angular-ui-tree css
    
    * moving fake stubs and halting non-implemented functionality
    
    * Fix merge conflicts
    
    * Bugfix: Order parents inside array_agg to support 2 parents
    
    * fixes bug where parent was not being set properly when only one valid parent (zrhoffman/trafficcontrol#2)
    
    * Abstract out adding nodes and adding parents into their own methods
    
    * performed some cleanup and added some comments (#3)
    
    * Bugfix: Return a propert HTTP status code from Read()
    
    * PUT /topologies?name={{name}}
    
    * Select query: Specify that we wants parents of a cachegroup in a
    specific topology, not just parents of a cachegroup
    
    * more cleanup (#4)
    
    * Bugfix: Rebind topology variable for a new topology
    
    * PUT /topologies?name={{name}} route
    
    * Bugfix: Do not mutate params when getting a cachegroup by name
    
    * hooks in topologies PUT (#5)
    
    * DELETE /topologies?name={{name}}
    
    * hooks topology delete up in TP (#6)
    
    * Make tc.Topology.Nodes a pointer
    
    * Check for cycles in the topology
    
    * Check for leaf mids
    
    * Validation improvements
      - Fix test for no nodes
      - Do not perform validation related to cachegroup types if all
        cachegroups were not found
    
    * Topologies API tests
    
    * Fix topologies API tests
      - Make the cyclical nodes topology have a cycle
      - Negate condition that asserts that checks if POSTing the invalid
        topology succeeded
    
    * Do not let a topology node be a parent of itself
    
    * Topologies docs
    
    * Add license headers
    
    * TO Changelog entry
    
    * adds expand on hover (zrhoffman/trafficcontrol#7)
    
    * PUT and DELETE should be auth.PrivLevelOperations
    
    * removes some uses of underscore.js (zrhoffman/trafficcontrol#8)
    
    * hides topologies menu item until feature is complete (zrhoffman/trafficcontrol#9)
    
    * Newer timestamps for Topologies goose migration
    
    * changes per PR review (zrhoffman/trafficcontrol#10)
    
    * minifies angular-ui-tree css
    
    * code simplification and cleanup per PR review
    
    * adds button types
    
    * safe way of invoking query params
    
    * removes debugging pane (zrhoffman/trafficcontrol#11)
    
    * Underline/overline just right
    
    * Use spaces for alignment
    
    * gofmt
    
    * fixes license issues (zrhoffman/trafficcontrol#12)
    
    * Return err, not nil
    
    * Return the result of api.ParseDBError() instead of binding it to
    userErr, sysErr, and errCode and then returning
    
    * Add function name and period
    
    * Simplify child/parent/rank uniqueness constraint
    
    * id column: serial -> bigserial
    
    * Add an extra newline before each sub-list
    
    * Add a period
    
    * Add a content type to the POST request
    
    * Add a content-type header to the PUT request
    
    * Add godocs
    
    * Capitalize Topology
    And link to the glossary
    
    * Remove tabs from migration
    
    * Revert "Simplify child/parent/rank uniqueness constraint"
    
    This reverts commit fbd99fae0ff38fed859f010418282681096c46b4.
    
    * Move Topologies from API v2.0 to v3.0
    
    * Updated docs API version
    
    * adds button type (zrhoffman/trafficcontrol#13)
    
    * upgrades TP from api v2 to v3 (zrhoffman/trafficcontrol#14)
    
    * reverts TP to v2 and introduces v3 for topologies (zrhoffman/trafficcontrol#15)
    
    * fixes bug where if you select all and then filter, the all selection would not filter. (zrhoffman/trafficcontrol#16)
    
    * Do not use pointers for topology nodes
    
    * Fix warnings
    
    * Group import with other TO imports
    
    * UpdateTopologyByID -> UpdateTopology
    
    * No need to load Parameters objects for Topologies tests
    
    * Swap comments
    
    * Make a struct for invalid topologies instead of twi separate slices
    
    * Make failed assertions fatal
    
    * - Test UpdateTopologies
      - Fix UpdateTopologies function definition
    
    * Fix mixed up variable names from resolving "Receiver names are
    different" warning in 36e085494f
    
    * Retrieve all cache groups for topology validation in a single query
    
    * Rename cachegroup type constants
      - EdgeCacheGroupType -> CacheGroupEdgeTypeName
      - MidCacheGroupType -> CacheGroupMidTypeName
    
    * Move parentCachegroup2 fixture to v3 API tests
    
    * Correct term: Topology -> Cache Group
    
    * gofmt
    
    * Defer closing DB connections
    
    * No need to delete nodes and parents when deleting an entire topology
    
    * Remove unnecessary blank argument
    
    * Use log.Close() to close responses
    
    * adds changelog entry and shows topologies menu item in TP (zrhoffman/trafficcontrol#17)
    
    Co-authored-by: Jeremy Mitchell <mi...@gmail.com>
    Co-authored-by: Jeremy Mitchell <mi...@users.noreply.github.com>
---
 .dependency_license                                |   2 +
 CHANGELOG.md                                       |   3 +
 LICENSE                                            |   5 +
 docs/source/api/v3/topologies.rst                  | 593 +++++++++++++++++++++
 docs/source/glossary.rst                           |   4 +
 lib/go-tc/cachegroups.go                           |   1 +
 lib/go-tc/enum.go                                  |   6 +-
 lib/go-tc/topologies.go                            |  49 ++
 licenses/MIT-angular_ui_tree                       |  20 +
 .../clients/python/trafficops/tosession.py         |  49 ++
 .../20200422101648_create_topology_tables.sql      |  68 +++
 traffic_ops/client/topology.go                     | 123 +++++
 traffic_ops/testing/api/v3/tc-fixtures.json        |  49 ++
 traffic_ops/testing/api/v3/topologies_test.go      | 141 +++++
 traffic_ops/testing/api/v3/traffic_control_test.go |   1 +
 traffic_ops/testing/api/v3/withobjs_test.go        |   2 +
 .../traffic_ops_golang/api/shared_handlers_test.go |   2 +-
 .../traffic_ops_golang/cachegroup/cachegroups.go   |  47 ++
 traffic_ops/traffic_ops_golang/routing/routes.go   |   6 +
 .../traffic_ops_golang/topology/cycle_detection.go | 124 +++++
 .../traffic_ops_golang/topology/topologies.go      | 473 ++++++++++++++++
 .../traffic_ops_golang/topology/validation.go      | 115 ++++
 .../trafficstats/deliveryservice.go                |   8 +-
 traffic_portal/app/src/app.js                      |  11 +
 .../src/assets/css/angular-ui-tree.min_2.22.6.css  |   2 +
 .../src/assets/js/angular-ui-tree.min_2.22.6.js    |   2 +
 .../app/src/common/api/TopologyService.js          |  72 +++
 traffic_portal/app/src/common/api/index.js         |   1 +
 .../dialog/select/DialogSelectController.js        |   4 +
 .../modules/dialog/select/dialog.select.tpl.html   |   2 +-
 .../form/topology/FormTopologyController.js        | 257 +++++++++
 .../topology/edit/FormEditTopologyController.js    |  71 +++
 .../modules/form/topology/edit/index.js}           |  11 +-
 .../modules/form/topology/form.topology.tpl.html   |  93 ++++
 .../modules/form/topology/index.js}                |  11 +-
 .../form/topology/new/FormNewTopologyController.js |  44 ++
 .../modules/form/topology/new/index.js}            |  11 +-
 .../common/modules/navigation/navigation.tpl.html  |   1 +
 .../table/topologies/TableTopologiesController.js} |  29 +-
 .../modules/table/topologies/index.js}             |  11 +-
 .../table/topologies/table.topologies.tpl.html     |  51 ++
 .../TableTopologyCacheGroupServersController.js}   |  44 +-
 .../table/topologyCacheGroupServers/index.js}      |  11 +-
 .../table.topologyCacheGroupServers.tpl.html}      |  31 +-
 .../TableSelectTopologyCacheGroupsController.js    | 198 +++++++
 .../modules/table/topologyCacheGroups/index.js}    |  11 +-
 .../table.selectTopologyCacheGroups.tpl.html       |  55 ++
 .../app/src/common/service/utils/TopologyUtils.js  | 111 ++++
 .../app/src/common/service/utils/index.js          |   3 +-
 traffic_portal/app/src/index.html                  |   2 +
 .../private/topologies/edit}/index.js              |  35 +-
 .../private/topologies/index.js}                   |  24 +-
 .../private/topologies/list/index.js}              |  29 +-
 .../private/topologies/new/index.js}               |  34 +-
 .../modules/private/topologies/topologies.tpl.html |  22 +
 traffic_portal/app/src/scripts/config.js           |   2 +-
 traffic_portal/app/src/styles/main.scss            |  33 +-
 57 files changed, 3080 insertions(+), 140 deletions(-)

diff --git a/.dependency_license b/.dependency_license
index 7ca957d..941f286 100644
--- a/.dependency_license
+++ b/.dependency_license
@@ -115,6 +115,8 @@ traffic_portal/app/src/assets/js/chartjs/angular-chart\..*, BSD
 traffic_portal/app/src/assets/css/jsonformatter\..*, Apache
 traffic_portal/app/src/assets/js/jsonformatter\..*, Apache
 traffic_portal/app/src/assets/js/fast-json-patch\..*, MIT
+traffic_portal/app/src/assets/css/angular-ui-tree\..*, MIT
+traffic_portal/app/src/assets/js/angular-ui-tree\..*, MIT
 traffic_portal/app/src/assets/css/colReorder.dataTables\..*, MIT
 traffic_portal/app/src/assets/js/colReorder.dataTables\..*, MIT
 traffic_portal/app/src/assets/js/dataTables.buttons\..*, MIT
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20588b7..5101f7a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 ### Added
 - Traffic Ops API v3
 - Added an optional readiness check service to cdn-in-a-box that exits successfully when it is able to get a `200 OK` from all delivery services
+- [Flexible Topologies (in progress)](https://github.com/apache/trafficcontrol/blob/master/blueprints/flexible-topologies.md)
+    - Traffic Ops: Added an API 3.0 endpoint, /api/3.0/topologies, to create, read, update and delete flexible topologies.
+    - Traffic Portal: Added the ability to create, read, update and delete flexible topologies.
 
 ### Fixed
 - Fixed the `GET /api/x/jobs` and `GET /api/x/jobs/:id` Traffic Ops API routes to allow falling back to Perl via the routing blacklist
diff --git a/LICENSE b/LICENSE
index 544a666..0d0f1f9 100644
--- a/LICENSE
+++ b/LICENSE
@@ -273,6 +273,11 @@ For the angular-moment-picker component:
 @traffic_portal/app/src/assets/js/moment-picker/angular-moment-picker.min_0.10.2.js
 ./licenses/MIT-angular_moment_picker
 
+For the angular-ui-tree component:
+@traffic_portal/app/src/assets/css/angular-ui-tree.min_2.22.6.css
+@traffic_portal/app/src/assets/js/angular-ui-tree.min_2.22.6.js
+./licenses/MIT-angular_ui_tree
+
 For the Chart.js component:
 @traffic_portal/app/src/assets/js/chartjs/Chart.min_2.7.2.js
 ./licenses/MIT-chartjs
diff --git a/docs/source/api/v3/topologies.rst b/docs/source/api/v3/topologies.rst
new file mode 100644
index 0000000..3648a4a
--- /dev/null
+++ b/docs/source/api/v3/topologies.rst
@@ -0,0 +1,593 @@
+..
+..
+.. 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/3.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 :term:`Topology`.
+:lastUpdated:           The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:                  The name of the :term:`Topology`. This can only be letters, numbers, and dashes.
+:nodes:                 An array of nodes in the :term:`Topology`
+
+	:cachegroup:            The name of a :term:`Cache Group`
+	: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 :term:`Topology`
+
+	:cachegroup:            The name of a :term:`Cache Group`
+	: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/3.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
+	Content-Type: application/json
+
+	{
+		"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 :term:`Topology`
+
+	:cachegroup:            The name of a :term:`Cache Group`
+	: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 :term:`Topology`.
+:name:                  The name of the :term:`Topology`. This can only be letters, numbers, and dashes.
+:nodes:                 An array of nodes in the :term:`Topology`
+
+	:cachegroup:            The name of a :term:`Cache Group`
+	: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/3.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
+	Content-Type: application/json
+
+	{
+		"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 :term:`Topology`.
+:lastUpdated:           The date and time at which this :term:`Topology` was last updated, in ISO-like format
+:name:                  The name of the :term:`Topology`. This can only be letters, numbers, and dashes.
+:nodes:                 An array of nodes in the :term:`Topology`
+
+	:cachegroup:            The name of a :term:`Cache Group`
+	: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:33:13 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: WVOtsoOVrEWcVjWM2TmT5DXy/a5Q0ygTZEQRhbkHHUmz9dgVLK1F5Joc9jtKA8gZu8/eM5+Tqqguh3mzrhAy/Q==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:33:13 GMT
+	Content-Length: 237
+
+	{
+		"alerts": [
+			{
+				"text": "topology was updated.",
+				"level": "success"
+			}
+		],
+		"response": {
+			"description": "The description is updated, too",
+			"name": "my-topology",
+			"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
+					]
+				}
+			],
+			"lastUpdated": "2020-04-13 17:33:13+00"
+		}
+	}
+
+``DELETE``
+==========
+Deletes a specific :term:`Topology`.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type:  ``undefined``
+
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+------+----------+---------------------------------------------------------+
+	| Name | Required | Description                                             |
+	+======+==========+=========================================================+
+	| name | yes      | The name of the :term:`Topology` to be deleted          |
+	+------+----------+---------------------------------------------------------+
+
+.. code-block:: http
+	:caption: Request Example
+
+	DELETE /api/3.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: 0
+
+Response Structure
+------------------
+
+.. 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:35:32 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: yErJobzG9IA0khvqZQK+Yi7X4pFVvOqxn6PjrdzN5DnKVm/K8Kka3REul1XmKJnMXVRY8RayoEVGDm16mBFe4Q==
+	X-Server-Name: traffic_ops_golang/
+	Date: Mon, 13 Apr 2020 17:35:32 GMT
+	Content-Length: 87
+
+	{
+		"alerts": [
+			{
+				"text": "topology was deleted.",
+				"level": "success"
+			}
+		]
+	}
diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst
index 455dfd5..3f90813 100644
--- a/docs/source/glossary.rst
+++ b/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`.
+
 	Type
 	Types
 		A :dfn:`Type` defines a type of some kind of object configured in Traffic Ops. Unfortunately, that is exactly as specific as this definition can be.
diff --git a/lib/go-tc/cachegroups.go b/lib/go-tc/cachegroups.go
index 7b9d306..f073d70 100644
--- a/lib/go-tc/cachegroups.go
+++ b/lib/go-tc/cachegroups.go
@@ -95,3 +95,4 @@ type CachegroupQueueUpdatesRequest struct {
 	CDN    *CDNName         `json:"cdn"`
 	CDNID  *util.JSONIntStr `json:"cdnId"`
 }
+
diff --git a/lib/go-tc/enum.go b/lib/go-tc/enum.go
index bcc5739..0cb8e44 100644
--- a/lib/go-tc/enum.go
+++ b/lib/go-tc/enum.go
@@ -76,7 +76,11 @@ const MidTypePrefix = "MID"
 
 const OriginTypeName = "ORG"
 
-const CacheGroupOriginTypeName = "ORG_LOC"
+const (
+	CacheGroupEdgeTypeName   = "EDGE_LOC"
+	CacheGroupMidTypeName    = "MID_LOC"
+	CacheGroupOriginTypeName = "ORG_LOC"
+)
 
 const GlobalProfileName = "GLOBAL"
 
diff --git a/lib/go-tc/topologies.go b/lib/go-tc/topologies.go
new file mode 100644
index 0000000..e7c6644
--- /dev/null
+++ b/lib/go-tc/topologies.go
@@ -0,0 +1,49 @@
+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.
+ */
+
+// Topology holds the name and set of TopologyNodes that comprise a flexible topology.
+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"`
+}
+
+// TopologyNode holds a reference to a cachegroup and the indices of up to 2 parent
+// nodes in the same topology's array of nodes.
+type TopologyNode struct {
+	Id          int        `json:"-" db:"id"`
+	Cachegroup  string     `json:"cachegroup" db:"cachegroup"`
+	Parents     []int      `json:"parents"`
+	LastUpdated *TimeNoMod `json:"-" db:"last_updated"`
+}
+
+// TopologiesResponse models the JSON object returned for a single topology in a response.
+type TopologyResponse struct {
+	Response Topology `json:"response"`
+	Alerts
+}
+
+// TopologiesResponse models the JSON object returned for a list of topologies in a response.
+type TopologiesResponse struct {
+	Response []Topology `json:"response"`
+	Alerts
+}
diff --git a/licenses/MIT-angular_ui_tree b/licenses/MIT-angular_ui_tree
new file mode 100644
index 0000000..f80a016
--- /dev/null
+++ b/licenses/MIT-angular_ui_tree
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2014
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/traffic_control/clients/python/trafficops/tosession.py b/traffic_control/clients/python/trafficops/tosession.py
index f6355a9..3a4e12f 100644
--- a/traffic_control/clients/python/trafficops/tosession.py
+++ b/traffic_control/clients/python/trafficops/tosession.py
@@ -21,6 +21,8 @@ Requires Python Version >= 3.6
 # Core Modules
 import logging
 import sys
+from requests import Response
+from typing import Any, Dict, List, Tuple, Union
 
 # Third-party Modules
 import munch
@@ -1867,6 +1869,53 @@ class TOSession(RestApiSession):
 		"""
 
 	#
+	# Topologies
+	#
+	@api_request('post', 'topologies', ('3.0',))
+	def create_topology(self, data: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
+		"""
+		:ref:`to-api-topologies`
+		:param data: The Topology data to use for Topology creation.
+		:type data: Dict[str, Any]
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
+	@api_request('get', 'topologies', ('3.0',))
+	def create_topology(self, data: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
+		"""
+		:ref:`to-api-topologies`
+		Get Topologies.
+		:ref:`to-api-topologies`
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
+	@api_request('put', 'topologies', ('3.0',))
+	def update_topologies(self, data: Dict[str, Any]=None, query_params: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
+		"""
+		Update a Topology
+		:ref:`to-api-topologies`
+		:param data: The new values for the Topology
+		:type data: Dict[str, Any]
+		:type query: Dict[str, Any]
+		:param query_params: 'id' is a required parameter, defining the Topology to be replaced.
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
+	@api_request('delete', 'topologies', ('3.0',))
+	def delete_topology(self, query_params: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]:
+		"""
+		Delete a Topology
+		:ref:`to-api-topologies`
+		:param topology_id: The ID of the Topology to delete
+		:type topology_id: int
+		:rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]
+		:raises: Union[LoginError, OperationError]
+		"""
+
+	#
 	# Types
 	#
 	@api_request('get', 'types', ('2.0',))
diff --git a/traffic_ops/app/db/migrations/20200422101648_create_topology_tables.sql b/traffic_ops/app/db/migrations/20200422101648_create_topology_tables.sql
new file mode 100644
index 0000000..f3c1f60
--- /dev/null
+++ b/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 BIGSERIAL 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)
+);
+CREATE INDEX topology_cachegroup_parents_child_fkey ON topology_cachegroup_parents USING btree (child);
+CREATE INDEX topology_cachegroup_parents_parents_fkey ON topology_cachegroup_parents USING btree (parent);
+DROP TRIGGER IF EXISTS on_update_current_timestamp ON topology_cachegroup_parents;
+CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON topology_cachegroup_parents FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated();
+
+ALTER TABLE deliveryservice
+    ADD COLUMN topology text,
+    ADD CONSTRAINT deliveryservice_topology_fkey FOREIGN KEY (topology) REFERENCES topology (name) ON UPDATE CASCADE ON DELETE RESTRICT;
+CREATE INDEX deliveryservice_topology_fkey ON deliveryservice USING btree (topology);
+
+-- +goose Down
+-- SQL section 'Down' is executed when this migration is rolled back
+ALTER TABLE deliveryservice DROP COLUMN topology;
+DROP TABLE topology_cachegroup_parents;
+DROP TABLE topology_cachegroup;
+DROP TABLE topology;
diff --git a/traffic_ops/client/topology.go b/traffic_ops/client/topology.go
new file mode 100644
index 0000000..bc95dff
--- /dev/null
+++ b/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-log"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"net"
+	"net/http"
+	"net/url"
+)
+
+const (
+	ApiTopologies = 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, ApiTopologies, reqBody)
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer log.Close(resp.Body, "unable to close response")
+	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, ApiTopologies, nil)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer log.Close(resp.Body, "unable to close response")
+
+	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", ApiTopologies, 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 log.Close(resp.Body, "unable to close response")
+
+	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)
+}
+
+// UpdateTopology updates a Topology by name.
+func (to *Session) UpdateTopology(name string, t tc.Topology) (*tc.TopologyResponse, ReqInf, error) {
+	var remoteAddr net.Addr
+	reqBody, err := json.Marshal(t)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return nil, reqInf, err
+	}
+	route := fmt.Sprintf("%s?name=%s", ApiTopologies, name)
+	resp, remoteAddr, err := to.request(http.MethodPut, route, reqBody)
+	if err != nil {
+		return nil, reqInf, err
+	}
+	defer log.Close(resp.Body, "unable to close response")
+	var response = new(tc.TopologyResponse)
+	err = json.NewDecoder(resp.Body).Decode(response)
+	return response, reqInf, err
+}
+
+// DeleteTopology deletes the given topology by name.
+func (to *Session) DeleteTopology(name string) (tc.Alerts, ReqInf, error) {
+	reqUrl := fmt.Sprintf("%s?name=%s", ApiTopologies, url.QueryEscape(name))
+	resp, remoteAddr, err := to.request(http.MethodDelete, reqUrl, nil)
+	reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr}
+	if err != nil {
+		return tc.Alerts{}, reqInf, err
+	}
+	defer log.Close(resp.Body, "unable to close response")
+	var alerts tc.Alerts
+	if err = json.NewDecoder(resp.Body).Decode(&alerts); err != nil {
+		return tc.Alerts{}, reqInf, err
+	}
+	return alerts, reqInf, nil
+}
diff --git a/traffic_ops/testing/api/v3/tc-fixtures.json b/traffic_ops/testing/api/v3/tc-fixtures.json
index 3ed7661..9fc1cc2 100644
--- a/traffic_ops/testing/api/v3/tc-fixtures.json
+++ b/traffic_ops/testing/api/v3/tc-fixtures.json
@@ -34,6 +34,13 @@
         {
             "latitude": 0,
             "longitude": 0,
+            "name": "parentCachegroup2",
+            "shortName": "pg2",
+            "typeName": "MID_LOC"
+        },
+        {
+            "latitude": 0,
+            "longitude": 0,
             "name": "secondaryCachegroup",
             "shortName": "sg1",
             "typeName": "MID_LOC"
@@ -2222,6 +2229,48 @@
             "parentName": "root"
         }
     ],
+    "topologies": [
+        {
+            "name": "my-topology",
+            "description": "a topology",
+            "nodes": [
+                {
+                  "cachegroup": "parentCachegroup",
+                  "parents": []
+                },
+                {
+                    "cachegroup": "secondaryCachegroup",
+                    "parents": []
+                },
+                {
+                    "cachegroup": "cachegroup1",
+                    "parents": [0, 1]
+                }
+            ]
+        },
+        {
+            "name": "another-topology",
+            "description": "another topology",
+            "nodes": [
+                {
+                  "cachegroup": "parentCachegroup",
+                  "parents": []
+                },
+                {
+                    "cachegroup": "cachegroup1",
+                    "parents": [0]
+                },
+                {
+                    "cachegroup": "secondaryCachegroup",
+                    "parents": []
+                },
+                {
+                    "cachegroup": "cachegroup2",
+                    "parents": [2]
+                }
+            ]
+        }
+    ],
     "types": [
         {
             "description": "Host header regular expression",
diff --git a/traffic_ops/testing/api/v3/topologies_test.go b/traffic_ops/testing/api/v3/topologies_test.go
new file mode 100644
index 0000000..1577abb
--- /dev/null
+++ b/traffic_ops/testing/api/v3/topologies_test.go
@@ -0,0 +1,141 @@
+package v3
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import (
+	"fmt"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"reflect"
+	"testing"
+)
+
+type topologyTestCase struct {
+	reasonToFail string
+	tc.Topology
+}
+
+func TestTopologies(t *testing.T) {
+	WithObjs(t, []TCObj{Types, CacheGroups, Topologies}, func() {
+		UpdateTestTopologies(t)
+		ValidationTestTopologies(t)
+	})
+}
+
+func CreateTestTopologies(t *testing.T) {
+	var (
+		postResponse *tc.TopologyResponse
+		err          error
+	)
+	for _, topology := range testData.Topologies {
+		if postResponse, _, err = TOSession.CreateTopology(topology); err != nil {
+			t.Fatalf("could not CREATE topology: %v", err)
+		}
+		postResponse.Response.LastUpdated = nil
+		if !reflect.DeepEqual(topology, postResponse.Response) {
+			t.Fatalf("Topology in response should be the same as the one POSTed. expected: %v\nactual: %v", topology, postResponse.Response)
+		}
+		t.Log("Response: ", postResponse)
+	}
+}
+
+func ValidationTestTopologies(t *testing.T) {
+	invalidTopologyTestCases := []topologyTestCase{
+		{reasonToFail: "no nodes", Topology: tc.Topology{Name: "empty-top", Description: "Invalid because there are no nodes", Nodes: []tc.TopologyNode{}}},
+		{reasonToFail: "a node listing itself as a parent", Topology: tc.Topology{Name: "self-parent", Description: "Invalid because a node lists itself as a parent", Nodes: []tc.TopologyNode{
+			{Cachegroup: "cachegroup1", Parents: []int{1}},
+			{Cachegroup: "parentCachegroup", Parents: []int{1}},
+		}}},
+		{reasonToFail: "duplicate parents", Topology: tc.Topology{}},
+		{reasonToFail: "too many parents", Topology: tc.Topology{Name: "duplicate-parents", Description: "Invalid because a node lists the same parent twice", Nodes: []tc.TopologyNode{
+			{Cachegroup: "cachegroup1", Parents: []int{1, 1}},
+			{Cachegroup: "parentCachegroup", Parents: []int{}},
+		}}},
+		{reasonToFail: "too many parents", Topology: tc.Topology{Name: "too-many-parents", Description: "Invalid because a node has more than 2 parents", Nodes: []tc.TopologyNode{
+			{Cachegroup: "parentCachegroup", Parents: []int{}},
+			{Cachegroup: "secondaryCachegroup", Parents: []int{}},
+			{Cachegroup: "parentCachegroup2", Parents: []int{}},
+			{Cachegroup: "cachegroup1", Parents: []int{0, 1, 2}},
+		}}},
+		{reasonToFail: "a parent edge", Topology: tc.Topology{Name: "parent-edge", Description: "Invalid because an edge is a parent", Nodes: []tc.TopologyNode{
+			{Cachegroup: "cachegroup1", Parents: []int{1}},
+			{Cachegroup: "cachegroup2", Parents: []int{}},
+		}}},
+		{reasonToFail: "a leaf mid", Topology: tc.Topology{Name: "leaf-mid", Description: "Invalid because a mid is a leaf node", Nodes: []tc.TopologyNode{
+			{Cachegroup: "parentCachegroup", Parents: []int{1}},
+			{Cachegroup: "secondaryCachegroup", Parents: []int{}},
+		}}},
+		{reasonToFail: "cyclical nodes", Topology: tc.Topology{Name: "cyclical-nodes", Description: "Invalid because it contains cycles", Nodes: []tc.TopologyNode{
+			{Cachegroup: "cachegroup1", Parents: []int{1, 2}},
+			{Cachegroup: "parentCachegroup", Parents: []int{2}},
+			{Cachegroup: "secondaryCachegroup", Parents: []int{1}},
+		}}},
+	}
+	for _, testCase := range invalidTopologyTestCases {
+		if _, _, err := TOSession.CreateTopology(testCase.Topology); err == nil {
+			t.Fatalf("expected POST with %v to return an error, actual: nil", testCase.reasonToFail)
+		}
+	}
+}
+
+func updateSingleTopology(topology tc.Topology) error {
+	updateResponse, _, err := TOSession.UpdateTopology(topology.Name, topology)
+	if err != nil {
+		return fmt.Errorf("cannot PUT topology: %v - %v", err, updateResponse)
+	}
+	updateResponse.Response.LastUpdated = nil
+	if !reflect.DeepEqual(topology, updateResponse.Response) {
+		return fmt.Errorf("Topologies should be equal after updating. expected: %v\nactual: %v", topology, updateResponse.Response)
+	}
+	return nil
+}
+
+func UpdateTestTopologies(t *testing.T) {
+	topologiesCount := len(testData.Topologies)
+	for index := range testData.Topologies {
+		topology := testData.Topologies[(index+1)%topologiesCount]
+		topology.Name = testData.Topologies[index].Name // We cannot update a topology's name
+		if err := updateSingleTopology(topology); err != nil {
+			t.Fatalf(err.Error())
+		}
+	}
+	// Revert test topologies
+	for _, topology := range testData.Topologies {
+		if err := updateSingleTopology(topology); err != nil {
+			t.Fatalf(err.Error())
+		}
+	}
+}
+
+func DeleteTestTopologies(t *testing.T) {
+	for _, top := range testData.Topologies {
+		delResp, _, err := TOSession.DeleteTopology(top.Name)
+		if err != nil {
+			t.Fatalf("cannot DELETE topology: %v - %v", err, delResp)
+		}
+
+		topology, _, err := TOSession.GetTopology(top.Name)
+		if err == nil {
+			t.Fatalf("expected error trying to GET deleted topology: %s, actual: nil", top.Name)
+		}
+		if topology != nil {
+			t.Fatalf("expected nil trying to GET deleted topology: %s, actual: non-nil", top.Name)
+		}
+	}
+}
diff --git a/traffic_ops/testing/api/v3/traffic_control_test.go b/traffic_ops/testing/api/v3/traffic_control_test.go
index 8fe0a1c..b6423fc 100644
--- a/traffic_ops/testing/api/v3/traffic_control_test.go
+++ b/traffic_ops/testing/api/v3/traffic_control_test.go
@@ -50,6 +50,7 @@ type TrafficControl struct {
 	StatsSummaries                       []tc.StatsSummary                       `json:"statsSummaries"`
 	Tenants                              []tc.Tenant                             `json:"tenants"`
 	ServerCheckExtensions                []tc.ServerCheckExtensionNullable       `json:"servercheck_extensions"`
+	Topologies                           []tc.Topology                           `json:"topologies"`
 	Types                                []tc.Type                               `json:"types"`
 	SteeringTargets                      []tc.SteeringTargetNullable             `json:"steeringTargets"`
 	Serverchecks                         []tc.ServercheckRequestNullable         `json:"serverchecks"`
diff --git a/traffic_ops/testing/api/v3/withobjs_test.go b/traffic_ops/testing/api/v3/withobjs_test.go
index c0d6a0e..0f7996e 100644
--- a/traffic_ops/testing/api/v3/withobjs_test.go
+++ b/traffic_ops/testing/api/v3/withobjs_test.go
@@ -66,6 +66,7 @@ const (
 	SteeringTargets
 	Tenants
 	ServerCheckExtensions
+	Topologies
 	Types
 	Users
 )
@@ -106,6 +107,7 @@ var withFuncs = map[TCObj]TCObjFuncs{
 	SteeringTargets:                      {SetupSteeringTargets, DeleteTestSteeringTargets},
 	Tenants:                              {CreateTestTenants, DeleteTestTenants},
 	ServerCheckExtensions:                {CreateTestServerCheckExtensions, DeleteTestServerCheckExtensions},
+	Topologies:                           {CreateTestTopologies, DeleteTestTopologies},
 	Types:                                {CreateTestTypes, DeleteTestTypes},
 	Users:                                {CreateTestUsers, ForceDeleteTestUsers},
 }
diff --git a/traffic_ops/traffic_ops_golang/api/shared_handlers_test.go b/traffic_ops/traffic_ops_golang/api/shared_handlers_test.go
index 6cef2fc..010b0a5 100644
--- a/traffic_ops/traffic_ops_golang/api/shared_handlers_test.go
+++ b/traffic_ops/traffic_ops_golang/api/shared_handlers_test.go
@@ -40,7 +40,7 @@ type tester struct {
 	APIInfoImpl `json:"-"`
 	userErr     error //only for testing
 	sysErr      error //only for testing
-	errCode     int   //only for testing
+	errCode     int //only for testing
 }
 
 var cfg = config.Config{ConfigTrafficOpsGolang: config.ConfigTrafficOpsGolang{DBQueryTimeoutSeconds: 20}}
diff --git a/traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go b/traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go
index 21f2cba..102560e 100644
--- a/traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go
+++ b/traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go
@@ -379,6 +379,46 @@ func (cg *TOCacheGroup) deleteCoordinate(coordinateID int) error {
 	return nil
 }
 
+func GetCacheGroupsByName(names []string, Tx *sqlx.Tx) (map[string]tc.CacheGroupNullable, error, error, int) {
+	query := SelectQuery() + multipleCacheGroupsWhere()
+	namesPqArray := pq.Array(names)
+	rows, err := Tx.Query(query, namesPqArray)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		return nil, userErr, sysErr, errCode
+	}
+	defer log.Close(rows, "unable to close DB connection")
+	cacheGroupMap := map[string]tc.CacheGroupNullable{}
+	for rows.Next() {
+		var s tc.CacheGroupNullable
+		lms := make([]tc.LocalizationMethod, 0)
+		cgfs := make([]string, 0)
+		if err = rows.Scan(
+			&s.ID,
+			&s.Name,
+			&s.ShortName,
+			&s.Latitude,
+			&s.Longitude,
+			pq.Array(&lms),
+			&s.ParentCachegroupID,
+			&s.ParentName,
+			&s.SecondaryParentCachegroupID,
+			&s.SecondaryParentName,
+			&s.Type,
+			&s.TypeID,
+			&s.LastUpdated,
+			pq.Array(&cgfs),
+			&s.FallbackToClosest,
+		); err != nil {
+			return nil, nil, errors.New("cachegroup read: scanning: " + err.Error()), http.StatusInternalServerError
+		}
+		s.LocalizationMethods = &lms
+		s.Fallbacks = &cgfs
+		cacheGroupMap[*s.Name] = s
+	}
+	return cacheGroupMap, nil, nil, http.StatusOK
+}
+
 func (cg *TOCacheGroup) Read() ([]interface{}, error, error, int) {
 	// Query Parameters to Database Query column mappings
 	// see the fields mapped in the SQL query
@@ -621,6 +661,13 @@ LEFT JOIN cachegroup AS cgp ON cachegroup.parent_cachegroup_id = cgp.id
 LEFT JOIN cachegroup AS cgs ON cachegroup.secondary_parent_cachegroup_id = cgs.id`
 }
 
+func multipleCacheGroupsWhere() string {
+	return `
+WHERE
+cachegroup.name = ANY ($1)
+`
+}
+
 func UpdateQuery() string {
 	// to disambiguate struct scans, the named
 	// parameter 'type_id' is an alias to cachegroup.type
diff --git a/traffic_ops/traffic_ops_golang/routing/routes.go b/traffic_ops/traffic_ops_golang/routing/routes.go
index aa27cc6..5bfd8ae 100644
--- a/traffic_ops/traffic_ops_golang/routing/routes.go
+++ b/traffic_ops/traffic_ops_golang/routing/routes.go
@@ -88,6 +88,7 @@ import (
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/steering"
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/steeringtargets"
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/systeminfo"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/topology"
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/trafficstats"
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/types"
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/urisigning"
@@ -282,6 +283,11 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) {
 		{api.Version{3, 0}, http.MethodPost, `regions/?$`, api.CreateHandler(&region.TORegion{}), auth.PrivLevelOperations, Authenticated, nil, 22883344883, noPerlBypass},
 		{api.Version{3, 0}, http.MethodDelete, `regions/?$`, api.DeleteHandler(&region.TORegion{}), auth.PrivLevelOperations, Authenticated, nil, 22326267583, noPerlBypass},
 
+		{api.Version{3, 0}, http.MethodPost, `topologies/?$`, api.CreateHandler(&topology.TOTopology{}), auth.PrivLevelOperations, Authenticated, nil, 3871452221, noPerlBypass},
+		{api.Version{3, 0}, http.MethodGet, `topologies/?$`, api.ReadHandler(&topology.TOTopology{}), auth.PrivLevelReadOnly, Authenticated, nil, 3871452222, noPerlBypass},
+		{api.Version{3, 0}, http.MethodPut, `topologies/?$`, api.UpdateHandler(&topology.TOTopology{}), auth.PrivLevelOperations, Authenticated, nil, 3871452223, noPerlBypass},
+		{api.Version{3, 0}, http.MethodDelete, `topologies/?$`, api.DeleteHandler(&topology.TOTopology{}), auth.PrivLevelOperations, Authenticated, nil, 3871452224, noPerlBypass},
+
 		// get all edge servers associated with a delivery service (from deliveryservice_server table)
 
 		{api.Version{3, 0}, http.MethodGet, `deliveryserviceserver/?$`, dsserver.ReadDSSHandlerV14, auth.PrivLevelReadOnly, Authenticated, nil, 29461450333, noPerlBypass},
diff --git a/traffic_ops/traffic_ops_golang/topology/cycle_detection.go b/traffic_ops/traffic_ops_golang/topology/cycle_detection.go
new file mode 100644
index 0000000..2344bf0
--- /dev/null
+++ b/traffic_ops/traffic_ops_golang/topology/cycle_detection.go
@@ -0,0 +1,124 @@
+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 (
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"math"
+)
+
+type TarjanNode struct {
+	tc.TopologyNode
+	Index   *int
+	LowLink *int
+	OnStack *bool
+}
+
+type NodeStack []*TarjanNode
+type Graph []*TarjanNode
+type Component []tc.TopologyNode
+
+type Tarjan struct {
+	Graph      *Graph
+	Stack      *NodeStack
+	Components []Component
+	Index      int
+}
+
+func (stack *NodeStack) push(node *TarjanNode) {
+	*stack = append(append([]*TarjanNode{}, *stack...), node)
+}
+
+func (stack *NodeStack) pop() *TarjanNode {
+	length := len(*stack)
+	node := (*stack)[length-1]
+	*stack = (*stack)[:length-1]
+	return node
+}
+
+func tarjan(nodes []tc.TopologyNode) [][]tc.TopologyNode {
+	structs := Tarjan{
+		Graph:      &Graph{},
+		Stack:      &NodeStack{},
+		Components: []Component{},
+		Index:      0,
+	}
+	for _, node := range nodes {
+		tarjanNode := TarjanNode{TopologyNode: node, LowLink: new(int)}
+		*tarjanNode.LowLink = 500
+		*structs.Graph = append(*structs.Graph, &tarjanNode)
+	}
+	structs.Stack = &NodeStack{}
+	structs.Index = 0
+	for _, vertex := range *structs.Graph {
+		if vertex.Index == nil {
+			structs.strongConnect(vertex)
+		}
+	}
+	var components [][]tc.TopologyNode
+	for _, component := range structs.Components {
+		var componentArray []tc.TopologyNode
+		for _, node := range component {
+			componentArray = append(componentArray, node)
+		}
+		components = append(components, componentArray)
+	}
+	return components
+}
+
+func (structs *Tarjan) nextComponent() (Component, int) {
+	var component = Component{}
+	index := len(structs.Components)
+	structs.Components = append(structs.Components, component)
+	return component, index
+}
+
+func (structs *Tarjan) strongConnect(node *TarjanNode) {
+	stack := structs.Stack
+	node.Index = new(int)
+	*node.Index = structs.Index
+	node.LowLink = new(int)
+	*node.LowLink = structs.Index
+	structs.Index++
+	stack.push(node)
+	node.OnStack = new(bool)
+	*node.OnStack = true
+
+	for _, parentIndex := range node.Parents {
+		parent := (*structs.Graph)[parentIndex]
+		if parent.Index == nil {
+			structs.strongConnect(parent)
+			*(*parent).LowLink = int(math.Min(float64(*node.LowLink), float64(*parent.LowLink)))
+		} else if *parent.OnStack {
+			*node.LowLink = int(math.Min(float64(*node.LowLink), float64(*parent.Index)))
+		}
+	}
+
+	if *node.LowLink == *node.Index {
+		component, index := structs.nextComponent()
+		var otherNode *TarjanNode
+		for node != otherNode {
+			otherNode = stack.pop()
+			*otherNode.OnStack = false
+			component = append(component, otherNode.TopologyNode)
+		}
+		structs.Components[index] = component
+	}
+}
diff --git a/traffic_ops/traffic_ops_golang/topology/topologies.go b/traffic_ops/traffic_ops_golang/topology/topologies.go
new file mode 100644
index 0000000..595d5da
--- /dev/null
+++ b/traffic_ops/traffic_ops_golang/topology/topologies.go
@@ -0,0 +1,473 @@
+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-log"
+	"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"
+)
+
+// TOTopology is a type alias on which we can define functions.
+type TOTopology struct {
+	api.APIInfoImpl `json:"-"`
+	tc.Topology
+}
+
+// DeleteQueryBase holds a delete query with no WHERE clause and is a
+// requirement of the api.GenericOptionsDeleter interface.
+func (topology *TOTopology) DeleteQueryBase() string {
+	return deleteQueryBase()
+}
+
+// ParamColumns maps query parameters to their respective database columns.
+func (topology *TOTopology) ParamColumns() map[string]dbhelpers.WhereColumnInfo {
+	return map[string]dbhelpers.WhereColumnInfo{
+		"name":        {Column: "t.name"},
+		"description": {Column: "t.description"},
+		"lastUpdated": {Column: "t.last_updated"},
+	}
+}
+
+// GenericOptionsDeleter is required by the api.GenericOptionsDeleter interface
+// and is called by api.GenericOptionsDelete().
+func (topology *TOTopology) DeleteKeyOptions() map[string]dbhelpers.WhereColumnInfo {
+	return topology.ParamColumns()
+}
+
+func (topology *TOTopology) SetLastUpdated(time tc.TimeNoMod) { topology.LastUpdated = &time }
+
+// GetKeyFieldsInfo is a requirement of the api.Updater interface.
+func (topology TOTopology) GetKeyFieldsInfo() []api.KeyFieldInfo {
+	return []api.KeyFieldInfo{{"name", api.GetStringKey}}
+}
+
+// GetType returns the human-readable type of TOTopology as a string.
+func (topology *TOTopology) GetType() string {
+	return "topology"
+}
+
+// Validate is a requirement of the api.Validator interface.
+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)
+	}
+	var (
+		cacheGroups      = make([]tc.CacheGroupNullable, nodeCount)
+		cacheGroupsExist = true
+		err              error
+		userErr          error
+		sysErr           error
+		cacheGroupMap    map[string]tc.CacheGroupNullable
+		exists           bool
+	)
+	_ = err
+	cacheGroupNames := make([]string, len(topology.Nodes))
+	for index, node := range topology.Nodes {
+		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)
+		cacheGroupNames[index] = node.Cachegroup
+	}
+	if cacheGroupMap, userErr, sysErr, _ = cachegroup.GetCacheGroupsByName(cacheGroupNames, topology.APIInfoImpl.ReqInfo.Tx); userErr != nil || sysErr != nil {
+		var err error
+		message := "could not get cachegroups"
+		if userErr != nil {
+			err = fmt.Errorf("%s: %s", message, userErr.Error())
+		}
+		return err
+	}
+	cacheGroups = make([]tc.CacheGroupNullable, len(topology.Nodes))
+	for index, node := range topology.Nodes {
+		if cacheGroups[index], exists = cacheGroupMap[node.Cachegroup]; !exists {
+			rules[fmt.Sprintf("cachegroup %s not found", node.Cachegroup)] = fmt.Errorf("node %d references nonexistent cachegroup %s", index, node.Cachegroup)
+			cacheGroupsExist = false
+		}
+	}
+	rules["duplicate cachegroup name"] = checkUniqueCacheGroupNames(topology.Nodes)
+
+	if cacheGroupsExist {
+		for index, node := range topology.Nodes {
+			rules[fmt.Sprintf("parent '%v' edge type", node.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.CacheGroupMidTypeName)
+		}
+	}
+	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
+}
+
+// SetKeys is a requirement of the api.Updater interface and is called by
+// api.UpdateHandler().
+func (topology *TOTopology) SetKeys(keys map[string]interface{}) {
+	topology.Name, _ = keys["name"].(string)
+}
+
+// GetAuditName is a requirement of the api.Identifier interface.
+func (topology *TOTopology) GetAuditName() string {
+	return topology.Name
+}
+
+// Create is a requirement of the api.Creator interface.
+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 {
+		return api.ParseDBError(err)
+	}
+
+	if userErr, sysErr, errCode := topology.addNodes(); userErr != nil || sysErr != nil {
+		return userErr, sysErr, errCode
+	}
+
+	if userErr, sysErr, errCode := topology.addParents(); userErr != nil || sysErr != nil {
+		return userErr, sysErr, errCode
+	}
+
+	return nil, nil, 0
+}
+
+// Read is a requirement of the api.Reader interface and is called by api.ReadHandler().
+func (topology *TOTopology) Read() ([]interface{}, error, error, int) {
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(topology.ReqInfo.Params, topology.ParamColumns())
+	if len(errs) > 0 {
+		return nil, util.JoinErrs(errs), nil, http.StatusBadRequest
+	}
+	query := selectQuery() + where + orderBy + pagination
+	rows, err := topology.ReqInfo.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		return nil, nil, errors.New("topology read: querying: " + err.Error()), http.StatusInternalServerError
+	}
+	defer log.Close(rows, "unable to close DB connection")
+
+	var interfaces []interface{}
+	topologies := map[string]*tc.Topology{}
+	indices := map[int]int{}
+	for index := 0; rows.Next(); index++ {
+		var (
+			name, description string
+			lastUpdated       tc.TimeNoMod
+		)
+		topologyNode := tc.TopologyNode{}
+		topologyNode.Parents = []int{}
+		var parents pq.Int64Array
+		if err = rows.Scan(
+			&name,
+			&description,
+			&lastUpdated,
+			&topologyNode.Id,
+			&topologyNode.Cachegroup,
+			&parents,
+		); err != nil {
+			return nil, nil, errors.New("topology read: scanning: " + err.Error()), http.StatusInternalServerError
+		}
+		for _, id := range parents {
+			topologyNode.Parents = append(topologyNode.Parents, int(id))
+		}
+		indices[topologyNode.Id] = index
+		if _, exists := topologies[name]; !exists {
+			topology := tc.Topology{Nodes: []tc.TopologyNode{}}
+			topologies[name] = &topology
+			topology.Name = name
+			topology.Description = description
+			topology.LastUpdated = &lastUpdated
+		}
+		topologies[name].Nodes = append(topologies[name].Nodes, topologyNode)
+	}
+
+	for _, topology := range topologies {
+		nodeMap := map[int]int{}
+		for index, node := range topology.Nodes {
+			nodeMap[node.Id] = index
+		}
+		for _, node := range topology.Nodes {
+			for parentIndex := 0; parentIndex < len(node.Parents); parentIndex++ {
+				node.Parents[parentIndex] = nodeMap[node.Parents[parentIndex]]
+			}
+		}
+		interfaces = append(interfaces, *topology)
+	}
+	return interfaces, nil, nil, http.StatusOK
+}
+
+func (topology *TOTopology) removeParents() error {
+	_, err := topology.ReqInfo.Tx.Exec(deleteParentsQuery(), topology.Name)
+	if err != nil {
+		return errors.New("topology update: error deleting old parents: " + err.Error())
+	}
+	return nil
+}
+
+func (topology *TOTopology) removeNodes(cachegroups *[]string) error {
+	_, err := topology.ReqInfo.Tx.Exec(deleteNodesQuery(), topology.Name, pq.Array(*cachegroups))
+	if err != nil {
+		return errors.New("topology update: error removing old unused nodes: " + err.Error())
+	}
+	return nil
+}
+
+func (topology *TOTopology) addNodes() (error, error, int) {
+	var cachegroupsToInsert []string
+	var indices = make([]int, 0)
+	for index, node := range topology.Nodes {
+		if node.Id == 0 {
+			cachegroupsToInsert = append(cachegroupsToInsert, node.Cachegroup)
+			indices = append(indices, index)
+		}
+	}
+	if len(cachegroupsToInsert) == 0 {
+		return nil, nil, http.StatusOK
+	}
+	rows, err := topology.ReqInfo.Tx.Query(nodeInsertQuery(), topology.Name, pq.Array(cachegroupsToInsert))
+	if err != nil {
+		return nil, errors.New("error adding nodes: " + err.Error()), http.StatusInternalServerError
+	}
+	defer log.Close(rows, "unable to close DB connection")
+	for _, index := range indices {
+		rows.Next()
+		err = rows.Scan(&topology.Nodes[index].Id, &topology.Name, &topology.Nodes[index].Cachegroup)
+		if err != nil {
+			return api.ParseDBError(err)
+		}
+	}
+	return nil, nil, http.StatusOK
+}
+
+func (topology *TOTopology) addParents() (error, error, int) {
+	var (
+		children []int
+		parents  []int
+		ranks    []int
+	)
+	for _, node := range topology.Nodes {
+		for rank := 1; rank <= len(node.Parents); rank++ {
+			parent := topology.Nodes[node.Parents[rank-1]]
+			children = append(children, node.Id)
+			parents = append(parents, parent.Id)
+			ranks = append(ranks, rank)
+		}
+	}
+	rows, err := topology.ReqInfo.Tx.Query(nodeParentInsertQuery(), pq.Array(children), pq.Array(parents), pq.Array(ranks))
+	if err != nil {
+		return api.ParseDBError(err)
+	}
+	defer log.Close(rows, "unable to close DB connection")
+	for _, node := range topology.Nodes {
+		for rank := 1; rank <= len(node.Parents); rank++ {
+			rows.Next()
+			parent := topology.Nodes[node.Parents[rank-1]]
+			err = rows.Scan(&node.Id, &parent.Id, &rank)
+			if err != nil {
+				return api.ParseDBError(err)
+			}
+		}
+	}
+	return nil, nil, http.StatusOK
+}
+
+func (topology *TOTopology) setDescription() (error, error, int) {
+	rows, err := topology.ReqInfo.Tx.Query(updateQuery(), topology.Description, topology.Name)
+	if err != nil {
+		return nil, fmt.Errorf("topology update: error setting the description for topology %v: %v", topology.Name, err.Error()), http.StatusInternalServerError
+	}
+	defer log.Close(rows, "unable to close DB connection")
+	for rows.Next() {
+		err = rows.Scan(&topology.Name, &topology.Description, &topology.LastUpdated)
+		if err != nil {
+			return api.ParseDBError(err)
+		}
+	}
+	return nil, nil, http.StatusOK
+}
+
+// Update is a requirement of the api.Updater interface.
+func (topology *TOTopology) Update() (error, error, int) {
+	topologies, userErr, sysErr, errCode := topology.Read()
+	if userErr != nil || sysErr != nil {
+		return userErr, sysErr, errCode
+	}
+	if len(topologies) != 1 {
+		return fmt.Errorf("cannot find exactly 1 topology with the query string provided"), nil, http.StatusBadRequest
+	}
+	oldTopology := TOTopology{APIInfoImpl: topology.APIInfoImpl, Topology: topologies[0].(tc.Topology)}
+	if userErr, sysErr, errCode := topology.setDescription(); userErr != nil || sysErr != nil {
+		return userErr, sysErr, errCode
+	}
+
+	if err := oldTopology.removeParents(); err != nil {
+		return nil, err, http.StatusInternalServerError
+	}
+	var oldNodes, newNodes = map[string]int{}, map[string]int{}
+	for index, node := range oldTopology.Nodes {
+		oldNodes[node.Cachegroup] = index
+	}
+	for index, node := range topology.Nodes {
+		newNodes[node.Cachegroup] = index
+	}
+	var toRemove []string
+	for cachegroupName := range oldNodes {
+		if _, exists := newNodes[cachegroupName]; !exists {
+			toRemove = append(toRemove, cachegroupName)
+		} else {
+			topology.Nodes[newNodes[cachegroupName]].Id = oldTopology.Nodes[oldNodes[cachegroupName]].Id
+		}
+	}
+	if len(toRemove) > 0 {
+		if err := oldTopology.removeNodes(&toRemove); err != nil {
+			return nil, err, http.StatusInternalServerError
+		}
+	}
+	if userErr, sysErr, errCode := topology.addNodes(); userErr != nil || sysErr != nil {
+		return userErr, sysErr, errCode
+	}
+	if userErr, sysErr, errCode := topology.addParents(); userErr != nil || sysErr != nil {
+		return userErr, sysErr, errCode
+	}
+
+	return nil, nil, http.StatusOK
+}
+
+// Delete is unused and simply satisfies the Deleter interface
+// (although TOTOpology is used as an OptionsDeleter)
+func (topology *TOTopology) Delete() (error, error, int) {
+	return nil, nil, 0
+}
+
+// OptionsDelete is a requirement of the OptionsDeleter interface.
+func (topology *TOTopology) OptionsDelete() (error, error, int) {
+	topologies, userErr, sysErr, errCode := topology.Read()
+	if userErr != nil || sysErr != nil {
+		return userErr, sysErr, errCode
+	}
+	if len(topologies) != 1 {
+		return fmt.Errorf("cannot find exactly 1 topology with the query string provided"), nil, http.StatusBadRequest
+	}
+	topology.Topology = topologies[0].(tc.Topology)
+
+	var cachegroups []string
+	for _, node := range topology.Nodes {
+		cachegroups = append(cachegroups, node.Cachegroup)
+	}
+	return api.GenericOptionsDelete(topology)
+}
+
+func insertQuery() string {
+	query := `
+INSERT INTO topology (name, description)
+VALUES ($1, $2)
+RETURNING name, description, last_updated
+`
+	return query
+}
+
+func nodeInsertQuery() string {
+	query := `
+INSERT INTO topology_cachegroup (topology, cachegroup)
+VALUES ($1, unnest($2::text[]))
+RETURNING id, topology, cachegroup
+`
+	return query
+}
+
+func nodeParentInsertQuery() string {
+	query := `
+INSERT INTO topology_cachegroup_parents (child, parent, rank)
+VALUES (unnest($1::int[]), unnest($2::int[]), unnest($3::int[]))
+RETURNING child, parent, rank
+`
+	return query
+}
+
+func selectQuery() string {
+	query := `
+SELECT t.name, t.description, t.last_updated,
+tc.id, tc.cachegroup,
+	(SELECT COALESCE (ARRAY_AGG (CAST (tcp.parent as INT) ORDER BY tcp.rank ASC)) AS parents
+	FROM topology_cachegroup tc2
+	INNER JOIN topology_cachegroup_parents tcp ON tc2.id = tcp.child
+	WHERE tc2.topology = tc.topology
+	AND tc2.cachegroup = tc.cachegroup
+	)
+FROM topology t
+JOIN topology_cachegroup tc on t.name = tc.topology
+`
+	return query
+}
+
+func deleteQueryBase() string {
+	query := `
+DELETE FROM topology t
+`
+	return query
+}
+
+func deleteNodesQuery() string {
+	query := `
+DELETE FROM topology_cachegroup tc
+WHERE tc.topology = $1
+AND tc.cachegroup = ANY ($2::text[])
+`
+	return query
+}
+
+func deleteParentsQuery() string {
+	query := `
+DELETE FROM topology_cachegroup_parents tcp
+WHERE tcp.child IN
+    (SELECT tc.id
+    FROM topology t
+    JOIN topology_cachegroup tc on t.name = tc.topology
+    WHERE t.name = $1)
+`
+	return query
+}
+
+func updateQuery() string {
+	query := `
+UPDATE topology t SET
+description = $1
+WHERE t.name = $2
+RETURNING t.name, t.description, t.last_updated
+`
+	return query
+}
diff --git a/traffic_ops/traffic_ops_golang/topology/validation.go b/traffic_ops/traffic_ops_golang/topology/validation.go
new file mode 100644
index 0000000..c4270ac
--- /dev/null
+++ b/traffic_ops/traffic_ops_golang/topology/validation.go
@@ -0,0 +1,115 @@
+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 (
+	"fmt"
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+)
+
+func checkUniqueCacheGroupNames(nodes []tc.TopologyNode) error {
+	cacheGroupNames := map[string]bool{}
+	for _, node := range nodes {
+		if _, exists := cacheGroupNames[node.Cachegroup]; exists {
+			return fmt.Errorf("cachegroup %v cannot be used more than once in the topology", node.Cachegroup)
+		}
+		cacheGroupNames[node.Cachegroup] = true
+	}
+	return nil
+}
+
+func checkForDuplicateParents(nodes []tc.TopologyNode, index int) error {
+	parents := nodes[index].Parents
+	if len(parents) != 2 || parents[0] != parents[1] {
+		return nil
+	}
+	return fmt.Errorf("cachegroup %v cannot be both a primary and secondary parent of cachegroup %v", nodes[parents[0]].Cachegroup, nodes[index].Cachegroup)
+}
+
+func checkForSelfParents(nodes []tc.TopologyNode, index int) error {
+	for _, parentIndex := range nodes[index].Parents {
+		if index == parentIndex {
+			return fmt.Errorf("cachegroup %v cannot be a parent of itself", index)
+		}
+	}
+	return nil
+}
+
+func checkForEdgeParents(nodes []tc.TopologyNode, cachegroups []tc.CacheGroupNullable, nodeIndex int) error {
+	node := nodes[nodeIndex]
+	errs := make([]error, len(node.Parents))
+	for parentIndex := range node.Parents {
+		cacheGroupType := cachegroups[node.Parents[parentIndex]].Type
+		if *cacheGroupType == tc.CacheGroupEdgeTypeName {
+			errs[parentIndex] = fmt.Errorf("cachegroup %v's type is %v; it cannot be a parent of %v", nodes[parentIndex].Cachegroup, tc.CacheGroupEdgeTypeName, node.Cachegroup)
+		}
+	}
+	return util.JoinErrs(errs)
+}
+
+func checkForLeafMids(nodes []tc.TopologyNode, cacheGroups []tc.CacheGroupNullable) []tc.TopologyNode {
+	isLeafMid := make([]bool, len(nodes))
+	for index := range isLeafMid {
+		isLeafMid[index] = true
+	}
+	for index, node := range nodes {
+		if *cacheGroups[index].Type == tc.CacheGroupEdgeTypeName {
+			isLeafMid[index] = false
+		}
+		for _, parentIndex := range node.Parents {
+			if !isLeafMid[parentIndex] {
+				continue
+			}
+			isLeafMid[parentIndex] = false
+		}
+	}
+
+	var leafMids []tc.TopologyNode
+	for index, node := range nodes {
+		if isLeafMid[index] {
+			leafMids = append(leafMids, node)
+		}
+	}
+	return leafMids
+}
+
+func checkForCycles(nodes []tc.TopologyNode) error {
+	components := tarjan(nodes)
+	var errs []error
+	for _, component := range components {
+		if len(component) > 1 {
+			errString := "cycle detected between cachegroups "
+			var node tc.TopologyNode
+			for _, node = range component {
+				errString += node.Cachegroup + ", "
+			}
+			length := len(errString)
+			cachegroupNameLength := len(node.Cachegroup)
+			errString = errString[0:length-2-cachegroupNameLength-2] + " and " + errString[length-2-cachegroupNameLength:length-2]
+			errs = append(errs, fmt.Errorf(errString))
+		}
+	}
+	if len(errs) == 0 {
+		return nil
+	}
+	errs = append([]error{fmt.Errorf("topology cannot have cycles")}, errs...)
+	return util.JoinErrs(errs)
+}
diff --git a/traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go b/traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go
index 066b505..3a05e5d 100644
--- a/traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go
+++ b/traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go
@@ -210,8 +210,8 @@ func handleRequest(w http.ResponseWriter, r *http.Request, client *influx.Client
 		if summary != nil {
 			resp.Summary = &tc.TrafficDSStatsSummary{
 				TrafficStatsSummary: *summary,
-				TotalKiloBytes: kBs,
-				TotalTransactions: txns,
+				TotalKiloBytes:      kBs,
+				TotalTransactions:   txns,
 			}
 		} else {
 			resp.Summary = &tc.TrafficDSStatsSummary{}
@@ -278,8 +278,8 @@ func handleLegacyRequest(w http.ResponseWriter, r *http.Request, client *influx.
 		if summary != nil {
 			resp.Summary = &tc.LegacyTrafficDSStatsSummary{
 				TrafficStatsSummary: *summary,
-				TotalBytes: kBs,
-				TotalTransactions: txns,
+				TotalBytes:          kBs,
+				TotalTransactions:   txns,
 			}
 		} else {
 			resp.Summary = &tc.LegacyTrafficDSStatsSummary{}
diff --git a/traffic_portal/app/src/app.js b/traffic_portal/app/src/app.js
index 3e1d0fd..1b2e477 100644
--- a/traffic_portal/app/src/app.js
+++ b/traffic_portal/app/src/app.js
@@ -33,6 +33,7 @@ var trafficPortal = angular.module('trafficPortal', [
         'ngSanitize',
         'ngRoute',
         'ui.router',
+        'ui.tree',
         'ui.bootstrap',
         'ui.bootstrap.contextMenu',
         'app.templates',
@@ -201,6 +202,10 @@ var trafficPortal = angular.module('trafficPortal', [
         require('./modules/private/tenants/new').name,
         require('./modules/private/tenants/users').name,
         require('./modules/private/types').name,
+        require('./modules/private/topologies').name,
+        require('./modules/private/topologies/edit').name,
+        require('./modules/private/topologies/list').name,
+        require('./modules/private/topologies/new').name,
         require('./modules/private/types/edit').name,
         require('./modules/private/types/list').name,
         require('./modules/private/types/new').name,
@@ -317,6 +322,9 @@ var trafficPortal = angular.module('trafficPortal', [
         require('./common/modules/form/tenant').name,
         require('./common/modules/form/tenant/edit').name,
         require('./common/modules/form/tenant/new').name,
+        require('./common/modules/form/topology').name,
+        require('./common/modules/form/topology/edit').name,
+        require('./common/modules/form/topology/new').name,
         require('./common/modules/form/type').name,
         require('./common/modules/form/type/edit').name,
         require('./common/modules/form/type/new').name,
@@ -387,6 +395,9 @@ var trafficPortal = angular.module('trafficPortal', [
         require('./common/modules/table/tenants').name,
         require('./common/modules/table/tenantDeliveryServices').name,
         require('./common/modules/table/tenantUsers').name,
+        require('./common/modules/table/topologies').name,
+        require('./common/modules/table/topologyCacheGroups').name,
+        require('./common/modules/table/topologyCacheGroupServers').name,
         require('./common/modules/table/types').name,
         require('./common/modules/table/typeCacheGroups').name,
         require('./common/modules/table/typeDeliveryServices').name,
diff --git a/traffic_portal/app/src/assets/css/angular-ui-tree.min_2.22.6.css b/traffic_portal/app/src/assets/css/angular-ui-tree.min_2.22.6.css
new file mode 100644
index 0000000..dbf6650
--- /dev/null
+++ b/traffic_portal/app/src/assets/css/angular-ui-tree.min_2.22.6.css
@@ -0,0 +1,2 @@
+/* Angular UI Tree v2.22.6 - (c) 2010-2017. https://github.com/angular-ui-tree/angular-ui-tree - MIT */
+.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 30p [...]
\ No newline at end of file
diff --git a/traffic_portal/app/src/assets/js/angular-ui-tree.min_2.22.6.js b/traffic_portal/app/src/assets/js/angular-ui-tree.min_2.22.6.js
new file mode 100644
index 0000000..86a7741
--- /dev/null
+++ b/traffic_portal/app/src/assets/js/angular-ui-tree.min_2.22.6.js
@@ -0,0 +1,2 @@
+/* Angular UI Tree v2.22.6 - (c) 2010-2017. https://github.com/angular-ui-tree/angular-ui-tree - MIT */
+!function(){"use strict";angular.module("ui.tree",[]).constant("treeConfig",{treeClass:"angular-ui-tree",emptyTreeClass:"angular-ui-tree-empty",dropzoneClass:"angular-ui-tree-dropzone",hiddenClass:"angular-ui-tree-hidden",nodesClass:"angular-ui-tree-nodes",nodeClass:"angular-ui-tree-node",handleClass:"angular-ui-tree-handle",placeholderClass:"angular-ui-tree-placeholder",dragClass:"angular-ui-tree-drag",dragThreshold:3,defaultCollapsed:!1,appendChildOnHover:!0})}(),function(){"use strict [...]
\ No newline at end of file
diff --git a/traffic_portal/app/src/common/api/TopologyService.js b/traffic_portal/app/src/common/api/TopologyService.js
new file mode 100644
index 0000000..87a7f6d
--- /dev/null
+++ b/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['latest'] + 'topologies', { params: queryParams }).then(
+			function(result) {
+				return result.data.response;
+			},
+			function(err) {
+				throw err;
+			}
+		);
+	};
+
+	this.createTopology = function(topology) {
+		return $http.post(ENV.api['latest'] + '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['latest'] + 'topologies', topology, { params: { name: topology.name } }).then(
+			function(result) {
+				return result;
+			},
+			function(err) {
+				messageModel.setMessages(err.data.alerts, false);
+				throw err;
+			}
+		);
+	};
+
+	this.deleteTopology = function(topology) {
+		return $http.delete(ENV.api['latest'] + "topologies", { params: { name: topology.name } }).then(
+			function(result) {
+				return result;
+			},
+			function(err) {
+				messageModel.setMessages(err.data.alerts, true);
+				throw err;
+			}
+		);
+	};
+
+};
+
+TopologyService.$inject = ['$http', 'ENV', 'locationUtils', 'messageModel'];
+module.exports = TopologyService;
diff --git a/traffic_portal/app/src/common/api/index.js b/traffic_portal/app/src/common/api/index.js
index 7ee157a..079b6d9 100644
--- a/traffic_portal/app/src/common/api/index.js
+++ b/traffic_portal/app/src/common/api/index.js
@@ -53,6 +53,7 @@ module.exports = angular.module('trafficPortal.api', [])
     .service('statusService', require('./StatusService'))
     .service('tenantService', require('./TenantService'))
     .service('toolsService', require('./ToolsService'))
+    .service('topologyService', require('./TopologyService'))
     .service('typeService', require('./TypeService'))
     .service('trafficPortalService', require('./TrafficPortalService'))
     .service('userService', require('./UserService'))
diff --git a/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js b/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js
index d2fccc9..2d3fac7 100644
--- a/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js
+++ b/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 || null;
 	};
 	init();
 
diff --git a/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html b/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html
index 881a32d..0d3522e 100644
--- a/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html
+++ b/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html
@@ -24,7 +24,7 @@ under the License.
 <div class="modal-body">
     <p ng-bind-html="params.message"></p>
     <form name="selectForm" novalidate>
-        <select class="form-control" name="selectFormDropdown" ng-model="selectedItemKeyValue" ng-options="item[key] as label(item) for item in collection" required>
+        <select class="form-control" name="selectFormDropdown" ng-model="selectedItemKeyValue" ng-options="item[key] as label(item) for item in collection" ng-required="{{::required}}">
             <option value="">Select...</option>
         </select>
     </form>
diff --git a/traffic_portal/app/src/common/modules/form/topology/FormTopologyController.js b/traffic_portal/app/src/common/modules/form/topology/FormTopologyController.js
new file mode 100644
index 0000000..c912f40
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/form/topology/FormTopologyController.js
@@ -0,0 +1,257 @@
+/*
+ * 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) {
+		return node.cachegroup || 'TOPOLOGY';
+	};
+
+	$scope.editSecParent = function(node) {
+
+		if (!node.parent) return; // if a node has no parent, it can't have a second parent
+
+		buildCacheGroupNamesInTopology($scope.topologyTree, true);
+
+		/*  Cache groups that can act as a second parent include:
+			1. cache groups of type ORG_LOC or MID_LOC (not EDGE_LOC)
+			2. cache groups that are not currently acting as the primary parent
+			3. cache groups that exist currently in the topology
+		 */
+		let eligibleSecParentCandidates = cacheGroups.filter(function(cg) {
+			return cg.typeName !== 'EDGE_LOC' &&
+				(node.parent && node.parent !== cg.name) &&
+				cacheGroupNamesInTopology.includes(cg.name);
+		});
+
+		let params = {
+			title: 'Select a secondary parent',
+			message: 'Please select a secondary parent that is part of the ' + topology.name + ' topology',
+			key: 'name',
+			required: false,
+			selectedItemKeyValue: node.secParent
+		};
+		let modalInstance = $uibModal.open({
+			templateUrl: 'common/modules/dialog/select/dialog.select.tpl.html',
+			controller: 'DialogSelectController',
+			size: 'md',
+			resolve: {
+				params: function () {
+					return params;
+				},
+				collection: function() {
+					return eligibleSecParentCandidates;
+				}
+			}
+		});
+		modalInstance.result.then(function(selectedSecParent) {
+			if (selectedSecParent) {
+				node.secParent = selectedSecParent.name;
+			} else {
+				node.secParent = '';
+			}
+		});
+	};
+
+	$scope.deleteCacheGroup = function(node, scope) {
+		if (node.cachegroup) {
+			removeSecParentReferences($scope.topologyTree, node.cachegroup);
+			scope.remove();
+		}
+	};
+
+	$scope.toggle = function(scope) {
+		scope.toggle();
+	};
+
+	$scope.hasNodeError = function(node) {
+		if (node.type !== 'EDGE_LOC' && node.children.length === 0) {
+			return true;
+		}
+		return false;
+	};
+
+	$scope.isOrigin = function(node) {
+		return node.type === 'ROOT' || node.type === 'ORG_LOC';
+	};
+
+	$scope.isMid = function(node) {
+		return node.type === 'MID_LOC';
+	};
+
+	$scope.hasChildren = function(node) {
+		return node.children.length > 0;
+	};
+
+	$scope.addCacheGroups = function(parent, scope) {
+
+		if (parent.type === 'EDGE_LOC') {
+			// can't add children to EDGE_LOC. button should be hidden anyhow.
+			return;
+		}
+
+		// cache groups already in the topology cannot be selected again for addition
+		buildCacheGroupNamesInTopology($scope.topologyTree, true);
+
+		let modalInstance = $uibModal.open({
+			templateUrl: 'common/modules/table/topologyCacheGroups/table.selectTopologyCacheGroups.tpl.html',
+			controller: 'TableSelectTopologyCacheGroupsController',
+			size: 'lg',
+			resolve: {
+				parent: function() {
+					return parent;
+				},
+				topology: function() {
+					return topology;
+				},
+				cacheGroups: function(cacheGroupService) {
+					return cacheGroupService.getCacheGroups();
+				},
+				usedCacheGroupNames: function() {
+					return cacheGroupNamesInTopology;
+				}
+			}
+		});
+		modalInstance.result.then(function(result) {
+			let nodeData = scope.$modelValue,
+				cacheGroupNodes = result.selectedCacheGroups.map(function(cg) {
+					return {
+						id: cg.id,
+						cachegroup: cg.name,
+						type: cg.typeName,
+						parent: (result.parent) ? result.parent : '',
+						secParent: result.secParent,
+						children: []
+					}
+				});
+			cacheGroupNodes.forEach(function(node) {
+				nodeData.children.unshift(node);
+			});
+		});
+	};
+
+	$scope.viewCacheGroupServers = function(node) {
+		$uibModal.open({
+			templateUrl: 'common/modules/table/topologyCacheGroupServers/table.topologyCacheGroupServers.tpl.html',
+			controller: 'TableTopologyCacheGroupServersController',
+			size: 'lg',
+			resolve: {
+				cacheGroupName: function() {
+					return node.cachegroup;
+				},
+				cacheGroupServers: function(serverService) {
+					return serverService.getServers({ cachegroup: node.id });
+				}
+			}
+		});
+	};
+
+	let init = function() {
+		hydrateTopology();
+		$scope.topologyTree = topologyUtils.getTopologyTree($scope.topology);
+	};
+	init();
+};
+
+FormTopologyController.$inject = ['topology', 'cacheGroups', '$anchorScroll', '$scope', '$location', '$uibModal', 'formUtils', 'locationUtils', 'topologyUtils', 'messageModel'];
+module.exports = FormTopologyController;
diff --git a/traffic_portal/app/src/common/modules/form/topology/edit/FormEditTopologyController.js b/traffic_portal/app/src/common/modules/form/topology/edit/FormEditTopologyController.js
new file mode 100644
index 0000000..326ff69
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/form/topology/edit/FormEditTopologyController.js
@@ -0,0 +1,71 @@
+/*
+ * 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);
+		});
+	};
+
+};
+
+FormEditTopologyController.$inject = ['topologies', 'cacheGroups', '$scope', '$controller', '$uibModal', '$anchorScroll', 'locationUtils', 'topologyService', 'messageModel', 'topologyUtils'];
+module.exports = FormEditTopologyController;
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/common/modules/form/topology/edit/index.js
similarity index 83%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/common/modules/form/topology/edit/index.js
index 2c2ba1f..f3c7ad8 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/common/modules/form/topology/edit/index.js
@@ -17,12 +17,5 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.form.topology.edit', [])
+	.controller('FormEditTopologyController', require('./FormEditTopologyController'));
diff --git a/traffic_portal/app/src/common/modules/form/topology/form.topology.tpl.html b/traffic_portal/app/src/common/modules/form/topology/form.topology.tpl.html
new file mode 100644
index 0000000..966867e
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/form/topology/form.topology.tpl.html
@@ -0,0 +1,93 @@
+<!--
+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><a ng-click="navigateToPath('/topologies')">Topologies</a></li>
+            <li class="active">{{topologyName}}</li>
+        </ol>
+        <div class="clearfix"></div>
+    </div>
+    <div class="x_content">
+        <br>
+        <form name="topologyForm" class="form-horizontal form-label-left" novalidate>
+            <div class="form-group" ng-class="{'has-error': hasError(topologyForm.name), 'has-feedback': hasError(topologyForm.name)}">
+                <label class="control-label col-md-2 col-sm-2 col-xs-12">Name *</label>
+                <div class="col-md-10 col-sm-10 col-xs-12">
+                    <input name="name" type="text" class="form-control" ng-model="topology.name" ng-disabled="!settings.isNew" pattern="[A-Za-z0-9]([A-Za-z\-0-9]*[A-Za-z0-9])?" required autofocus>
+                    <small class="input-error" ng-show="hasPropertyError(topologyForm.name, 'required')">Required</small>
+                    <small class="input-error" ng-show="hasPropertyError(topologyForm.name, 'pattern')">No special characters, periods, underscores, or spaces and cannot begin or end with a hyphen</small>
+                    <span ng-show="hasError(topologyForm.name)" class="form-control-feedback"><i class="fa fa-times"></i></span>
+                </div>
+            </div>
+            <div class="form-group" ng-class="{'has-error': hasError(topologyForm.description), 'has-feedback': hasError(topologyForm.description)}">
+                <label class="control-label col-md-2 col-sm-2 col-xs-12">Description *</label>
+                <div class="col-md-10 col-sm-10 col-xs-12">
+                    <textarea id="description" name="description" rows="3" cols="17" class="form-control" ng-model="topology.description" maxlength="256" required></textarea>
+                    <small class="input-error" ng-show="hasPropertyError(topologyForm.description, 'required')">Required</small>
+                </div>
+            </div>
+            <div class="form-group">
+                <label class="control-label col-md-2 col-sm-2 col-xs-12">Cache Groups *</label>
+                <div class="col-md-10 col-sm-10 col-xs-12">
+                    <div id="tree-root" ui-tree="topologyTreeOptions" >
+                        <ol ui-tree-nodes ng-model="topologyTree">
+                            <li ng-repeat="node in topologyTree" ui-tree-node ng-include="'nodes_renderer.html'"></li>
+                        </ol>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-danger" ng-show="!settings.isNew" ng-click="confirmDelete(topology)">Delete</button>
+                <button type="button" class="btn btn-success" ng-disabled="topologyForm.$invalid" ng-click="save(topology.name, topology.description, topologyTree)">{{settings.saveLabel}}</button>
+            </div>
+        </form>
+    </div>
+</div>
+
+<script type="text/ng-template" id="nodes_renderer.html">
+    <input name="error" ng-if="hasNodeError(node)" ng-model="error" type="hidden" required>
+    <div id="{{node.cachegroup}}" ui-tree-handle class="tree-node tree-node-content"
+         ng-class="{ 'error': hasNodeError(node), 'origin': isOrigin(node), 'mid': isMid(node) }">
+        <div class="tree-node-label pull-left">
+            <a class="tree-toggle btn btn-primary btn-xs" ng-if="node.type !== 'ROOT' && hasChildren(node)" data-nodrag ng-click="toggle(this)">
+                <i class="fa" ng-class="collapsed ? 'fa-caret-right' : 'fa-caret-down'"></i>
+            </a> {{nodeLabel(node)}} <small>{{node.type}}</small>
+        </div>
+        <div ng-if="hasNodeError(node)" class="error-msg">1+ child required for {{node.type}}</div>
+        <div class="pull-right">
+            <a ng-show="node.cachegroup && node.parent" title="Set Secondary Parent Cache Group for {{node.cachegroup}}" class="btn btn-primary btn-xs" data-nodrag ng-click="editSecParent(node)" style="margin-right: 8px;">
+                2nd: {{(node.secParent) ? node.secParent : 'None'}}
+            </a>
+            <a ng-if="node.cachegroup" title="View Servers Assigned to {{node.cachegroup}}" class="btn btn-primary btn-xs" data-nodrag ng-click="viewCacheGroupServers(node)" style="margin-right: 8px;">
+                <i class="fa fa-server"></i>
+            </a>
+            <a ng-disabled="node.type === 'EDGE_LOC'" title="{{(node.type !== 'EDGE_LOC') ? 'Add cache groups to ' + node.cachegroup : 'Cache groups cannot be added to EDGE_LOC cache groups'}}" class="btn btn-primary btn-xs" data-nodrag ng-click="addCacheGroups(node, this)" style="margin-right: 8px;">
+                <i class="fa fa-plus"></i>
+            </a>
+            <a ng-if="node.cachegroup" title="Remove {{node.cachegroup}} Cache Group" class="btn btn-danger btn-xs" data-nodrag ng-click="deleteCacheGroup(node, this)">
+                <i class="fa fa-times"></i>
+            </a>
+        </div>
+    </div>
+    <ol ui-tree-nodes="" ng-model="node.children" ng-class="{hidden: collapsed}">
+        <li ng-repeat="node in node.children" ui-tree-node data-expand-on-hover="true" ng-include="'nodes_renderer.html'"></li>
+    </ol>
+</script>
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/common/modules/form/topology/index.js
similarity index 83%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/common/modules/form/topology/index.js
index 2c2ba1f..9ebfc4f 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/common/modules/form/topology/index.js
@@ -17,12 +17,5 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.form.topology', [])
+	.controller('FormTopologyController', require('./FormTopologyController'));
diff --git a/traffic_portal/app/src/common/modules/form/topology/new/FormNewTopologyController.js b/traffic_portal/app/src/common/modules/form/topology/new/FormNewTopologyController.js
new file mode 100644
index 0000000..1528b16
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/form/topology/new/FormNewTopologyController.js
@@ -0,0 +1,44 @@
+/*
+ * 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 FormNewTopologyController = function(topology, cacheGroups, $scope, $controller, locationUtils, topologyService, messageModel, topologyUtils) {
+
+	// extends the FormTopologyController to inherit common methods
+	angular.extend(this, $controller('FormTopologyController', { topology: topology, cacheGroups: cacheGroups, $scope: $scope }));
+
+	$scope.topologyName = 'New';
+
+	$scope.settings = {
+		isNew: true,
+		saveLabel: 'Create'
+	};
+
+	$scope.save = function(name, description, topologyTree) {
+		let normalizedTopology = topologyUtils.getNormalizedTopology(name, description, topologyTree);
+		topologyService.createTopology(normalizedTopology).
+			then(function() {
+				messageModel.setMessages([ { level: 'success', text: 'Topology created' } ], true);
+				locationUtils.navigateToPath('/topologies');
+			});
+	};
+
+};
+
+FormNewTopologyController.$inject = ['topology', 'cacheGroups', '$scope', '$controller', 'locationUtils', 'topologyService', 'messageModel', 'topologyUtils'];
+module.exports = FormNewTopologyController;
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/common/modules/form/topology/new/index.js
similarity index 83%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/common/modules/form/topology/new/index.js
index 2c2ba1f..4608439 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/common/modules/form/topology/new/index.js
@@ -17,12 +17,5 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.form.topology.new', [])
+	.controller('FormNewTopologyController', require('./FormNewTopologyController'));
diff --git a/traffic_portal/app/src/common/modules/navigation/navigation.tpl.html b/traffic_portal/app/src/common/modules/navigation/navigation.tpl.html
index 0dd58da..dc4d0a0 100644
--- a/traffic_portal/app/src/common/modules/navigation/navigation.tpl.html
+++ b/traffic_portal/app/src/common/modules/navigation/navigation.tpl.html
@@ -52,6 +52,7 @@ under the License.
                 </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>
                         <li class="side-menu-category-item" ng-if="hasCapability('cache-groups-read')" ng-class="{'current-page': isState('trafficPortal.private.cacheGroups')}"><a href="/#!/cache-groups">Cache Groups</a></li>
                         <li class="side-menu-category-item" ng-if="hasCapability('coordinates-read')" ng-class="{'current-page': isState('trafficPortal.private.coordinates')}"><a href="/#!/coordinates">Coordinates</a></li>
                         <li class="side-menu-category-item" ng-if="hasCapability('phys-locations-read')" ng-class="{'current-page': isState('trafficPortal.private.physLocations')}"><a href="/#!/phys-locations">Phys Locations</a></li>
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/common/modules/table/topologies/TableTopologiesController.js
similarity index 51%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/common/modules/table/topologies/TableTopologiesController.js
index 2c2ba1f..ce0b714 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/common/modules/table/topologies/TableTopologiesController.js
@@ -17,12 +17,31 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
+var TableTopologiesController = function(topologies, $scope, $state, locationUtils) {
 
-"use strict";
+	$scope.topologies = topologies;
 
-angular.module('config', [])
+	$scope.editTopology = function(name) {
+		locationUtils.navigateToPath('/topologies/edit?name=' + name);
+	};
 
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
+	$scope.createTopology = function() {
+		locationUtils.navigateToPath('/topologies/new');
+	};
 
-;
+	$scope.refresh = function() {
+		$state.reload(); // reloads all the resolves for the view
+	};
+
+	angular.element(document).ready(function () {
+		$('#topologiesTable').dataTable({
+			"aLengthMenu": [[25, 50, 100, -1], [25, 50, 100, "All"]],
+			"iDisplayLength": 25,
+			"aaSorting": []
+		});
+	});
+
+};
+
+TableTopologiesController.$inject = ['topologies', '$scope', '$state', 'locationUtils'];
+module.exports = TableTopologiesController;
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/common/modules/table/topologies/index.js
similarity index 83%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/common/modules/table/topologies/index.js
index 2c2ba1f..9084dd9 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/common/modules/table/topologies/index.js
@@ -17,12 +17,5 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.table.topologies', [])
+	.controller('TableTopologiesController', require('./TableTopologiesController'));
diff --git a/traffic_portal/app/src/common/modules/table/topologies/table.topologies.tpl.html b/traffic_portal/app/src/common/modules/table/topologies/table.topologies.tpl.html
new file mode 100644
index 0000000..1cf20b3
--- /dev/null
+++ b/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 type="button" class="btn btn-primary" title="Create Topology" ng-click="createTopology()"><i class="fa fa-plus"></i></button>
+            <button type="button" class="btn btn-default" title="Refresh" ng-click="refresh()"><i class="fa fa-refresh"></i></button>
+        </div>
+        <div class="clearfix"></div>
+    </div>
+    <div class="x_content">
+        <br>
+        <table id="topologiesTable" class="table responsive-utilities jambo_table">
+            <thead>
+            <tr class="headings">
+                <th>Name</th>
+                <th>Description</th>
+                <th>Cache Groups</th>
+            </tr>
+            </thead>
+            <tbody>
+            <tr ng-click="editTopology(t.name)" ng-repeat="t in ::topologies">
+                <td data-search="^{{::t.name}}$">{{::t.name}}</td>
+                <td data-search="^{{::t.description}}$">{{::t.description}}</td>
+                <td >{{::t.nodes.length}}</td>
+            </tr>
+            </tbody>
+        </table>
+    </div>
+</div>
+
diff --git a/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js b/traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/TableTopologyCacheGroupServersController.js
similarity index 51%
copy from traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js
copy to traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/TableTopologyCacheGroupServersController.js
index d2fccc9..93e1556 100644
--- a/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js
+++ b/traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/TableTopologyCacheGroupServersController.js
@@ -17,39 +17,39 @@
  * under the License.
  */
 
-var DialogSelectController = function(params, collection, $scope, $uibModalInstance) {
+var TableTopologyCacheGroupServersController = function(cacheGroupName, cacheGroupServers, $scope, $uibModalInstance) {
 
-	$scope.params = params;
+	let adjustTableColumns = function() {
+		window.setTimeout(function() {
+			$($.fn.dataTable.tables(true)).DataTable()
+				.columns.adjust();
+		},100);
+	};
 
-	$scope.collection = collection;
+	$scope.cacheGroupName = cacheGroupName;
 
-	$scope.selectedItemKeyValue = null;
-
-	$scope.select = function() {
-		const selectedItem = collection.find(function(item) {
-			return item[$scope.key] === $scope.selectedItemKeyValue;
-		});
-		$uibModalInstance.close(selectedItem);
-	};
+	$scope.cacheGroupServers = cacheGroupServers;
 
 	$scope.cancel = function () {
 		$uibModalInstance.dismiss('cancel');
 	};
 
-	var init = function() {
-		if ($scope.params.label) {
-			$scope.label = function(item) { return item[$scope.params.label]; }
-		} else if ($scope.params.labelFunction) {
-			$scope.label = $scope.params.labelFunction;
-		} else {
-			$scope.label = function(item) { return item['name']; }
-		}
+	angular.element(document).ready(function () {
+		$('#topologyCacheGroupServersTable').dataTable({
+			"aLengthMenu": [[25, 50, 100, -1], [25, 50, 100, "All"]],
+			"iDisplayLength": 25,
+			"aaSorting": [],
+			"buttons": []
+		});
+	});
 
-		$scope.key = $scope.params.key || 'id';
+	let init = function() {
+		// ensures the column headers are positioned correctly
+		adjustTableColumns();
 	};
 	init();
 
 };
 
-DialogSelectController.$inject = ['params', 'collection', '$scope', '$uibModalInstance'];
-module.exports = DialogSelectController;
+TableTopologyCacheGroupServersController.$inject = ['cacheGroupName', 'cacheGroupServers', '$scope', '$uibModalInstance'];
+module.exports = TableTopologyCacheGroupServersController;
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/index.js
similarity index 80%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/index.js
index 2c2ba1f..2c4956e 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/index.js
@@ -17,12 +17,5 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.table.topologyCacheGroupServers', [])
+	.controller('TableTopologyCacheGroupServersController', require('./TableTopologyCacheGroupServersController'));
diff --git a/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html b/traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/table.topologyCacheGroupServers.tpl.html
similarity index 53%
copy from traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html
copy to traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/table.topologyCacheGroupServers.tpl.html
index 881a32d..4100591 100644
--- a/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html
+++ b/traffic_portal/app/src/common/modules/table/topologyCacheGroupServers/table.topologyCacheGroupServers.tpl.html
@@ -17,19 +17,32 @@ 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>
-    <h4 class="modal-title">{{::params.title}}</h4>
+    <h3 class="modal-title">Servers assigned to {{::cacheGroupName}}</h3>
 </div>
 <div class="modal-body">
-    <p ng-bind-html="params.message"></p>
-    <form name="selectForm" novalidate>
-        <select class="form-control" name="selectFormDropdown" ng-model="selectedItemKeyValue" ng-options="item[key] as label(item) for item in collection" required>
-            <option value="">Select...</option>
-        </select>
-    </form>
+    <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-link" ng-click="cancel()">Cancel</button>
-    <button class="btn btn-primary" ng-disabled="selectForm.$invalid" ng-click="select()">Submit</button>
+    <button type="button" class="btn btn-primary" ng-click="cancel()">Close</button>
 </div>
diff --git a/traffic_portal/app/src/common/modules/table/topologyCacheGroups/TableSelectTopologyCacheGroupsController.js b/traffic_portal/app/src/common/modules/table/topologyCacheGroups/TableSelectTopologyCacheGroupsController.js
new file mode 100644
index 0000000..db7391f
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/table/topologyCacheGroups/TableSelectTopologyCacheGroupsController.js
@@ -0,0 +1,198 @@
+/*
+ * 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 markVisibleCacheGroups = function(selected) {
+		let visibleCacheGroupIds = $('#availableCacheGroupsTable tr.cg-row').map(
+			function() {
+				return parseInt($(this).attr('id'));
+			}).get();
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			if (cg['used'] === true) {
+				return cg;
+			}
+			if (selected && visibleCacheGroupIds.includes(cg.id)) {
+				cg['selected'] = true;
+			} else {
+				cg['selected'] = false;
+			}
+			return cg;
+		});
+		updateSelectedCount();
+	};
+
+	let decorateCacheGroups = function() {
+		$scope.cacheGroups = cacheGroups.map(function(cg) {
+			const isUsed = usedCacheGroupNames.find(function(usedCacheGroupName) { return usedCacheGroupName === cg.name });
+			if (isUsed) {
+				cg['selected'] = true;
+				cg['used'] = true;
+				usedCacheGroupCount++;
+			}
+			return cg;
+		});
+	};
+
+	let updateSelectedCount = function() {
+		let visibleCacheGroupIds = $('#availableCacheGroupsTable tr.cg-row').map(
+			function() {
+				return parseInt($(this).attr('id'));
+			}).get();
+
+		selectedCacheGroups = $scope.cacheGroups.filter(function(cg) { return visibleCacheGroupIds.includes(cg.id) && 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) {
+		const checkbox = $event.target;
+		if (checkbox.checked) {
+			markVisibleCacheGroups(true);
+		} else {
+			markVisibleCacheGroups(false);
+		}
+	};
+
+	$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 () {
+		decorateCacheGroups();
+
+		$('#availableCacheGroupsTable').DataTable({
+			"scrollY": "60vh",
+			"paging": false,
+			"order": [[ 1, 'asc' ]],
+			"dom": '<"selected-count">frtip',
+			"drawCallback": function() {
+				updateSelectedCount();
+			},
+			"columnDefs": [
+				{ 'orderable': false, 'targets': [0,5] },
+				{ "width": "5%", "targets": [ 0 ] },
+				{ "width": "35%", "targets": [ 1 ] },
+				{ "width": "15%", "targets": [ 2,3,4,5 ] }
+			],
+			"stateSave": false
+		});
+	});
+
+};
+
+TableSelectTopologyCacheGroupsController.$inject = ['parent', 'topology', 'cacheGroups', 'usedCacheGroupNames', '$scope', '$uibModal', '$uibModalInstance', 'serverService'];
+module.exports = TableSelectTopologyCacheGroupsController;
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/common/modules/table/topologyCacheGroups/index.js
similarity index 80%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/common/modules/table/topologyCacheGroups/index.js
index 2c2ba1f..023d755 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/common/modules/table/topologyCacheGroups/index.js
@@ -17,12 +17,5 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.table.topologyCacheGroups', [])
+	.controller('TableSelectTopologyCacheGroupsController', require('./TableSelectTopologyCacheGroupsController'));
diff --git a/traffic_portal/app/src/common/modules/table/topologyCacheGroups/table.selectTopologyCacheGroups.tpl.html b/traffic_portal/app/src/common/modules/table/topologyCacheGroups/table.selectTopologyCacheGroups.tpl.html
new file mode 100644
index 0000000..737a982
--- /dev/null
+++ b/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 type="button" class="btn btn-link" ng-click="cancel()">cancel</button>
+    <button type="button" class="btn btn-primary" ng-click="submit()">Submit</button>
+</div>
diff --git a/traffic_portal/app/src/common/service/utils/TopologyUtils.js b/traffic_portal/app/src/common/service/utils/TopologyUtils.js
new file mode 100644
index 0000000..ec45c8a
--- /dev/null
+++ b/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;
+		});
+
+		// create children based on parent definitions
+		Object.keys(all).forEach(function (guid) {
+			let item = all[guid];
+			if (!('children' in item)) {
+				item.children = []
+			}
+			if (item.parents.length === 0) {
+				item.parent = "";
+				item.secParent = "";
+				roots.push(item)
+			} else if (item.parents[0] in all) {
+				let p = all[item.parents[0]]
+				if (!('children' in p)) {
+					p.children = []
+				}
+				p.children.push(item);
+				// add parent to each node
+				item.parent = all[item.parents[0]].cachegroup;
+				// add secParent to each node
+				if (item.parents.length === 2 && item.parents[1] in all) {
+					item.secParent = all[item.parents[1]].cachegroup;
+				}
+			}
+		});
+
+		return [
+			{
+				type: 'ROOT',
+				children: roots
+			}
+		];
+	};
+
+};
+
+TopologyUtils.$inject = [];
+module.exports = TopologyUtils;
diff --git a/traffic_portal/app/src/common/service/utils/index.js b/traffic_portal/app/src/common/service/utils/index.js
index b499861..4221ef8 100644
--- a/traffic_portal/app/src/common/service/utils/index.js
+++ b/traffic_portal/app/src/common/service/utils/index.js
@@ -28,4 +28,5 @@ module.exports = angular.module('trafficPortal.utils', [])
     .service('permissionUtils', require('./PermissionUtils'))
     .service('serverUtils', require('./ServerUtils'))
     .service('stringUtils', require('./StringUtils'))
-    .service('tenantUtils', require('./TenantUtils'));
+    .service('tenantUtils', require('./TenantUtils'))
+    .service('topologyUtils', require('./TopologyUtils'));
diff --git a/traffic_portal/app/src/index.html b/traffic_portal/app/src/index.html
index 7ba73e1..0593391 100644
--- a/traffic_portal/app/src/index.html
+++ b/traffic_portal/app/src/index.html
@@ -40,6 +40,7 @@ under the License.
         <link rel="stylesheet" media="all" href="resources/assets/css/jquery.dataTables.min_1.10.9.css">
         <link rel="stylesheet" media="all" href="resources/assets/css/colReorder.dataTables.min_1.5.1.css">
         <link rel="stylesheet" media="all" href="resources/assets/css/angular-moment-picker_0.10.2.css">
+        <link rel="stylesheet" media="all" href="resources/assets/css/angular-ui-tree.min_2.22.6.css">
 
     </head>
 
@@ -51,6 +52,7 @@ under the License.
         <script src="resources/assets/js/app.js"></script>
         <script src="resources/assets/js/config.js"></script>
 
+        <script src="resources/assets/js/angular-ui-tree.min_2.22.6.js"></script>
         <script src="resources/assets/js/jsonformatter.min_0.6.0.js"></script>
         <script src="resources/assets/js/fast-json-patch_v2.1.0.js"></script>
         <script src="resources/assets/js/downloadjs-min_v4.21.js"></script>
diff --git a/traffic_portal/app/src/common/service/utils/index.js b/traffic_portal/app/src/modules/private/topologies/edit/index.js
similarity index 52%
copy from traffic_portal/app/src/common/service/utils/index.js
copy to traffic_portal/app/src/modules/private/topologies/edit/index.js
index b499861..eb736bc 100644
--- a/traffic_portal/app/src/common/service/utils/index.js
+++ b/traffic_portal/app/src/modules/private/topologies/edit/index.js
@@ -17,15 +17,26 @@
  * under the License.
  */
 
-module.exports = angular.module('trafficPortal.utils', [])
-    .service('collectionUtils', require('./CollectionUtils'))
-    .service('dateUtils', require('./DateUtils'))
-    .service('deliveryServiceUtils', require('./DeliveryServiceUtils'))
-    .service('fileUtils', require('./FileUtils'))
-    .service('formUtils', require('./FormUtils'))
-    .service('locationUtils', require('./LocationUtils'))
-    .service('numberUtils', require('./NumberUtils'))
-    .service('permissionUtils', require('./PermissionUtils'))
-    .service('serverUtils', require('./ServerUtils'))
-    .service('stringUtils', require('./StringUtils'))
-    .service('tenantUtils', require('./TenantUtils'));
+module.exports = angular.module('trafficPortal.private.topologies.edit', [])
+	.config(function($stateProvider, $urlRouterProvider) {
+		$stateProvider
+			.state('trafficPortal.private.topologies.edit', {
+				url: '/edit?name',
+				views: {
+					topologiesContent: {
+						templateUrl: 'common/modules/form/topology/form.topology.tpl.html',
+						controller: 'FormEditTopologyController',
+						resolve: {
+							topologies: function($stateParams, topologyService) {
+								return topologyService.getTopologies({ name: $stateParams.name });
+							},
+							cacheGroups: function(cacheGroupService) {
+								return cacheGroupService.getCacheGroups();
+							}
+						}
+					}
+				}
+			})
+		;
+		$urlRouterProvider.otherwise('/');
+	});
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/modules/private/topologies/index.js
similarity index 66%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/modules/private/topologies/index.js
index 2c2ba1f..caa3cde 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/modules/private/topologies/index.js
@@ -17,12 +17,18 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.private.topologies', [])
+	.config(function($stateProvider, $urlRouterProvider) {
+		$stateProvider
+			.state('trafficPortal.private.topologies', {
+				url: 'topologies',
+				abstract: true,
+				views: {
+					privateContent: {
+						templateUrl: 'modules/private/topologies/topologies.tpl.html'
+					}
+				}
+			})
+		;
+		$urlRouterProvider.otherwise('/');
+	});
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/modules/private/topologies/list/index.js
similarity index 58%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/modules/private/topologies/list/index.js
index 2c2ba1f..9f6bd90 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/modules/private/topologies/list/index.js
@@ -17,12 +17,23 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.private.topologies.list', [])
+	.config(function($stateProvider, $urlRouterProvider) {
+		$stateProvider
+			.state('trafficPortal.private.topologies.list', {
+				url: '',
+				views: {
+					topologiesContent: {
+						templateUrl: 'common/modules/table/topologies/table.topologies.tpl.html',
+						controller: 'TableTopologiesController',
+						resolve: {
+							topologies: function(topologyService) {
+								return topologyService.getTopologies();
+							}
+						}
+					}
+				}
+			})
+		;
+		$urlRouterProvider.otherwise('/');
+	});
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/modules/private/topologies/new/index.js
similarity index 54%
copy from traffic_portal/app/src/scripts/config.js
copy to traffic_portal/app/src/modules/private/topologies/new/index.js
index 2c2ba1f..1dfcdbf 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/modules/private/topologies/new/index.js
@@ -17,12 +17,28 @@
  * under the License.
  */
 
-// this is the config the TO UI uses
-
-"use strict";
-
-angular.module('config', [])
-
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
-
-;
+module.exports = angular.module('trafficPortal.private.topologies.new', [])
+	.config(function($stateProvider, $urlRouterProvider) {
+		$stateProvider
+			.state('trafficPortal.private.topologies.new', {
+				url: '/new',
+				views: {
+					topologiesContent: {
+						templateUrl: 'common/modules/form/topology/form.topology.tpl.html',
+						controller: 'FormNewTopologyController',
+						resolve: {
+							topology: function() {
+								return {
+									nodes: []
+								};
+							},
+							cacheGroups: function(cacheGroupService) {
+								return cacheGroupService.getCacheGroups();
+							}
+						}
+					}
+				}
+			})
+		;
+		$urlRouterProvider.otherwise('/');
+	});
diff --git a/traffic_portal/app/src/modules/private/topologies/topologies.tpl.html b/traffic_portal/app/src/modules/private/topologies/topologies.tpl.html
new file mode 100644
index 0000000..d617199
--- /dev/null
+++ b/traffic_portal/app/src/modules/private/topologies/topologies.tpl.html
@@ -0,0 +1,22 @@
+<!--
+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 id="topologiesContainer">
+    <div ui-view="topologiesContent"></div>
+</div>
diff --git a/traffic_portal/app/src/scripts/config.js b/traffic_portal/app/src/scripts/config.js
index 2c2ba1f..c5423a2 100644
--- a/traffic_portal/app/src/scripts/config.js
+++ b/traffic_portal/app/src/scripts/config.js
@@ -23,6 +23,6 @@
 
 angular.module('config', [])
 
-.constant('ENV', { api: { root:'/api/2.0/', legacy: '/api/1.5/' } })
+.constant('ENV', { api: { latest:'/api/3.0/', root:'/api/2.0/', legacy: '/api/1.5/' } })
 
 ;
diff --git a/traffic_portal/app/src/styles/main.scss b/traffic_portal/app/src/styles/main.scss
index 04a630d..a984fea 100644
--- a/traffic_portal/app/src/styles/main.scss
+++ b/traffic_portal/app/src/styles/main.scss
@@ -189,7 +189,36 @@ input[type="checkbox"].dirty {
   box-shadow:         0 0 10px 5px blue;
 }
 
+#tree-root {
+
+  .tree-node-content {
+    height: 45px;
+    text-align: center;
+    font-size: 14px;
+    margin-bottom: 10px;
+    small {
+      font-size: 65%;
+    }
+    .error-msg {
+      display: inline;
+      font-style: italic;
+    }
+  }
 
+  .tree-node.origin {
+    color: #004085;
+    background-color: #cce5ff;
+    border-color: #b8daff;
+  }
+  .tree-node.mid {
+    color: #383d41;
+    background-color: #e2e3e5;
+    border-color: #d6d8db;
+  }
+  .tree-node.error {
+    color: #721c24;
+    background-color: #f2dede;
+    border-color: #f5c6cb;
+  }
 
-
-
+}