You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by sh...@apache.org on 2023/01/23 21:41:16 UTC

[trafficcontrol] branch master updated: TPv2 Cache Groups table/details pages (#7292)

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

shamrick 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 ceaa6c257b TPv2 Cache Groups table/details pages (#7292)
ceaa6c257b is described below

commit ceaa6c257b235ed5faabb87c067f9c239df333db
Author: ocket8888 <oc...@apache.org>
AuthorDate: Mon Jan 23 14:41:10 2023 -0700

    TPv2 Cache Groups table/details pages (#7292)
    
    * Move some imports around
    
    * Add a default typing to HTTP methods
    
    That default being that no `response` is present
    
    * Switch to using trafficops-types Cache Group types in the API service
    
    * Add API methods for creating, updating, and deleting Cache Groups
    
    Also updated trafficops-types to 3.1.3 to make that possible.
    
    * Add new methods to testing module, convert to trafficops-types
    
    * Add stub component for Cache Group details page
    
    * Update trafficops-types for a bugfix
    
    * Update code depending on the Cache Group service to use trafficops-types
    
    * add localization methods column, value formatters for ID'd fields
    
    * add skeleton component form
    
    * Fix label bindings
    
    * Add routing for CG details page
    
    * Add latitude/longitude inputs, add responsive styling
    
    * Disable unused Context Menu items
    
    * Added a dialog type for choosing an item from a collection
    
    * Disable 'view servers' since that work should be done separately
    
    * Fix broken links to division/region/cache-group creation
    
    * Fix shared service instances not actually shared
    
    * Add queue method to Cache Group API service
    
    * Fix linting problems in the cache group test service
    
    * Update CDN service to use trafficops-types
    
    * Remove now-unused CacheGroup model
    
    * Add a button for creating new Cache Groups
    
    * Fix an aria label
    
    * Fix testing server service not provided in API testing module
    
    * Fix missing test injections
    
    * Fix tests for time utilities using timezone-dependent methods
    
    This makes them fail if run at certain times of day
    
    * Add dialog tests
    
    * Add "Open in new tab" context menu item
    
    * Add test coverage for the Cache Groups table
    
    * Add test coverage for the Cache Group details page
    
    * Update types service to use trafficops-types (partially)
    
    * Add missing Cache Group form controls
    
    * Fix Cache Group's own fallbacks allowed as parent(s)
    
    * Add e2e tests
    
    * Fix incorrect import path
    
    * Fix invalid file naming
    
    ???????
    
    * Fix tests not re-runnable
    
    * Remove duplicated attribute
    
    * Fix race condition in unit tests
    
    * Fix deleted Cache Groups still in table
    
    * Fix header and URL not updating on Cache Group update/creation
    
    * Fix typo in e2e tests
    
    * Fix unit tests
    
    * Add explicit button type
    
    * Fix attempting to load page before request completes
---
 .../traffic-portal/nightwatch/globals/globals.ts   |  52 ++-
 .../nightwatch/page_objects/cacheGroupDetails.ts   |  51 +++
 .../nightwatch/page_objects/cacheGroupsTable.ts    |  46 +++
 .../cacheGroups/{divisions => }/detail.spec.ts     |  18 +-
 .../tests/cacheGroups/divisions/detail.spec.ts     |   4 +-
 .../tests/cacheGroups/regions/detail.spec.ts       |   4 +-
 .../nightwatch/tests/cacheGroups/table.spec.ts     |  24 ++
 experimental/traffic-portal/package-lock.json      |  14 +-
 experimental/traffic-portal/package.json           |   2 +-
 .../traffic-portal/src/app/api/base-api.service.ts |  14 +-
 .../src/app/api/cache-group.service.ts             | 240 +++++++++++---
 .../traffic-portal/src/app/api/cdn.service.ts      |  36 +--
 .../traffic-portal/src/app/api/server.service.ts   |   2 +-
 .../src/app/api/testing/cache-group.service.ts     | 349 +++++++++++++++++++--
 .../src/app/api/testing/cdn.service.ts             |  48 ++-
 .../traffic-portal/src/app/api/testing/index.ts    |   3 +-
 .../src/app/api/testing/server.service.ts          |   2 +-
 .../traffic-portal/src/app/api/type.service.ts     |   5 +-
 .../cache-group-details.component.html             |  87 +++++
 .../cache-group-details.component.scss             |  64 ++++
 .../cache-group-details.component.spec.ts          | 299 ++++++++++++++++++
 .../cache-group-details.component.ts               | 239 ++++++++++++++
 .../cache-group-table.component.html               |   4 +-
 .../cache-group-table.component.spec.ts            | 329 ++++++++++++++++++-
 .../cache-group-table.component.ts                 | 172 ++++++++--
 .../traffic-portal/src/app/core/core.module.ts     |  52 +--
 .../src/app/core/dashboard/dashboard.component.ts  |   1 -
 .../new-delivery-service.component.ts              |   8 +-
 .../server-details/server-details.component.ts     |   9 +-
 .../app/core/users/tenants/tenants.component.html  |   2 +-
 .../traffic-portal/src/app/models/cache-groups.ts  |  63 ----
 .../traffic-portal/src/app/models/index.ts         |   2 -
 .../traffic-portal/src/app/models/models.spec.ts   |  12 -
 .../src/app/shared/alert/alert.component.scss      |  34 --
 .../src/app/shared/alert/alert.service.ts          |   6 +-
 .../app/shared/currentUser/current-user.service.ts |   4 +-
 .../collection-choice-dialog.component.html        |  31 ++
 .../collection-choice-dialog.component.scss}       |  18 +-
 .../collection-choice-dialog.component.spec.ts     |  58 ++++
 .../collection-choice-dialog.component.ts          |  63 ++++
 .../app/shared/interceptor/alerts.interceptor.ts   |   2 +-
 .../app/shared/interceptor/error.interceptor.ts    |   2 +-
 .../traffic-portal/src/app/shared/shared.module.ts |   8 +-
 .../src/app/shared/tp-header/tp-header.service.ts  |   3 +-
 .../traffic-portal/src/app/utils/time.spec.ts      |  18 +-
 45 files changed, 2120 insertions(+), 384 deletions(-)

diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts
index bed3a84b8c..f140802bcd 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -14,25 +14,29 @@
 */
 import * as https from "https";
 
-import axios, {AxiosError} from "axios";
-import {NightwatchBrowser} from "nightwatch";
+import axios, { AxiosError } from "axios";
+import { NightwatchBrowser } from "nightwatch";
+import type { CacheGroupDetailPageObject } from "nightwatch/page_objects/cacheGroupDetails";
+import type { CacheGroupsPageObject } from "nightwatch/page_objects/cacheGroupsTable";
 import type { ChangeLogsPageObject } from "nightwatch/page_objects/changeLogs";
-import type {CommonPageObject} from "nightwatch/page_objects/common";
-import type {DeliveryServiceCardPageObject} from "nightwatch/page_objects/deliveryServiceCard";
-import type {DeliveryServiceDetailPageObject} from "nightwatch/page_objects/deliveryServiceDetail";
-import type {DeliveryServiceInvalidPageObject} from "nightwatch/page_objects/deliveryServiceInvalidationJobs";
+import type { CommonPageObject } from "nightwatch/page_objects/common";
+import type { DeliveryServiceCardPageObject } from "nightwatch/page_objects/deliveryServiceCard";
+import type { DeliveryServiceDetailPageObject } from "nightwatch/page_objects/deliveryServiceDetail";
+import type { DeliveryServiceInvalidPageObject } from "nightwatch/page_objects/deliveryServiceInvalidationJobs";
 import type { DivisionDetailPageObject } from "nightwatch/page_objects/divisionDetail";
 import type { DivisionsPageObject } from "nightwatch/page_objects/divisionsTable";
-import type {LoginPageObject} from "nightwatch/page_objects/login";
+import type { LoginPageObject } from "nightwatch/page_objects/login";
 import type { RegionDetailPageObject } from "nightwatch/page_objects/regionDetail";
 import type { RegionsPageObject } from "nightwatch/page_objects/regionsTable";
-import type {ServersPageObject} from "nightwatch/page_objects/servers";
+import type { ServersPageObject } from "nightwatch/page_objects/servers";
 import type { TenantDetailPageObject } from "nightwatch/page_objects/tenantDetail";
 import type { TenantsPageObject } from "nightwatch/page_objects/tenants";
-import type {UsersPageObject} from "nightwatch/page_objects/users";
+import type { UsersPageObject } from "nightwatch/page_objects/users";
 import {
 	CDN,
-	GeoLimit, GeoProvider, LoginRequest,
+	GeoLimit,
+	GeoProvider,
+	LoginRequest,
 	Protocol,
 	RequestDeliveryService,
 	ResponseCDN,
@@ -44,7 +48,9 @@ import {
 	ResponseDivision,
 	RequestDivision,
 	ResponseRegion,
-	RequestRegion
+	RequestRegion,
+	RequestCacheGroup,
+	ResponseCacheGroup
 } from "trafficops-types";
 
 declare module "nightwatch" {
@@ -52,6 +58,8 @@ declare module "nightwatch" {
 	 * Defines the global nightwatch browser type with our types mixed in.
 	 */
 	export interface NightwatchCustomPageObjects {
+		cacheGroupDetails: () => CacheGroupDetailPageObject;
+		cacheGroupsTable: () => CacheGroupsPageObject;
 		common: () => CommonPageObject;
 		changeLogs: () => ChangeLogsPageObject;
 		deliveryServiceCard: () => DeliveryServiceCardPageObject;
@@ -85,6 +93,7 @@ declare module "nightwatch" {
  * Contains the data created by the client before the test suite runs.
  */
 export interface CreatedData {
+	cacheGroup: ResponseCacheGroup;
 	cdn: ResponseCDN;
 	ds: ResponseDeliveryService;
 	ds2: ResponseDeliveryService;
@@ -104,7 +113,7 @@ const globals = {
 			done();
 		});
 	},
-	apiVersion: "4.0",
+	apiVersion: "3.1",
 	before: async (done: () => void): Promise<void> => {
 		const apiUrl = `${globals.trafficOpsURL}/api/${globals.apiVersion}`;
 		const client = axios.create({
@@ -132,8 +141,9 @@ const globals = {
 			throw e;
 		}
 		if(accessToken === "") {
-			console.error("Access token is not set");
-			return Promise.reject();
+			const e = new Error("Access token is not set");
+			console.error(e.message);
+			throw e;
 		}
 		client.defaults.headers.common = { Cookie: accessToken };
 
@@ -156,6 +166,10 @@ const globals = {
 		if(steeringWeightType === undefined) {
 			throw new Error("Unable to find `STEERING_WEIGHT` type");
 		}
+		const cgType = types.find(typ => typ.useInTable === "cachegroup");
+		if (!cgType) {
+			throw new Error("Unable to find any Cache Group Types");
+		}
 
 		try {
 			const data = testData as CreatedData;
@@ -255,6 +269,16 @@ const globals = {
 			const respRegion: ResponseRegion = resp.data.response;
 			console.log(`Successfully created Region ${respRegion.name}`);
 			data.region = respRegion;
+
+			const cacheGroup: RequestCacheGroup = {
+				name: `test${globals.uniqueString}`,
+				shortName: `test${globals.uniqueString}`,
+				typeId: cgType.id
+			};
+			resp = await client.post(`${apiUrl}/cachegroups`, JSON.stringify(cacheGroup));
+			const responseCG: ResponseCacheGroup = resp.data.response;
+			console.log("Successfully created Cache Group:", responseCG);
+			data.cacheGroup = responseCG;
 		} catch(e) {
 			console.error((e as AxiosError).message);
 			throw e;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroupDetails.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroupDetails.ts
new file mode 100644
index 0000000000..973b66bded
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroupDetails.ts
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+
+import { EnhancedPageObject } from "nightwatch";
+
+/**
+ * Defines the PageObject for Cache Group Details.
+ */
+export type CacheGroupDetailPageObject = EnhancedPageObject<{}, typeof cacheGroupDetailPageObject.elements>;
+
+const cacheGroupDetailPageObject = {
+	elements: {
+		id: {
+			selector: "input[name='id']"
+		},
+		lastUpdated: {
+			selector: "input[name='lastUpdated']"
+		},
+		latitude: {
+			selector: "input[name='latitude']"
+		},
+		longitude: {
+			selector: "input[name='longitude']"
+		},
+		name: {
+			selector: "input[name='name']"
+		},
+		parent: {
+			selector: "mat-select[name='parentCacheGroup']"
+		},
+		saveBtn: {
+			selector: "button[type='submit']"
+		},
+		secondaryParent: {
+			selector: "mat-select[name='secondaryParentCacheGroup]"
+		},
+	},
+};
+
+export default cacheGroupDetailPageObject;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroupsTable.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroupsTable.ts
new file mode 100644
index 0000000000..77a16b3258
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroupsTable.ts
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+import { EnhancedPageObject, EnhancedSectionInstance, NightwatchAPI } from "nightwatch";
+
+import { TABLE_COMMANDS, TableSectionCommands } from "../globals/tables";
+
+/**
+ * Defines the Cache Group table commands
+ */
+type CacheGroupsTableCommands = TableSectionCommands;
+
+/**
+ * Defines the Page Object for the Cache Groups page.
+ */
+export type CacheGroupsPageObject = EnhancedPageObject<{}, {},
+EnhancedSectionInstance<CacheGroupsTableCommands>>;
+
+const divisionsPageObject = {
+	api: {} as NightwatchAPI,
+	sections: {
+		divisionsTable: {
+			commands: {
+				...TABLE_COMMANDS
+			},
+			elements: {},
+			selector: "mat-card"
+		}
+	},
+	url(): string {
+		return `${this.api.launchUrl}/core/cache-groups`;
+	}
+};
+
+export default divisionsPageObject;
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/detail.spec.ts
similarity index 66%
copy from experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
copy to experimental/traffic-portal/nightwatch/tests/cacheGroups/detail.spec.ts
index 943ed77176..032a7bde24 100644
--- a/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/detail.spec.ts
@@ -12,24 +12,24 @@
  * limitations under the License.
  */
 
-describe("Division Detail Spec", () => {
-	it("Test division", () => {
-		const page = browser.page.divisionDetail();
-		browser.url(`${page.api.launchUrl}/core/division/${browser.globals.testData.division.id}`, res => {
+describe("Cache Group Detail Spec", () => {
+	it("Test Cache Group", () => {
+		const page = browser.page.cacheGroupDetails();
+		browser.url(`${page.api.launchUrl}/core/cache-groups/${browser.globals.testData.cacheGroup.id}`, res => {
 			browser.assert.ok(res.status === 0);
 			page.waitForElementVisible("mat-card")
 				.assert.enabled("@name")
 				.assert.enabled("@saveBtn")
 				.assert.not.enabled("@id")
 				.assert.not.enabled("@lastUpdated")
-				.assert.valueEquals("@name", browser.globals.testData.division.name)
-				.assert.valueEquals("@id", String(browser.globals.testData.division.id));
+				.assert.valueEquals("@name", browser.globals.testData.cacheGroup.name)
+				.assert.valueEquals("@id", String(browser.globals.testData.cacheGroup.id));
 		});
 	});
 
-	it("New division", () => {
-		const page = browser.page.divisionDetail();
-		browser.url(`${page.api.launchUrl}/core/division/new`, res => {
+	it("New Cache Group", () => {
+		const page = browser.page.cacheGroupDetails();
+		browser.url(`${page.api.launchUrl}/core/cache-groups/new`, res => {
 			browser.assert.ok(res.status === 0);
 			page.waitForElementVisible("mat-card")
 				.assert.enabled("@name")
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
index 943ed77176..96b0a59a90 100644
--- a/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
@@ -15,7 +15,7 @@
 describe("Division Detail Spec", () => {
 	it("Test division", () => {
 		const page = browser.page.divisionDetail();
-		browser.url(`${page.api.launchUrl}/core/division/${browser.globals.testData.division.id}`, res => {
+		browser.url(`${page.api.launchUrl}/core/divisions/${browser.globals.testData.division.id}`, res => {
 			browser.assert.ok(res.status === 0);
 			page.waitForElementVisible("mat-card")
 				.assert.enabled("@name")
@@ -29,7 +29,7 @@ describe("Division Detail Spec", () => {
 
 	it("New division", () => {
 		const page = browser.page.divisionDetail();
-		browser.url(`${page.api.launchUrl}/core/division/new`, res => {
+		browser.url(`${page.api.launchUrl}/core/divisions/new`, res => {
 			browser.assert.ok(res.status === 0);
 			page.waitForElementVisible("mat-card")
 				.assert.enabled("@name")
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/regions/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/regions/detail.spec.ts
index 8af0540622..e116fa28db 100644
--- a/experimental/traffic-portal/nightwatch/tests/cacheGroups/regions/detail.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/regions/detail.spec.ts
@@ -15,7 +15,7 @@
 describe("Region Detail Spec", () => {
 	it("Test region", () => {
 		const page = browser.page.regionDetail();
-		browser.url(`${page.api.launchUrl}/core/region/${browser.globals.testData.region.id}`, res => {
+		browser.url(`${page.api.launchUrl}/core/regions/${browser.globals.testData.region.id}`, res => {
 			browser.assert.ok(res.status === 0);
 			page.waitForElementVisible("mat-card")
 				.assert.enabled("@name")
@@ -30,7 +30,7 @@ describe("Region Detail Spec", () => {
 
 	it("New region", () => {
 		const page = browser.page.regionDetail();
-		browser.url(`${page.api.launchUrl}/core/region/new`, res => {
+		browser.url(`${page.api.launchUrl}/core/regions/new`, res => {
 			browser.assert.ok(res.status === 0);
 			page.waitForElementVisible("mat-card")
 				.assert.enabled("@name")
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/table.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/table.spec.ts
new file mode 100644
index 0000000000..f0a276caa6
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/table.spec.ts
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+describe("Cache Groups Spec", () => {
+	it("Loads elements", async () => {
+		browser.page.cacheGroupsTable().navigate()
+			.waitForElementPresent("input[name=fuzzControl]");
+		browser.elements("css selector", "div.ag-row", rows => {
+			browser.assert.ok(rows.status === 0);
+			browser.assert.ok((rows.value as []).length >= 2);
+		});
+	});
+});
diff --git a/experimental/traffic-portal/package-lock.json b/experimental/traffic-portal/package-lock.json
index c886bb6bc1..d1dbeaed1b 100644
--- a/experimental/traffic-portal/package-lock.json
+++ b/experimental/traffic-portal/package-lock.json
@@ -68,7 +68,7 @@
         "karma-jasmine": "~4.0.0",
         "karma-jasmine-html-reporter": "^1.5.0",
         "nightwatch": "^2.5.1",
-        "trafficops-types": "^3.1.2",
+        "trafficops-types": "^3.1.4",
         "ts-node": "~8.3.0",
         "typescript": "^4.5.4"
       },
@@ -17184,9 +17184,9 @@
       }
     },
     "node_modules/trafficops-types": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-3.1.2.tgz",
-      "integrity": "sha512-4XHGNmZALrO4lhs0lJIxMFSH1+zsILe1/gCMAuNW5dZOdaa9tsOqmAMXjXidti6lmuk+IaWk5wZoiiFyW09PTQ==",
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-3.1.4.tgz",
+      "integrity": "sha512-PSaJdzgwi7gkoUFMn5oNmrwyqeCyQrvuH47XuJSOJjgoo43DPWvlSTH0Ru/w5IRZ2Zli20wib7S4Ej/S8g8Y+A==",
       "dev": true
     },
     "node_modules/traverse": {
@@ -31156,9 +31156,9 @@
       }
     },
     "trafficops-types": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-3.1.2.tgz",
-      "integrity": "sha512-4XHGNmZALrO4lhs0lJIxMFSH1+zsILe1/gCMAuNW5dZOdaa9tsOqmAMXjXidti6lmuk+IaWk5wZoiiFyW09PTQ==",
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-3.1.4.tgz",
+      "integrity": "sha512-PSaJdzgwi7gkoUFMn5oNmrwyqeCyQrvuH47XuJSOJjgoo43DPWvlSTH0Ru/w5IRZ2Zli20wib7S4Ej/S8g8Y+A==",
       "dev": true
     },
     "traverse": {
diff --git a/experimental/traffic-portal/package.json b/experimental/traffic-portal/package.json
index b30bb941d7..17c868efe6 100644
--- a/experimental/traffic-portal/package.json
+++ b/experimental/traffic-portal/package.json
@@ -108,7 +108,7 @@
     "karma-jasmine": "~4.0.0",
     "karma-jasmine-html-reporter": "^1.5.0",
     "nightwatch": "^2.5.1",
-    "trafficops-types": "^3.1.2",
+    "trafficops-types": "^3.1.4",
     "ts-node": "~8.3.0",
     "typescript": "^4.5.4"
   },
diff --git a/experimental/traffic-portal/src/app/api/base-api.service.ts b/experimental/traffic-portal/src/app/api/base-api.service.ts
index 7d811cfa78..97138e8547 100644
--- a/experimental/traffic-portal/src/app/api/base-api.service.ts
+++ b/experimental/traffic-portal/src/app/api/base-api.service.ts
@@ -36,7 +36,7 @@ export abstract class APIService {
 	 * @param params Option query parameters to send in the request.
 	 * @returns An Observable that emits the server response.
 	 */
-	protected delete<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+	protected delete<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
 		return this.do<T>("delete", path, data, params);
 	}
 
@@ -48,7 +48,7 @@ export abstract class APIService {
 	 * @param params Option query parameters to send in the request.
 	 * @returns An Observable that emits the server response.
 	 */
-	protected get<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+	protected get<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
 		return this.do<T>("get", path, data, params);
 	}
 
@@ -60,7 +60,7 @@ export abstract class APIService {
 	 * @param params Option query parameters to send in the request.
 	 * @returns An Observable that emits the server response.
 	 */
-	protected head<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+	protected head<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
 		return this.do<T>("head", path, data, params);
 	}
 
@@ -72,7 +72,7 @@ export abstract class APIService {
 	 * @param params Option query parameters to send in the request.
 	 * @returns An Observable that emits the server response.
 	 */
-	protected options<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+	protected options<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
 		return this.do<T>("options", path, data, params);
 	}
 
@@ -84,7 +84,7 @@ export abstract class APIService {
 	 * @param params Option query parameters to send in the request.
 	 * @returns An Observable that emits the server response.
 	 */
-	protected patch<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+	protected patch<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
 		return this.do<T>("patch", path, data, params);
 	}
 
@@ -96,7 +96,7 @@ export abstract class APIService {
 	 * @param params Option query parameters to send in the request.
 	 * @returns An Observable that emits the server response.
 	 */
-	protected post<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+	protected post<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
 		return this.do<T>("post", path, data, params);
 	}
 
@@ -108,7 +108,7 @@ export abstract class APIService {
 	 * @param params Option query parameters to send in the request.
 	 * @returns An Observable that emits the server response.
 	 */
-	protected put<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+	protected put<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
 		return this.do<T>("put", path, data, params);
 	}
 
diff --git a/experimental/traffic-portal/src/app/api/cache-group.service.ts b/experimental/traffic-portal/src/app/api/cache-group.service.ts
index 7fab81817a..6bdb28f48f 100644
--- a/experimental/traffic-portal/src/app/api/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/cache-group.service.ts
@@ -13,28 +13,63 @@
 */
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
-import type { RequestDivision, ResponseDivision, RequestRegion, ResponseRegion } from "trafficops-types";
-
-import type { CacheGroup } from "src/app/models";
+import type {
+	RequestDivision,
+	ResponseDivision,
+	RequestRegion,
+	ResponseRegion,
+	ResponseCacheGroup,
+	RequestCacheGroup,
+	CDN,
+	CacheGroupQueueResponse,
+	CacheGroupQueueRequest,
+} from "trafficops-types";
 
 import { APIService } from "./base-api.service";
 
+/**
+ * Checks the type of an argument to
+ * {@link CacheGroupService.queueCacheGroupUpdates}.
+ *
+ * @param x The object to check.
+ * @returns Whether `x` is an {@link CacheGroupQueueRequest}.
+ */
+function isRequest(x: CacheGroupQueueRequest | CDN | string | number): x is CacheGroupQueueRequest {
+	return Object.prototype.hasOwnProperty.call(x, "action");
+}
+
 /**
  * CDNService expose API functionality relating to CDNs.
  */
 @Injectable()
 export class CacheGroupService extends APIService {
-	public async getCacheGroups(idOrName: number | string): Promise<CacheGroup>;
-	public async getCacheGroups(): Promise<Array<CacheGroup>>;
+
 	/**
-	 * Gets one or all CDNs from Traffic Ops
+	 * Gets a single Cache Group from Traffic Ops.
 	 *
-	 * @param idOrName Optionally either the name or integral, unique identifier of a single Cache Group to be returned.
-	 * @returns Either an Array of CacheGroup objects, or a single CacheGroup, depending on whether
-	 * `idOrName` was 	passed.
-	 * @throws {Error} In the event that `idOrName` is passed but does not match any CacheGroup.
+	 * @param idOrName Either the name or integral, unique identifier of the
+	 * single Cache Group to be returned.
+	 * @returns The Cache Group identified by `idOrName`.
+	 * @throws {Error} When no matching Cache Group is found in Traffic Ops.
 	 */
-	public async getCacheGroups(idOrName?: number | string): Promise<Array<CacheGroup> | CacheGroup> {
+	public async getCacheGroups(idOrName: number | string): Promise<ResponseCacheGroup>;
+	/**
+	 * Gets Cache Groups from Traffic Ops.
+	 *
+	 * @returns All requested Cache Groups.
+	 */
+	public async getCacheGroups(): Promise<Array<ResponseCacheGroup>>;
+	/**
+	 * Gets one or all Cache Groups from Traffic Ops
+	 *
+	 * @param idOrName Optionally either the name or integral, unique identifier
+	 * of a single Cache Group to be returned.
+	 * @returns Either an Array of Cache Group objects, or a single Cache Group,
+	 * depending on whether `idOrName` was 	passed.
+	 * @throws {Error} In the event that `idOrName` is passed but does not match
+	 * any Cache Group.
+	 */
+	public async getCacheGroups(idOrName?: number | string): Promise<Array<ResponseCacheGroup> | ResponseCacheGroup> {
 		const path = "cachegroups";
 		if (idOrName !== undefined) {
 			let params;
@@ -45,50 +80,151 @@ export class CacheGroupService extends APIService {
 				case "number":
 					params = {id: String(idOrName)};
 			}
-			return this.get<[CacheGroup]>(path, undefined, params).toPromise().then(
-				r => {
-					const cg = r[0];
-					if (cg.id !== idOrName) {
-						throw new Error(`Traffic Ops returned no match for ID ${idOrName}`);
-					}
-					//  lastUpdated comes in as a string
-					cg.lastUpdated = cg.lastUpdated ? new Date((cg.lastUpdated as unknown as string).replace("+00", "Z")) : undefined;
-					return cg;
-				}
-			).catch(
-				e => {
-					console.error("Failed to get Cache Group with identifier", idOrName, ":", e);
-					return {
-						fallbackToClosest: false,
-						fallbacks: [],
-						latitude: 0,
-						localizationMethods: [],
-						longitude: 0,
-						name: "",
-						parentCacheGroupID: -1,
-						parentCacheGroupName: "",
-						secondaryParentCacheGroupID: -1,
-						secondaryParentCacheGroupName: "",
-						shortName: "",
-						typeId: -1,
-						typeName: ""
-					};
-				}
-			);
+			const resp = await this.get<[ResponseCacheGroup]>(path, undefined, params).toPromise();
+			if (resp.length !== 1) {
+				throw new Error(`Traffic Ops returned wrong number of results for Cache Group identifier: ${params}`);
+			}
+			const cg = resp[0];
+			//  lastUpdated comes in as a string
+			return {...cg, lastUpdated: new Date((cg.lastUpdated as unknown as string).replace("+00", "Z"))};
 		}
-		return this.get<Array<CacheGroup>>(path).toPromise().then(r => r.map(
-			cg => {
-				if (cg.lastUpdated) {
-					cg.lastUpdated = new Date((cg.lastUpdated as unknown as string).replace("+00", "Z"));
-				}
-				return cg;
+		const r = await this.get<Array<ResponseCacheGroup>>(path).toPromise();
+		return r.map(cg => ({...cg, lastUpdated: new Date((cg.lastUpdated as unknown as string).replace("+00", "Z"))}));
+	}
+
+	/**
+	 * Deletes a Cache Group.
+	 *
+	 * @param cacheGroup The Cache Group to be deleted, or just its ID.
+	 */
+	public async deleteCacheGroup(cacheGroup: ResponseCacheGroup | number): Promise<void> {
+		const id = typeof(cacheGroup) === "number" ? cacheGroup : cacheGroup.id;
+		return this.delete(`cachegroups/${id}`).toPromise();
+	}
+
+	/**
+	 * Creates a new Cache Group.
+	 *
+	 * @param cacheGroup The Cache Group to create.
+	 */
+	public async createCacheGroup(cacheGroup: RequestCacheGroup): Promise<ResponseCacheGroup> {
+		return this.post<ResponseCacheGroup>("cachegroups", cacheGroup).toPromise();
+	}
+
+	/**
+	 * Replaces an existing Cache Group with the provided new definition of a
+	 * Cache Group.
+	 *
+	 * @param id The if of the Cache Group being updated.
+	 * @param cacheGroup The new definition of the Cache Group.
+	 */
+	public async updateCacheGroup(id: number, cacheGroup: RequestCacheGroup): Promise<ResponseCacheGroup>;
+	/**
+	 * Replaces an existing Cache Group with the provided new definition of a
+	 * Cache Group.
+	 *
+	 * @param cacheGroup The full new definition of the Cache Group being
+	 * updated.
+	 */
+	public async updateCacheGroup(cacheGroup: ResponseCacheGroup): Promise<ResponseCacheGroup>;
+	/**
+	 * Replaces an existing Cache Group with the provided new definition of a
+	 * Cache Group.
+	 *
+	 * @param cacheGroupOrID The full new definition of the Cache Group being
+	 * updated, or just its ID.
+	 * @param payload The new definition of the Cache Group. This is required if
+	 * `cacheGroupOrID` is an ID, and ignored otherwise.
+	 */
+	public async updateCacheGroup(cacheGroupOrID: ResponseCacheGroup | number, payload?: RequestCacheGroup): Promise<ResponseCacheGroup> {
+		let id;
+		let body;
+		if (typeof(cacheGroupOrID) === "number") {
+			if (!payload) {
+				throw new TypeError("invalid call signature - missing request payload");
 			}
-		)).catch(
-			e => {
-				console.error("Failed to get Cache Groups:", e);
-				return [];
+			body = payload;
+			id = cacheGroupOrID;
+		} else {
+			body = cacheGroupOrID;
+			({id} = cacheGroupOrID);
+		}
+
+		return this.put<ResponseCacheGroup>(`cachegroups/${id}`, body).toPromise();
+	}
+
+	/**
+	 * Queues (or dequeues) updates on a Cache Group's servers.
+	 *
+	 * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+	 * just its ID.
+	 * @param cdnOrIdentifier Either a CDN, its name, or its ID.
+	 * @param action Used to determine the queue action to take. If not given,
+	 * defaults to `queue`.
+	 * @returns The API's response.
+	 */
+	public async queueCacheGroupUpdates(
+		cacheGroupOrID: ResponseCacheGroup | number,
+		cdnOrIdentifier: CDN | string | number,
+		action?: "queue" | "dequeue"
+	): Promise<CacheGroupQueueResponse>;
+	/**
+	 * Queues (or dequeues) updates on a Cache Group's servers.
+	 *
+	 * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+	 * just its ID.
+	 * @param request The full (de/)queue request.
+	 * @returns The API's response.
+	 */
+	public async queueCacheGroupUpdates(
+		cacheGroupOrID: ResponseCacheGroup | number,
+		request: CacheGroupQueueRequest
+	): Promise<CacheGroupQueueResponse>;
+	/**
+	 * Queues (or dequeues) updates on a Cache Group's servers.
+	 *
+	 * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+	 * just its ID.
+	 * @param cdnOrIdentifierOrRequest Either the full (de/)queue request or a
+	 * CDN, its name, or its ID.
+	 * @param action If `cdnOrIdentifierOrRequest` is not a full (de/)queue
+	 * request, then this will be used to determine the queue action to take. If
+	 * not given, defaults to `queue`.
+	 * @returns The API's response.
+	 */
+	public async queueCacheGroupUpdates(
+		cacheGroupOrID: ResponseCacheGroup | number,
+		cdnOrIdentifierOrRequest: CacheGroupQueueRequest | CDN | string | number,
+		action?: "queue" | "dequeue"
+	): Promise<CacheGroupQueueResponse> {
+		const cgID = typeof(cacheGroupOrID) === "number" ? cacheGroupOrID : cacheGroupOrID.id;
+		const path = `cachegroups/${cgID}/queue_update`;
+		let request: CacheGroupQueueRequest;
+		if (isRequest(cdnOrIdentifierOrRequest)) {
+			request = cdnOrIdentifierOrRequest;
+		} else {
+			action = action ?? "queue";
+			switch (typeof(cdnOrIdentifierOrRequest)) {
+				case "string":
+					request = {
+						action,
+						cdn: cdnOrIdentifierOrRequest,
+					};
+					break;
+				case "number":
+					request = {
+						action,
+						cdnId: cdnOrIdentifierOrRequest,
+					};
+					break;
+				default:
+					request = {
+						action,
+						cdn: cdnOrIdentifierOrRequest.name
+					};
 			}
-		);
+		}
+		return this.post<CacheGroupQueueResponse>(path, request).toPromise();
 	}
 
 	public async getDivisions(): Promise<Array<ResponseDivision>>;
diff --git a/experimental/traffic-portal/src/app/api/cdn.service.ts b/experimental/traffic-portal/src/app/api/cdn.service.ts
index 8e27c0da85..3cbd990743 100644
--- a/experimental/traffic-portal/src/app/api/cdn.service.ts
+++ b/experimental/traffic-portal/src/app/api/cdn.service.ts
@@ -13,8 +13,7 @@
 */
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
-
-import type { CDN } from "src/app/models";
+import type { ResponseCDN } from "trafficops-types";
 
 import { APIService } from "./base-api.service";
 
@@ -28,8 +27,8 @@ export class CDNService extends APIService {
 		super(http);
 	}
 
-	public async getCDNs(id: number): Promise<CDN>;
-	public async getCDNs(): Promise<Map<string, CDN>>;
+	public async getCDNs(id: number): Promise<ResponseCDN>;
+	public async getCDNs(): Promise<Array<ResponseCDN>>;
 	/**
 	 * Gets one or all CDNs from Traffic Ops
 	 *
@@ -38,30 +37,15 @@ export class CDNService extends APIService {
 	 * 	passed.
 	 * (In the event that `id` is passed but does not match any CDN, `null` will be emitted)
 	 */
-	public async getCDNs(id?: number): Promise<Map<string, CDN> | CDN> {
+	public async getCDNs(id?: number): Promise<Array<ResponseCDN> | ResponseCDN> {
 		const path = "cdns";
 		if (id) {
-			return this.get<[CDN]>(path, undefined, {id: String(id)}).toPromise().then(
-				r => r[0]
-			).catch(
-				e => {
-					console.error(`Failed to get CDN #${id}`, e);
-					return {
-						dnssecEnabled: false,
-						domainName: "",
-						id: -1,
-						name: "",
-					};
-				}
-			);
-		}
-		return this.get<Array<CDN>>(path).toPromise().then(
-			r => new Map<string, CDN>(r.map(c=>[c.name, c]))
-		).catch(
-			e => {
-				console.error("Failed to get CDNs:", e);
-				return new Map();
+			const cdn = await this.get<[ResponseCDN]>(path, undefined, {id: String(id)}).toPromise();
+			if (cdn.length !== 1) {
+				throw new Error(`${cdn.length} CDNs found by ID ${id}`);
 			}
-		);
+			return cdn;
+		}
+		return this.get<Array<ResponseCDN>>(path).toPromise();
 	}
 }
diff --git a/experimental/traffic-portal/src/app/api/server.service.ts b/experimental/traffic-portal/src/app/api/server.service.ts
index 73c7fcf91f..d4a541bc87 100644
--- a/experimental/traffic-portal/src/app/api/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/server.service.ts
@@ -233,7 +233,7 @@ export class ServerService extends APIService {
 			id = server.id;
 		}
 
-		return this.put<undefined>(`servers/${id}/status`, {offlineReason, status}).toPromise().catch(
+		return this.put(`servers/${id}/status`, {offlineReason, status}).toPromise().catch(
 			e=> {
 				console.error("Failed to update server status:", e);
 				return undefined;
diff --git a/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts b/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
index 99f5f2c8ed..660f94090e 100644
--- a/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
@@ -12,9 +12,73 @@
 * limitations under the License.
 */
 import { Injectable } from "@angular/core";
-import { RequestDivision, ResponseDivision, RequestRegion, ResponseRegion } from "trafficops-types";
+import type {
+	CacheGroupQueueRequest,
+	CacheGroupQueueResponse,
+	CDN,
+	RequestCacheGroup,
+	RequestDivision,
+	RequestRegion,
+	ResponseCacheGroup,
+	ResponseDivision,
+	ResponseRegion,
+} from "trafficops-types";
 
-import type { CacheGroup } from "src/app/models";
+import { ServerService } from "./server.service";
+
+/**
+ * The names of properties of {@link ResponseCacheGroup}s that define its
+ * primary parentage.
+ */
+type ParentKeys = "parentCachegroupId" | "parentCachegroupName";
+/**
+ * The names of properties of {@link ResponseCacheGroup}s that define its
+ * secondary parentage.
+ */
+type SecondaryParentKeys = "secondaryParentCachegroupId" | "secondaryParentCachegroupName";
+/**
+ * The names of properties of {@link ResponseCacheGroup}s that define its
+ * parentage; both primary and secondary.
+ */
+type AllParentageKeys = ParentKeys | SecondaryParentKeys;
+
+/**
+ * The parts of a Cache Group pertaining to its primary parentage.
+ */
+type Parentage = {
+	parentCachegroupId: null;
+	parentCachegroupName: null;
+} | {
+	parentCachegroupId: number;
+	parentCachegroupName: string;
+};
+
+/**
+ * The parts of a Cache Group pertaining to its secondary parentage.
+ */
+type SecondaryParentage = {
+	secondaryParentCachegroupId: null;
+	secondaryParentCachegroupName: null;
+} | {
+	secondaryParentCachegroupId: number;
+	secondaryParentCachegroupName: string;
+};
+
+/**
+ * Contains all information about a Cache Groups parents, primary and secondary.
+ */
+type AllParentage = Parentage & SecondaryParentage;
+
+/**
+ * Checks the type of an argument to
+ * {@link CacheGroupService.queueCacheGroupUpdates}.
+ *
+ * @param x The object to check.
+ * @returns Whether `x` is an {@link CacheGroupQueueRequest}.
+ */
+function isRequest(x: CacheGroupQueueRequest | CDN | string | number): x is CacheGroupQueueRequest {
+	return Object.prototype.hasOwnProperty.call(x, "action");
+}
 
 /**
  * CDNService expose API functionality relating to CDNs.
@@ -37,19 +101,20 @@ export class CacheGroupService {
 		name: "Reg1"
 	}
 	];
-	private readonly cacheGroups = [
+	private readonly cacheGroups: Array<ResponseCacheGroup> = [
 		{
 			fallbackToClosest: true,
 			fallbacks: [],
 			id: 1,
+			lastUpdated: new Date(),
 			latitude: 0,
 			localizationMethods: [],
 			longitude: 0,
 			name: "Mid",
-			parentCacheGroupID: null,
-			parentCacheGroupName: null,
-			secondaryParentCacheGroupID: null,
-			secondaryParentCacheGroupName: null,
+			parentCachegroupId: null,
+			parentCachegroupName: null,
+			secondaryParentCachegroupId: null,
+			secondaryParentCachegroupName: null,
 			shortName: "Mid",
 			typeId: 1,
 			typeName: "MID_LOC"
@@ -58,14 +123,15 @@ export class CacheGroupService {
 			fallbackToClosest: true,
 			fallbacks: [],
 			id: 2,
+			lastUpdated: new Date(),
 			latitude: 0,
 			localizationMethods: [],
 			longitude: 0,
 			name: "Edge",
-			parentCacheGroupID: 1,
-			parentCacheGroupName: "Mid",
-			secondaryParentCacheGroupID: null,
-			secondaryParentCacheGroupName: null,
+			parentCachegroupId: 1,
+			parentCachegroupName: "Mid",
+			secondaryParentCachegroupId: null,
+			secondaryParentCachegroupName: null,
 			shortName: "Edge",
 			typeId: 2,
 			typeName: "EDGE_LOC"
@@ -74,14 +140,15 @@ export class CacheGroupService {
 			fallbackToClosest: true,
 			fallbacks: [],
 			id: 3,
+			lastUpdated: new Date(),
 			latitude: 0,
 			localizationMethods: [],
 			longitude: 0,
 			name: "Origin",
-			parentCacheGroupID: null,
-			parentCacheGroupName: null,
-			secondaryParentCacheGroupID: null,
-			secondaryParentCacheGroupName: null,
+			parentCachegroupId: null,
+			parentCachegroupName: null,
+			secondaryParentCachegroupId: null,
+			secondaryParentCachegroupName: null,
 			shortName: "Origin",
 			typeId: 3,
 			typeName: "ORG_LOC"
@@ -90,22 +157,25 @@ export class CacheGroupService {
 			fallbackToClosest: true,
 			fallbacks: [],
 			id: 4,
+			lastUpdated: new Date(),
 			latitude: 0,
 			localizationMethods: [],
 			longitude: 0,
 			name: "Other",
-			parentCacheGroupID: null,
-			parentCacheGroupName: null,
-			secondaryParentCacheGroupID: null,
-			secondaryParentCacheGroupName: null,
+			parentCachegroupId: null,
+			parentCachegroupName: null,
+			secondaryParentCachegroupId: null,
+			secondaryParentCachegroupName: null,
 			shortName: "Other",
 			typeId: 4,
 			typeName: "TC_LOC"
 		}
 	];
 
-	public async getCacheGroups(idOrName: number | string): Promise<CacheGroup>;
-	public async getCacheGroups(): Promise<Array<CacheGroup>>;
+	constructor(private readonly servers: ServerService) {}
+
+	public async getCacheGroups(idOrName: number | string): Promise<ResponseCacheGroup>;
+	public async getCacheGroups(): Promise<Array<ResponseCacheGroup>>;
 	/**
 	 * Gets one or all CDNs from Traffic Ops
 	 *
@@ -114,7 +184,7 @@ export class CacheGroupService {
 	 * `idOrName` was 	passed.
 	 * @throws {Error} In the event that `idOrName` is passed but does not match any CacheGroup.
 	 */
-	public async getCacheGroups(idOrName?: number | string): Promise<Array<CacheGroup> | CacheGroup> {
+	public async getCacheGroups(idOrName?: number | string): Promise<Array<ResponseCacheGroup> | ResponseCacheGroup> {
 		if (idOrName !== undefined) {
 			let cacheGroup;
 			switch (typeof(idOrName)) {
@@ -132,6 +202,241 @@ export class CacheGroupService {
 		return this.cacheGroups;
 	}
 
+	/**
+	 * Deletes a Cache Group.
+	 *
+	 * @param cacheGroup The Cache Group to be deleted, or just its ID.
+	 */
+	public async deleteCacheGroup(cacheGroup: ResponseCacheGroup | number): Promise<void> {
+		const id = typeof(cacheGroup) === "number" ? cacheGroup : cacheGroup.id;
+		const idx = this.cacheGroups.findIndex(cg => cg.id === id);
+		if (idx < 0) {
+			throw new Error(`no such Cache Group: #${id}`);
+		}
+		this.cacheGroups.splice(idx, 1);
+	}
+
+	/**
+	 * Gets the names of parents for a Cache Group from their IDs.
+	 *
+	 * @param parentID The ID of a parent Cache Group (or not).
+	 * @param secondaryParentID The ID of a "secondary" parent Cache Group (or
+	 * not).
+	 * @returns The parentage portion of a Cache Group.
+	 */
+	private getParents(parentID: number | null | undefined, secondaryParentID: number | null | undefined): AllParentage {
+		let parent: Parentage = {
+			parentCachegroupId: null,
+			parentCachegroupName: null
+		};
+		if (typeof(parentID) === "number") {
+			const p = this.cacheGroups.find(cg => cg.id === parentID);
+			if (!p) {
+				throw new Error(`no such parent Cache Group: #${parentID}`);
+			}
+			parent = {
+				parentCachegroupId: p.id,
+				parentCachegroupName: p.name
+			};
+		}
+
+		let secondaryParent: SecondaryParentage = {
+			secondaryParentCachegroupId: null,
+			secondaryParentCachegroupName: null
+		};
+		if (typeof(secondaryParentID) === "number") {
+			const p = this.cacheGroups.find(cg => cg.id === secondaryParentID);
+			if (!p) {
+				throw new Error(`no such secondary parent Cache Group: #${secondaryParentID}`);
+			}
+			secondaryParent = {
+				secondaryParentCachegroupId: p.id,
+				secondaryParentCachegroupName: p.name
+			};
+		}
+
+		return {
+			...parent,
+			...secondaryParent
+		};
+	}
+
+	/**
+	 * Creates a new Cache Group.
+	 *
+	 * @param cacheGroup The Cache Group to create.
+	 */
+	public async createCacheGroup(cacheGroup: RequestCacheGroup): Promise<ResponseCacheGroup> {
+		const cg = {
+			...cacheGroup,
+			...this.getParents(cacheGroup.parentCachegroupId, cacheGroup.secondaryParentCachegroupId),
+			fallbackToClosest: cacheGroup.fallbackToClosest ?? false,
+			fallbacks: cacheGroup.fallbacks ?? [],
+			id: ++this.lastID,
+			lastUpdated: new Date(),
+			latitude: cacheGroup.latitude ?? 0,
+			localizationMethods: cacheGroup.localizationMethods ?? [],
+			longitude: cacheGroup.longitude ?? 0,
+			typeName: "",
+		};
+		this.cacheGroups.push(cg);
+		return cg;
+	}
+
+	/**
+	 * Replaces an existing Cache Group with the provided new definition of a
+	 * Cache Group.
+	 *
+	 * @param id The if of the Cache Group being updated.
+	 * @param cacheGroup The new definition of the Cache Group.
+	 */
+	public async updateCacheGroup(id: number, cacheGroup: RequestCacheGroup): Promise<ResponseCacheGroup>;
+	/**
+	 * Replaces an existing Cache Group with the provided new definition of a
+	 * Cache Group.
+	 *
+	 * @param cacheGroup The full new definition of the Cache Group being
+	 * updated.
+	 */
+	public async updateCacheGroup(cacheGroup: ResponseCacheGroup): Promise<ResponseCacheGroup>;
+	/**
+	 * Replaces an existing Cache Group with the provided new definition of a
+	 * Cache Group.
+	 *
+	 * @param cacheGroupOrID The full new definition of the Cache Group being
+	 * updated, or just its ID.
+	 * @param payload The new definition of the Cache Group. This is required if
+	 * `cacheGroupOrID` is an ID, and ignored otherwise.
+	 */
+	public async updateCacheGroup(cacheGroupOrID: ResponseCacheGroup | number, payload?: RequestCacheGroup): Promise<ResponseCacheGroup> {
+		let idx;
+		let cg: Omit<ResponseCacheGroup, AllParentageKeys>;
+		let parentCachegroupId;
+		let secondaryParentCachegroupId;
+		if (typeof(cacheGroupOrID) === "number") {
+			if (!payload) {
+				throw new TypeError("invalid call signature - missing request payload");
+			}
+			idx = this.cacheGroups.findIndex(c => c.id === cacheGroupOrID);
+			cg = {
+				...payload,
+				fallbackToClosest: payload.fallbackToClosest ?? false,
+				fallbacks: payload.fallbacks ?? [],
+				id: ++this.lastID,
+				lastUpdated: new Date(),
+				latitude: payload.latitude ?? 0,
+				localizationMethods: payload.localizationMethods ?? [],
+				longitude: payload.longitude ?? 0,
+				typeName: "",
+			};
+			parentCachegroupId = payload.parentCachegroupId;
+			secondaryParentCachegroupId = payload.secondaryParentCachegroupId;
+		} else {
+			idx = this.cacheGroups.findIndex(c => c.id === cacheGroupOrID.id);
+			cg = {
+				...cacheGroupOrID,
+				lastUpdated: new Date()
+			};
+			parentCachegroupId = cacheGroupOrID.parentCachegroupId;
+			secondaryParentCachegroupId = cacheGroupOrID.secondaryParentCachegroupId;
+		}
+
+		if (idx < 0) {
+			throw new Error(`no such Cache Group: #${cacheGroupOrID}`);
+		}
+
+		const final = {
+			...cg,
+			...this.getParents(parentCachegroupId, secondaryParentCachegroupId)
+		};
+
+		this.cacheGroups[idx] = final;
+
+		return final;
+	}
+
+	/**
+	 * Queues (or dequeues) updates on a Cache Group's servers.
+	 *
+	 * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+	 * just its ID.
+	 * @param cdnOrIdentifier Either a CDN, its name, or its ID.
+	 * @param action Used to determine the queue action to take. If not given,
+	 * defaults to `queue`.
+	 * @returns The API's response.
+	 */
+	public async queueCacheGroupUpdates(
+		cacheGroupOrID: ResponseCacheGroup | number,
+		cdnOrIdentifier: CDN | string | number,
+		action?: "queue" | "dequeue"
+	): Promise<CacheGroupQueueResponse>;
+	/**
+	 * Queues (or dequeues) updates on a Cache Group's servers.
+	 *
+	 * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+	 * just its ID.
+	 * @param request The full (de/)queue request.
+	 * @returns The API's response.
+	 */
+	public async queueCacheGroupUpdates(
+		cacheGroupOrID: ResponseCacheGroup | number,
+		request: CacheGroupQueueRequest
+	): Promise<CacheGroupQueueResponse>;
+	/**
+	 * Queues (or dequeues) updates on a Cache Group's servers.
+	 *
+	 * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+	 * just its ID.
+	 * @param cdnOrIdentifierOrRequest Either the full (de/)queue request or a
+	 * CDN, its name, or its ID.
+	 * @param action If `cdnOrIdentifierOrRequest` is not a full (de/)queue
+	 * request, then this will be used to determine the queue action to take. If
+	 * not given, defaults to `queue`.
+	 * @returns The API's response.
+	 */
+	public async queueCacheGroupUpdates(
+		cacheGroupOrID: ResponseCacheGroup | number,
+		cdnOrIdentifierOrRequest: CacheGroupQueueRequest | CDN | string | number,
+		action?: "queue" | "dequeue"
+	): Promise<CacheGroupQueueResponse> {
+		const cachegroupID = typeof(cacheGroupOrID) === "number" ? cacheGroupOrID : cacheGroupOrID.id;
+		const cg = this.cacheGroups.find(c => c.id === cachegroupID);
+		if (!cg) {
+			throw new Error(`no such Cache Group: #${cachegroupID}`);
+		}
+
+		let cdn;
+		if (isRequest(cdnOrIdentifierOrRequest)) {
+			action = cdnOrIdentifierOrRequest.action;
+			cdn = cdnOrIdentifierOrRequest.cdn ?? cdnOrIdentifierOrRequest.cdnId;
+		} else {
+			action = action ?? "queue";
+			switch (typeof(cdnOrIdentifierOrRequest)) {
+				case "string":
+				case "number":
+					cdn = cdnOrIdentifierOrRequest;
+					break;
+				default:
+					cdn = cdnOrIdentifierOrRequest.name;
+			}
+		}
+		const updPendingValue = action === "queue";
+		const serverNames = [];
+		for (const server of this.servers.servers) {
+			if (server.cachegroupId === cachegroupID && (server.cdnId === cdn || server.cdnName === cdn)) {
+				server.updPending = updPendingValue;
+				serverNames.push(server.hostName);
+			}
+		}
+		return {
+			action,
+			cachegroupID,
+			cachegroupName: cg.name,
+			cdn: String(cdn),
+			serverNames,
+		};
+	}
+
 	public async getDivisions(): Promise<Array<ResponseDivision>>;
 	public async getDivisions(nameOrID: string | number): Promise<ResponseDivision>;
 
diff --git a/experimental/traffic-portal/src/app/api/testing/cdn.service.ts b/experimental/traffic-portal/src/app/api/testing/cdn.service.ts
index 31930bab2e..7ccf4134b6 100644
--- a/experimental/traffic-portal/src/app/api/testing/cdn.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/cdn.service.ts
@@ -11,9 +11,9 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-import { Injectable } from "@angular/core";
 
-import { CDN } from "src/app/models";
+import { Injectable } from "@angular/core";
+import { ResponseCDN } from "trafficops-types";
 
 /**
  * CDNService expose API functionality relating to CDNs.
@@ -21,31 +21,25 @@ import { CDN } from "src/app/models";
 @Injectable()
 export class CDNService {
 
-	private readonly cdns = new Map([
-		[
-			"ALL",
-			{
-				dnssecEnabled: false,
-				domainName: "-",
-				id: 1,
-				lastUpdated: new Date(),
-				name: "ALL"
-			}
-		],
-		[
-			"test",
-			{
-				dnssecEnabled: false,
-				domainName: "mycdn.test.test",
-				id: 2,
-				lastUpdated: new Date(),
-				name: "test"
-			}
-		]
-	]);
+	private readonly cdns = [
+		{
+			dnssecEnabled: false,
+			domainName: "-",
+			id: 1,
+			lastUpdated: new Date(),
+			name: "ALL"
+		},
+		{
+			dnssecEnabled: false,
+			domainName: "mycdn.test.test",
+			id: 2,
+			lastUpdated: new Date(),
+			name: "test"
+		}
+	];
 
-	public async getCDNs(id: number): Promise<CDN>;
-	public async getCDNs(): Promise<Map<string, CDN>>;
+	public async getCDNs(id: number): Promise<ResponseCDN>;
+	public async getCDNs(): Promise<Array<ResponseCDN>>;
 	/**
 	 * Gets one or all CDNs from Traffic Ops
 	 *
@@ -54,7 +48,7 @@ export class CDNService {
 	 * 	passed.
 	 * (In the event that `id` is passed but does not match any CDN, `null` will be emitted)
 	 */
-	public async getCDNs(id?: number): Promise<Map<string, CDN> | CDN> {
+	public async getCDNs(id?: number): Promise<Array<ResponseCDN> | ResponseCDN> {
 		if (id !== undefined) {
 			const cdn = Array.from(this.cdns.values()).filter(c=>c.id===id)[0];
 			if (!cdn) {
diff --git a/experimental/traffic-portal/src/app/api/testing/index.ts b/experimental/traffic-portal/src/app/api/testing/index.ts
index dd9a702ffa..d73f26a05b 100644
--- a/experimental/traffic-portal/src/app/api/testing/index.ts
+++ b/experimental/traffic-portal/src/app/api/testing/index.ts
@@ -58,7 +58,8 @@ import { UserService as TestingUserService } from "./user.service";
 		{provide: ProfileService, useClass: TestingProfileService},
 		{provide: ServerService, useClass: TestingServerService},
 		{provide: TypeService, useClass: TestingTypeService},
-		{provide: UserService, useClass: TestingUserService}
+		{provide: UserService, useClass: TestingUserService},
+		TestingServerService
 	]
 })
 export class APITestingModule { }
diff --git a/experimental/traffic-portal/src/app/api/testing/server.service.ts b/experimental/traffic-portal/src/app/api/testing/server.service.ts
index e5cc127d0a..4e2f765cbe 100644
--- a/experimental/traffic-portal/src/app/api/testing/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/server.service.ts
@@ -44,7 +44,7 @@ function serverCheck(server: Server): Servercheck {
 @Injectable()
 export class ServerService {
 
-	private readonly servers = new Array<Server>();
+	public servers = new Array<Server>();
 
 	private readonly statuses = [
 		{
diff --git a/experimental/traffic-portal/src/app/api/type.service.ts b/experimental/traffic-portal/src/app/api/type.service.ts
index 94c8e26c16..ff409a6ca3 100644
--- a/experimental/traffic-portal/src/app/api/type.service.ts
+++ b/experimental/traffic-portal/src/app/api/type.service.ts
@@ -13,6 +13,7 @@
 */
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
+import type { TypeFromResponse } from "trafficops-types";
 
 import type { Type } from "src/app/models";
 
@@ -78,8 +79,8 @@ export class TypeService extends APIService {
 	 * @param useInTable The database table for which to retrieve Types.
 	 * @returns The requested Types.
 	 */
-	public async getTypesInTable(useInTable: UseInTable): Promise<Array<Type>> {
-		return this.get<Array<Type>>("types", undefined, {useInTable}).toPromise().catch(
+	public async getTypesInTable(useInTable: UseInTable): Promise<Array<TypeFromResponse>> {
+		return this.get<Array<TypeFromResponse>>("types", undefined, {useInTable}).toPromise().catch(
 			(e) => {
 				console.error("Failed to get Types:", e);
 				return [];
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.html b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.html
new file mode 100644
index 0000000000..3f4ceb6025
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.html
@@ -0,0 +1,87 @@
+<!--
+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.
+-->
+<mat-card>
+	<tp-loading *ngIf="!cacheGroup"></tp-loading>
+	<form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="cacheGroup">
+		<mat-card-content>
+			<mat-form-field>
+				<mat-label>Name</mat-label>
+				<input matInput type="text" name="name" [(ngModel)]="cacheGroup.name" required/>
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Type</mat-label>
+				<mat-select name="type" [formControl]="typeCtrl" required (selectionChange)="showErrors = false">
+					<mat-option *ngFor="let type of types" [value]="type.id">{{type.name}}</mat-option>
+				</mat-select>
+				<mat-hint *ngIf="selectedTypeDescription">{{selectedTypeDescription}}</mat-hint>
+				<mat-error *ngIf="showErrors && typeCtrl.invalid">Required</mat-error>
+			</mat-form-field>
+			<mat-form-field *ngIf="!new">
+				<mat-label>ID</mat-label>
+				<input matInput type="text" name="id" disabled readonly [defaultValue]="cacheGroup.id" />
+			</mat-form-field>
+			<mat-form-field *ngIf="!new">
+				<mat-label>Last Updated</mat-label>
+				<input matInput type="text" name="lastUpdated" disabled readonly [defaultValue]="cacheGroup.lastUpdated" />
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Parent</mat-label>
+				<mat-select [(ngModel)]="cacheGroup.parentCachegroupId" name="parentCacheGroup">
+					<mat-option [value]="null">-- None --</mat-option>
+					<mat-optgroup label="Cache Groups">
+						<mat-option *ngFor="let cg of parentCacheGroups()" [value]="cg.id">{{cg.name}}</mat-option>
+					</mat-optgroup>
+				</mat-select>
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Secondary Parent</mat-label>
+				<mat-select [(ngModel)]="cacheGroup.secondaryParentCachegroupId" name="secondaryParentCacheGroup">
+					<mat-option [value]="null">-- None --</mat-option>
+					<mat-optgroup label="Cache Groups">
+						<mat-option *ngFor="let cg of secondaryParentCacheGroups()" [value]="cg.id">{{cg.name}}</mat-option>
+					</mat-optgroup>
+				</mat-select>
+			</mat-form-field>
+			<div class="pair">
+				<mat-form-field>
+					<mat-label>Latitude</mat-label>
+					<input type="number" matInput min="-90" max="90" step="0.001" name="latitude" [(ngModel)]="cacheGroup.latitude" required/>
+				</mat-form-field>
+				<mat-form-field>
+					<mat-label>Longitude</mat-label>
+					<input type="number" matInput min="-180" max="180" step="0.001" name="longitude" [(ngModel)]="cacheGroup.longitude" required/>
+				</mat-form-field>
+			</div>
+			<mat-form-field>
+				<mat-label>Allowed Client Localization Methods</mat-label>
+				<mat-select name="localizationMethods" multiple required [(ngModel)]="cacheGroup.localizationMethods" (selectionChange)="updateLocalizationMethods()">
+					<mat-option *ngFor="let method of localizationMethods" [value]="method">{{localizationMethodToString(method)}}</mat-option>
+				</mat-select>
+			</mat-form-field>
+			<div class="pair">
+				<mat-form-field>
+					<mat-label>Fallbacks</mat-label>
+					<mat-select name="fallbacks" multiple [(ngModel)]="cacheGroup.fallbacks">
+						<mat-option *ngFor="let cg of fallbacks()">{{cg.name}}</mat-option>
+					</mat-select>
+				</mat-form-field>
+				<mat-checkbox name="fallbackToClosest" [(ngModel)]="cacheGroup.fallbackToClosest">Fall back to closest on localization failure</mat-checkbox>
+			</div>
+		</mat-card-content>
+		<mat-card-actions align="end">
+			<button mat-raised-button type="button" *ngIf="!new" color="warn" (click)="delete()">Delete</button>
+			<button mat-raised-button type="submit" color="primary">Save</button>
+		</mat-card-actions>
+	</form>
+</mat-card>
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss
new file mode 100644
index 0000000000..7b4f84436e
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss
@@ -0,0 +1,64 @@
+/*
+* 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.
+*/
+
+form {
+	max-width: calc(100% - 32px);
+}
+
+mat-card {
+	margin: 1em auto;
+	box-sizing: border-box;
+	width: 80%;
+	min-width: 350px;
+
+	mat-card-content {
+		display: grid;
+		grid-template-columns: 1fr;
+		row-gap: 2em;
+		margin: 1em auto 50px;
+
+		.pair {
+			display: grid;
+			grid-template-columns: 1fr 1fr;
+			column-gap: 48px;
+			align-items: center;
+			justify-content: space-between;
+		}
+	}
+}
+
+/* Chosen more or less arbitrarily. */
+@media(max-width:1200px) {
+	mat-card form mat-card-content .pair {
+		column-gap: 8px;
+	}
+}
+
+/* This is roughly the point where 350px is 80% of the viewport. */
+@media(max-width:440px) {
+	mat-card {
+		min-width: unset;
+		width: 100%;
+		form {
+			min-width: 100%;
+			mat-card-content {
+				margin: 1em auto;
+				.pair {
+					grid-template-columns: 1fr;
+					row-gap: 2em;
+				}
+			}
+		}
+	}
+}
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.spec.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.spec.ts
new file mode 100644
index 0000000000..8497ee98a2
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.spec.ts
@@ -0,0 +1,299 @@
+/*
+* 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.
+*/
+
+import { HarnessLoader } from "@angular/cdk/testing";
+import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatButtonHarness } from "@angular/material/button/testing";
+import { MatDialogModule } from "@angular/material/dialog";
+import { MatDialogHarness } from "@angular/material/dialog/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { ReplaySubject } from "rxjs";
+
+import { CacheGroupService, TypeService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
+
+import { CacheGroupDetailsComponent } from "./cache-group-details.component";
+
+describe("CacheGroupDetailsComponent", () => {
+	let component: CacheGroupDetailsComponent;
+	let fixture: ComponentFixture<CacheGroupDetailsComponent>;
+	let route: ActivatedRoute;
+	let paramMap: jasmine.Spy;
+	let loader: HarnessLoader;
+	let cgSrv: CacheGroupService;
+
+	const headerSvc = jasmine.createSpyObj([],{headerHidden: new ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [ CacheGroupDetailsComponent ],
+			imports: [
+				APITestingModule,
+				RouterTestingModule.withRoutes([
+					{
+						component: CacheGroupDetailsComponent,
+						path: ""
+					},
+					{
+						component: CacheGroupDetailsComponent,
+						path: "cache-groups/:id"
+					}
+				]),
+				MatDialogModule,
+				NoopAnimationsModule,
+
+			],
+			providers: [ { provide: TpHeaderService, useValue: headerSvc } ]
+		}).compileComponents();
+
+		route = TestBed.inject(ActivatedRoute);
+		paramMap = spyOn(route.snapshot.paramMap, "get");
+		paramMap.and.returnValue(null);
+		fixture = TestBed.createComponent(CacheGroupDetailsComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
+		cgSrv = TestBed.inject(CacheGroupService);
+	});
+
+	it("should create", () => {
+		expect(component).toBeTruthy();
+	});
+
+	it("new Cache Group", async () => {
+		paramMap.and.returnValue("new");
+
+		fixture = TestBed.createComponent(CacheGroupDetailsComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		await fixture.whenStable();
+		expect(paramMap).toHaveBeenCalled();
+		expect(component.cacheGroup).not.toBeNull();
+		expect(component.cacheGroup.name).toBe("");
+		expect(component.new).toBeTrue();
+	});
+
+	it("existing Cache Group", async () => {
+		const cgs = await cgSrv.getCacheGroups();
+		if (cgs.length < 1) {
+			return fail("no testing Cache Groups - please add Cache Groups to the default set or fix the accidental deletion thereof");
+		}
+		const cg = cgs[0];
+		paramMap.and.returnValue(String(cg.id));
+
+		fixture = TestBed.createComponent(CacheGroupDetailsComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		await fixture.whenStable();
+		await component.ngOnInit();
+		expect(paramMap).toHaveBeenCalled();
+		expect(component.cacheGroup).not.toBeNull();
+		expect(component.cacheGroup.name).toBe(cg.name, component.cacheGroup);
+		expect(component.new).toBeFalse();
+	});
+
+	it("throws an error when the ID in the URL doesn't exist", async () => {
+		paramMap.and.returnValue("-1");
+		// This doesn't actually throw, but for unknown reasons it causes
+		// earlier tests to fail if were to throw. Somehow, the Component is
+		// getting a blank CG list from the testing API, and that should never
+		// be possible.
+		await expectAsync(component.ngOnInit()).toBeResolved();
+		paramMap.and.returnValue("testquest");
+		await expectAsync(component.ngOnInit()).toBeRejected();
+	});
+
+	it("gets available parent Cache Groups", async () => {
+		const cgs = await cgSrv.getCacheGroups();
+		expect(cgs.length).toBeGreaterThan(0);
+		component.cacheGroups = cgs;
+		component.cacheGroup.fallbacks = [`${cgs[0].name}-fallback-test`];
+
+		expect(component.parentCacheGroups()).toEqual(component.cacheGroups);
+		if (component.cacheGroups.length < 1) {
+			return fail("need at least one cache group to test parentage");
+		}
+		const initialLength = component.parentCacheGroups().length;
+		const cg = component.cacheGroups[0];
+		const original = component.cacheGroup;
+		component.cacheGroup = {
+			...component.cacheGroup,
+			secondaryParentCachegroupId: cg.id,
+			secondaryParentCachegroupName: cg.name
+		};
+		expect(component.parentCacheGroups()).not.toContain(cg);
+		expect(component.parentCacheGroups().length).toBe(initialLength-1);
+		component.cacheGroup = original;
+	});
+	it("gets available secondary parent Cache Groups", async () => {
+		const cgs = await cgSrv.getCacheGroups();
+		expect(cgs.length).toBeGreaterThan(0);
+		component.cacheGroups = cgs;
+		component.cacheGroup.fallbacks = [`${cgs[0].name}-fallback-test`];
+
+		expect(component.secondaryParentCacheGroups()).toEqual(component.cacheGroups);
+		if (component.cacheGroups.length < 1) {
+			return fail("need at least one cache group to test parentage");
+		}
+		const initialLength = component.secondaryParentCacheGroups().length;
+		const cg = component.cacheGroups[0];
+		const original = component.cacheGroup;
+		component.cacheGroup = {
+			...component.cacheGroup,
+			parentCachegroupId: cg.id,
+			parentCachegroupName: cg.name
+		};
+		expect(component.secondaryParentCacheGroups()).not.toContain(cg);
+		expect(component.secondaryParentCacheGroups().length).toBe(initialLength-1);
+		component.cacheGroup = original;
+	});
+	it("gets available fallback Cache Groups", async () => {
+		const cgs = await cgSrv.getCacheGroups();
+		expect(cgs.length).toBeGreaterThan(2);
+		component.cacheGroups = cgs;
+		component.cacheGroup.fallbacks = [`${cgs[0].name}-fallback-test`];
+
+		expect(component.fallbacks()).toEqual(component.cacheGroups);
+		if (component.cacheGroups.length < 1) {
+			return fail("need at least one cache group to test parentage");
+		}
+		const initialLength = component.fallbacks().length;
+		const [cg1, cg2] = component.cacheGroups;
+		const original = component.cacheGroup;
+		component.cacheGroup = {
+			...component.cacheGroup,
+			parentCachegroupId: cg1.id,
+			parentCachegroupName: cg1.name,
+			secondaryParentCachegroupId: cg2.id,
+			secondaryParentCachegroupName: cg2.name
+		};
+		const fallbacks = component.fallbacks();
+		expect(fallbacks).not.toContain(cg1);
+		expect(fallbacks).not.toContain(cg2);
+		expect(fallbacks.length).toBe(initialLength-2);
+		component.cacheGroup = original;
+	});
+
+	it("refuses to delete new Cache Groups", async () => {
+		component.new = true;
+		const spy = spyOn(cgSrv, "deleteCacheGroup");
+
+		const asyncExpectation = expectAsync(component.delete()).toBeResolvedTo(undefined);
+		await component.delete();
+		const dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		expect(dialogs.length).toBe(0);
+		expect(spy).not.toHaveBeenCalled();
+
+		await asyncExpectation;
+	});
+	it("deletes existing Cache Groups", async () => {
+		const spy = spyOn(cgSrv, "deleteCacheGroup").and.callThrough();
+		let cgs = await cgSrv.getCacheGroups();
+		const initialLength = cgs.length;
+		if (initialLength < 1) {
+			return fail("need at least one Cache Group");
+		}
+		const cg = cgs[0];
+		component.cacheGroup = cg;
+		component.new = false;
+
+		const asyncExpectation = expectAsync(component.delete()).toBeResolvedTo(undefined);
+		const dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		if (dialogs.length !== 1) {
+			return fail(`failed to open dialog; ${dialogs.length} dialogs found`);
+		}
+		const dialog = dialogs[0];
+		const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/}));
+		if (buttons.length !== 1) {
+			return fail(`'confirm' button not found; ${buttons.length} buttons found`);
+		}
+		await buttons[0].click();
+
+		expect(spy).toHaveBeenCalledOnceWith(cg);
+
+		cgs = await cgSrv.getCacheGroups();
+		expect(cgs).not.toContain(cg);
+		expect(cgs.length).toBe(initialLength - 1);
+
+		await asyncExpectation;
+	});
+
+	it("creates new Cache Groups", async () => {
+		const createSpy = spyOn(cgSrv, "createCacheGroup").and.callThrough();
+		const updateSpy = spyOn(cgSrv, "updateCacheGroup").and.callThrough();
+
+		component.new = true;
+		const cg = component.cacheGroup;
+		const typeSrv = TestBed.inject(TypeService);
+		const types = await typeSrv.getTypesInTable("cachegroup");
+		if (types.length < 1) {
+			return fail("no cg Types");
+		}
+		component.typeCtrl.setValue(types[0].id);
+		await expectAsync(component.submit(new Event("click"))).toBeResolvedTo(undefined);
+		expect(createSpy).toHaveBeenCalledOnceWith(cg);
+		expect(updateSpy).not.toHaveBeenCalled();
+		expect(component.new).toBeFalse();
+	});
+
+	it("updates existing Cache Groups", async () => {
+		const createSpy = spyOn(cgSrv, "createCacheGroup").and.callThrough();
+		component.new = false;
+		const cg = component.cacheGroup;
+		const typeSrv = TestBed.inject(TypeService);
+		const types = await typeSrv.getTypesInTable("cachegroup");
+		if (types.length < 1) {
+			return fail("no cg Types");
+		}
+		const updateSpy = spyOn(cgSrv, "updateCacheGroup").and.returnValue(new Promise(r => r(component.cacheGroup)));
+		component.typeCtrl.setValue(types[0].id);
+		await expectAsync(component.submit(new Event("click"))).toBeResolvedTo(undefined);
+		expect(updateSpy).toHaveBeenCalledOnceWith(cg);
+		expect(createSpy).not.toHaveBeenCalled();
+		expect(component.new).toBeFalse();
+	});
+
+	it("doesn't submit a request when the form is invalid", async () => {
+		const createSpy = spyOn(cgSrv, "createCacheGroup").and.callThrough();
+		const updateSpy = spyOn(cgSrv, "updateCacheGroup").and.callThrough();
+
+		component.typeCtrl.setErrors({something: true});
+		await expectAsync(component.submit(new Event("click"))).toBeResolvedTo(undefined);
+		expect(updateSpy).not.toHaveBeenCalled();
+		expect(createSpy).not.toHaveBeenCalled();
+
+		component.typeCtrl.setErrors(null);
+		component.typeCtrl.setValue(null);
+		await expectAsync(component.submit(new Event("click"))).toBeResolvedTo(undefined);
+		expect(updateSpy).not.toHaveBeenCalled();
+		expect(createSpy).not.toHaveBeenCalled();
+	});
+
+	it("gets Type descriptions", async () => {
+		const typeSrv = TestBed.inject(TypeService);
+		const types = await typeSrv.getTypesInTable("cachegroup");
+		if (types.length < 1) {
+			return fail("no cg Types");
+		}
+		// Unsure why this is necessary
+		component.types = types;
+		component.typeCtrl.setValue(types[0].id);
+		expect(component.selectedTypeDescription).toBe(types[0].description);
+		component.typeCtrl.setValue(null);
+		expect(component.selectedTypeDescription).toBeNull();
+	});
+});
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
new file mode 100644
index 0000000000..23c89f04dd
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
@@ -0,0 +1,239 @@
+/*
+* 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.
+*/
+
+import { Location } from "@angular/common";
+import { Component, type OnInit } from "@angular/core";
+import { FormControl } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { LocalizationMethod, localizationMethodToString, TypeFromResponse, type ResponseCacheGroup } from "trafficops-types";
+
+import { CacheGroupService, TypeService } from "src/app/api";
+import { DecisionDialogComponent, type DecisionDialogData } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
+
+/**
+ * The controller for the form page for creating/updating a Cache Group.
+ */
+@Component({
+	selector: "tp-cache-group-details",
+	styleUrls: ["./cache-group-details.component.scss"],
+	templateUrl: "./cache-group-details.component.html",
+})
+export class CacheGroupDetailsComponent implements OnInit {
+	public new = false;
+	public cacheGroup: ResponseCacheGroup = {
+		fallbackToClosest: true,
+		fallbacks: [],
+		id: -1,
+		lastUpdated: new Date(),
+		latitude: 0,
+		localizationMethods: [
+			LocalizationMethod.CZ,
+			LocalizationMethod.DEEP_CZ,
+			LocalizationMethod.GEO
+		],
+		longitude: 0,
+		name: "",
+		parentCachegroupId: null,
+		parentCachegroupName: null,
+		secondaryParentCachegroupId: null,
+		secondaryParentCachegroupName: null,
+		shortName: "",
+		typeId: -1,
+		typeName: ""
+	};
+
+	public cacheGroups: Array<ResponseCacheGroup> = [];
+	public types: Array<TypeFromResponse> = [];
+	public typeCtrl = new FormControl<number | null>(null);
+	public showErrors = false;
+
+	public readonly localizationMethods: readonly LocalizationMethod[] = [
+		LocalizationMethod.CZ,
+		LocalizationMethod.DEEP_CZ,
+		LocalizationMethod.GEO
+	];
+
+	/**
+	 * A description of the Cache Group's selected Type - or `null` if no Type
+	 * is (yet) selected.
+	 */
+	public get selectedTypeDescription(): string | null {
+		const type = this.types.find(t => t.id === this.typeCtrl.value);
+		if (!type) {
+			return null;
+		}
+		return type.description;
+	}
+
+	constructor(
+		private readonly route: ActivatedRoute,
+		private readonly api: CacheGroupService,
+		private readonly typesAPI: TypeService,
+		private readonly location: Location,
+		private readonly dialog: MatDialog,
+		private readonly header: TpHeaderService
+	) {
+	}
+
+	public localizationMethodToString = localizationMethodToString;
+
+	/**
+	 * Angular lifecycle hook where data is initialized.
+	 */
+	public async ngOnInit(): Promise<void> {
+		const ID = this.route.snapshot.paramMap.get("id");
+		if (ID === null) {
+			console.error("missing required route parameter 'id'");
+			return;
+		}
+
+		const cgsPromise = this.api.getCacheGroups().then(cgs => this.cacheGroups = cgs);
+		const typePromise = this.typesAPI.getTypesInTable("cachegroup").then(ts => this.types = ts);
+		if (ID === "new") {
+			this.setTitle();
+			this.new = true;
+			await Promise.all([typePromise, cgsPromise]);
+			return;
+		}
+		const numID = parseInt(ID, 10);
+		if (Number.isNaN(numID)) {
+			throw new Error(`route parameter 'id' was non-number: ${ID}`);
+		}
+
+		await cgsPromise;
+		const idx = this.cacheGroups.findIndex(c => c.id === numID);
+		if (idx < 0) {
+			console.error(`no such Cache Group: #${ID}`);
+			return;
+		}
+		this.cacheGroup = this.cacheGroups.splice(idx, 1)[0];
+		this.typeCtrl.setValue(this.cacheGroup.typeId);
+		this.updateLocalizationMethods();
+		this.setTitle();
+		await typePromise;
+	}
+
+	/**
+	 * Sets the title of the page to either "new" or the name of the displayed
+	 * Cache Group, depending on the value of
+	 * {@link CacheGroupDetailsComponent.new}.
+	 */
+	private setTitle(): void {
+		const title = this.new ? "New Cache Group" : `Cache Group: ${this.cacheGroup.name}`;
+		this.header.headerTitle.next(title);
+	}
+
+	/**
+	 * Gets all Cache Groups eligible to be the parent of this Cache Group.
+	 *
+	 * @returns Every Cache Group except this one and its secondary parent (if
+	 * it has one) and any of its "fallbacks".
+	 */
+	public parentCacheGroups(): Array<ResponseCacheGroup> {
+		return this.cacheGroups.filter(
+			cg => this.cacheGroup.fallbacks.every(f => f !== cg.name) && cg.id !== this.cacheGroup.secondaryParentCachegroupId
+		);
+	}
+
+	/**
+	 * Gets all Cache Groups eligible to be the secondary parent of this Cache
+	 * Group.
+	 *
+	 * @returns Every Cache Group except this one and its primary parent (if it
+	 * has one) and any of its "fallbacks".
+	 */
+	public secondaryParentCacheGroups(): Array<ResponseCacheGroup> {
+		return this.cacheGroups.filter(
+			cg => this.cacheGroup.fallbacks.every(f => f !== cg.name) && cg.id !== this.cacheGroup.parentCachegroupId
+		);
+	}
+
+	/**
+	 * Gets all Cache Groups eligible to be a "fallback" for this Cache Group.
+	 *
+	 * @returns Every Cache Group except this one and its parent(s).
+	 */
+	public fallbacks(): Array<ResponseCacheGroup> {
+		return this.cacheGroups.filter(
+			cg => cg.id !== this.cacheGroup.parentCachegroupId && cg.id !== this.cacheGroup.secondaryParentCachegroupId
+		);
+	}
+
+	/**
+	 * Deletes the Cache Group.
+	 */
+	public async delete(): Promise<void> {
+		if (this.new) {
+			console.error("Unable to delete new Cache Group");
+			return;
+		}
+		const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(
+			DecisionDialogComponent,
+			{
+				data: {
+					message: `Are you sure you want to delete Cache Group ${this.cacheGroup.name} (#${this.cacheGroup.id})?`,
+					title: "Confirm Delete"
+				}
+			}
+		);
+		ref.afterClosed().subscribe(result => {
+			if (result) {
+				this.api.deleteCacheGroup(this.cacheGroup);
+				this.location.replaceState("core/cache-groups");
+			}
+		});
+	}
+
+	/**
+	 * Submits new/updated Cache Group.
+	 *
+	 * @param e HTML form submission event.
+	 */
+	public async submit(e: Event): Promise<void> {
+		e.preventDefault();
+		e.stopPropagation();
+		this.showErrors = true;
+		if (this.typeCtrl.invalid) {
+			return;
+		}
+		const {value} = this.typeCtrl;
+		if (value === null) {
+			return console.error("cannot create Cache Group of null Type");
+		}
+		this.cacheGroup.typeId = value;
+		this.cacheGroup.shortName = this.cacheGroup.name;
+		if (this.new) {
+			this.cacheGroup = await this.api.createCacheGroup(this.cacheGroup);
+			this.new = false;
+		} else {
+			this.cacheGroup = await this.api.updateCacheGroup(this.cacheGroup);
+		}
+		this.setTitle();
+	}
+
+	/**
+	 * Updates the localization methods of the Cache Group based on user
+	 * selection.
+	 *
+	 * Specifically, selecting none is not allowed, so this will change to
+	 * select all available methods if none are selected.
+	 */
+	public updateLocalizationMethods(): void {
+		if (this.cacheGroup.localizationMethods.length === 0) {
+			this.cacheGroup.localizationMethods = [...this.localizationMethods];
+		}
+	}
+}
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.html b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.html
index 500304b1a9..eff44869e3 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.html
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.html
@@ -13,7 +13,7 @@ limitations under the License.
 -->
 <mat-card class="table-page-content">
 	<div class="search-container">
-		<input type="search" name="fuzzControl" aria-label="Fuzzy Search Servers" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()"/>
+		<input type="search" name="fuzzControl" aria-label="Fuzzy Search Cache Groups" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()"/>
 	</div>
 	<tp-generic-table
 		[data]="cacheGroups | async"
@@ -24,3 +24,5 @@ limitations under the License.
 		(contextMenuAction)="handleContextMenu($event)">
 	</tp-generic-table>
 </mat-card>
+
+<button class="page-fab" mat-fab title="Create a new Cache Group" *ngIf="auth.hasPermission('CACHE-GROUP:CREATE')" routerLink="new"><mat-icon>add</mat-icon></button>
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.spec.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.spec.ts
index e44e7c142e..044c162f6f 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.spec.ts
@@ -11,20 +11,53 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
+import { HarnessLoader } from "@angular/cdk/testing";
+import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
 import { HttpClientModule } from "@angular/common/http";
-import { type ComponentFixture, TestBed, fakeAsync } from "@angular/core/testing";
+import { type ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
 import { ReactiveFormsModule } from "@angular/forms";
+import { MatButtonHarness } from "@angular/material/button/testing";
+import { MatDialogModule } from "@angular/material/dialog";
+import { MatDialogHarness } from "@angular/material/dialog/testing";
+import { MatSelectModule } from "@angular/material/select";
+import { MatSelectHarness } from "@angular/material/select/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { Router } from "@angular/router";
 import { RouterTestingModule } from "@angular/router/testing";
-import {ReplaySubject} from "rxjs";
+import type { ValueFormatterParams, ValueGetterParams } from "ag-grid-community";
+import { ReplaySubject } from "rxjs";
+import { AlertLevel, LocalizationMethod, localizationMethodToString, type ResponseCacheGroup } from "trafficops-types";
 
+import { CacheGroupService, CDNService } from "src/app/api";
 import { APITestingModule } from "src/app/api/testing";
+import { AlertService } from "src/app/shared/alert/alert.service";
+import { isAction } from "src/app/shared/generic-table/generic-table.component";
 import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
 
 import { CacheGroupTableComponent } from "./cache-group-table.component";
 
+const sampleCG: ResponseCacheGroup = {
+	fallbackToClosest: true,
+	fallbacks: [],
+	id: 1,
+	lastUpdated: new Date(),
+	latitude: 0,
+	localizationMethods: [],
+	longitude: 0,
+	name: "sample",
+	parentCachegroupId: null,
+	parentCachegroupName: null,
+	secondaryParentCachegroupId: null,
+	secondaryParentCachegroupName: null,
+	shortName: "sample",
+	typeId: 1,
+	typeName: "some type"
+};
+
 describe("CacheGroupTableComponent", () => {
 	let component: CacheGroupTableComponent;
 	let fixture: ComponentFixture<CacheGroupTableComponent>;
+	let loader: HarnessLoader;
 
 	const headerSvc = jasmine.createSpyObj([],{headerHidden: new ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
 	beforeEach(async () => {
@@ -34,15 +67,19 @@ describe("CacheGroupTableComponent", () => {
 				APITestingModule,
 				HttpClientModule,
 				ReactiveFormsModule,
-				RouterTestingModule
+				RouterTestingModule,
+				MatDialogModule,
+				NoopAnimationsModule,
+				MatSelectModule
 			],
 			providers: [
-				{ provide: TpHeaderService, useValue: headerSvc}
+				{ provide: TpHeaderService, useValue: headerSvc},
 			]
 		}).compileComponents();
 		fixture = TestBed.createComponent(CacheGroupTableComponent);
 		component = fixture.componentInstance;
 		fixture.detectChanges();
+		loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
 	});
 
 	it("should create", () => {
@@ -58,4 +95,288 @@ describe("CacheGroupTableComponent", () => {
 	it("doesn't throw errors when handling context menu events", () => {
 		expect(()=>component.handleContextMenu({action: "something", data: []})).not.toThrow();
 	});
+
+	it("renders parent cache group cells", () => {
+		const col = component.columnDefs.find(d => d.field === "parentCachegroupName");
+		if (!col) {
+			return fail("parentCachegroupName column not found");
+		}
+		const {valueFormatter} = col;
+		if (typeof(valueFormatter) !== "function") {
+			return fail(`invalid valueFormatter found on parentCachegroupName column definition: ${valueFormatter}`);
+		}
+		let value = valueFormatter({data: sampleCG} as ValueFormatterParams);
+		expect(value).toBe("");
+		value = valueFormatter({data: {...sampleCG, parentCachegroupId: 1, parentCachegroupName: "sample"}} as ValueFormatterParams);
+		expect(value).toBe("sample (#1)");
+	});
+	it("renders secondary parent cache group cells", () => {
+		const col = component.columnDefs.find(d => d.field === "secondaryParentCachegroupName");
+		if (!col) {
+			return fail("secondaryParentCachegroupName column not found");
+		}
+		const {valueFormatter} = col;
+		if (typeof(valueFormatter) !== "function") {
+			return fail(`invalid valueFormatter found on secondaryParentCachegroupName column definition: ${valueFormatter}`);
+		}
+		let value = valueFormatter({data: sampleCG} as ValueFormatterParams);
+		expect(value).toBe("");
+		value = valueFormatter({
+			data: {
+				...sampleCG,
+				secondaryParentCachegroupId: 1,
+				secondaryParentCachegroupName: "sample"
+			}
+		} as ValueFormatterParams);
+		expect(value).toBe("sample (#1)");
+	});
+	it("renders type cells", () => {
+		const col = component.columnDefs.find(d => d.field === "typeName");
+		if (!col) {
+			return fail("type column not found");
+		}
+		const {valueFormatter} = col;
+		if (typeof(valueFormatter) !== "function") {
+			return fail(`invalid valueFormatter found on type column definition: ${valueFormatter}`);
+		}
+		const value = valueFormatter({data: {...sampleCG, typeId: 1, typeName: "sample"}} as ValueFormatterParams);
+		expect(value).toBe("sample (#1)");
+	});
+	it("renders localization methods cells", () => {
+		const col = component.columnDefs.find(d => d.field === "localizationMethods");
+		if (!col) {
+			return fail("localizationMethods column not found");
+		}
+		const {valueGetter} = col;
+		if (typeof(valueGetter) !== "function") {
+			return fail(`invalid valueGetter found on localizationMethods column definition: ${valueGetter}`);
+		}
+		let value: string = valueGetter({data: {...sampleCG, localizationMethods: []}} as ValueGetterParams);
+		let valueArr = value.split(", ");
+		expect(valueArr.length).toBe(3);
+		expect(valueArr).toContain(localizationMethodToString(LocalizationMethod.CZ));
+		expect(valueArr).toContain(localizationMethodToString(LocalizationMethod.DEEP_CZ));
+		expect(valueArr).toContain(localizationMethodToString(LocalizationMethod.GEO));
+		value = valueGetter({data: {...sampleCG, localizationMethods: [LocalizationMethod.CZ]}} as ValueGetterParams);
+		valueArr = value.split(", ");
+		expect(valueArr.length).toBe(1);
+		expect(valueArr).toContain(localizationMethodToString(LocalizationMethod.CZ));
+	});
+
+	it("has context menu links to individual Cache Groups", () => {
+		let menuItem = component.contextMenuItems.find(i => i.name === "Open in New Tab");
+		if (!menuItem) {
+			return fail("'Open in New Tab' context menu item not found");
+		}
+		if (isAction(menuItem) || typeof(menuItem.href) !== "function") {
+			return fail(`invalid 'Open in New Tab' context menu item; either not a link or has a static href: ${menuItem}`);
+		}
+		expect(menuItem.newTab).toBeTrue();
+		expect(menuItem.href({...sampleCG, id: 5})).toBe("core/cache-groups/5");
+
+		menuItem = component.contextMenuItems.find(i => i.name === "Edit");
+		if (!menuItem) {
+			return fail("'Edit' context menu item not found");
+		}
+		if (isAction(menuItem) || typeof(menuItem.href) !== "function") {
+			return fail(`invalid 'Edit' context menu item; either not a link or has a static href: ${menuItem}`);
+		}
+		expect(menuItem.newTab).toBeFalsy();
+		expect(menuItem.href({...sampleCG, id: 5})).toBe("core/cache-groups/5");
+	});
+	it("doesn't allow selection of unimplemented context menu items", () => {
+		let menuItem = component.contextMenuItems.find(i => i.name === "Manage ASNs");
+		if (!menuItem) {
+			return fail("'Manage ASNs' context menu item not found");
+		}
+		if (!isAction(menuItem)) {
+			return fail(`Invalid 'Manage ASNs' context menu item; not an action: ${menuItem}`);
+		}
+		if (typeof(menuItem.disabled) !== "function") {
+			return fail("'Manage ASNs' context menu item should be disabled, but no disabled function is defined");
+		}
+		if (menuItem.multiRow) {
+			expect(menuItem.disabled([sampleCG])).toBeTrue();
+		} else {
+			expect(menuItem.disabled(sampleCG)).toBeTrue();
+		}
+
+		menuItem = component.contextMenuItems.find(i => i.name === "Manage Servers");
+		if (!menuItem) {
+			return fail("'Manage Servers' context menu item not found");
+		}
+		if (!isAction(menuItem)) {
+			return fail(`Invalid 'Manage Servers' context menu item; not an action: ${menuItem}`);
+		}
+		if (typeof(menuItem.disabled) !== "function") {
+			return fail("'Manage Servers' context menu item should be disabled, but no disabled function is defined");
+		}
+		if (menuItem.multiRow) {
+			expect(menuItem.disabled([sampleCG])).toBeTrue();
+		} else {
+			expect(menuItem.disabled(sampleCG)).toBeTrue();
+		}
+
+		menuItem = component.contextMenuItems.find(i => i.name === "Manage Parameters");
+		if (!menuItem) {
+			return fail("'Manage Parameters' context menu item not found");
+		}
+		if (!isAction(menuItem)) {
+			return fail(`Invalid 'Manage Parameters' context menu item; not an action: ${menuItem}`);
+		}
+		if (typeof(menuItem.disabled) !== "function") {
+			return fail("'Manage Parameters' context menu item should be disabled, but no disabled function is defined");
+		}
+		if (menuItem.multiRow) {
+			expect(menuItem.disabled([sampleCG])).toBeTrue();
+		} else {
+			expect(menuItem.disabled(sampleCG)).toBeTrue();
+		}
+	});
+
+	it("initializes from query string parameters", fakeAsync(() => {
+		const router = TestBed.inject(Router);
+		router.navigate([], {queryParams: {search: "testquest"}});
+		component.ngOnInit();
+		tick();
+		expectAsync(component.fuzzySubject.toPromise()).toBeResolvedTo("testquest");
+	}));
+
+	it("deletes Cache Groups", async () => {
+		expect(() => component.handleContextMenu({action: "delete", data: []})).not.toThrow();
+		let dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		expect(dialogs.length).toBe(0);
+
+		const cgSrv = TestBed.inject(CacheGroupService);
+		const spy = spyOn(cgSrv, "deleteCacheGroup");
+
+		component.handleContextMenu({action: "delete", data: sampleCG});
+		dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		if (dialogs.length !== 1) {
+			return fail(`dialog should have opened for deleting, actual number of dialogs: ${dialogs.length}`);
+		}
+		let dialog = dialogs[0];
+		await dialog.close();
+		expect(spy).not.toHaveBeenCalled();
+
+		component.handleContextMenu({action: "delete", data: sampleCG});
+		dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		if (dialogs.length !== 1) {
+			return fail(`dialog should have opened for deleting, actual number of dialogs: ${dialogs.length}`);
+		}
+		dialog = dialogs[0];
+		const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/}));
+		if (buttons.length !== 1) {
+			return fail(`'Confirm' button not found; expected one, got: ${buttons.length}`);
+		}
+		const button = buttons[0];
+		await button.click();
+		expect(spy).toHaveBeenCalled();
+	});
+	it("queues Cache Group updates", async () => {
+		const cgSrv = TestBed.inject(CacheGroupService);
+		const spy = spyOn(cgSrv, "queueCacheGroupUpdates").and.returnValue(
+			new Promise(r => r({
+				action: "queue",
+				cachegroupID: 1,
+				cachegroupName: "testquest",
+				cdn: "doesn't matter",
+				serverNames: ["testquest"],
+			}))
+		);
+		const alertSrv = TestBed.inject(AlertService);
+		const alertSpy = spyOn(alertSrv, "newAlert");
+
+		expect(() => component.handleContextMenu({action: "queue", data: [sampleCG]})).not.toThrow();
+		let dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		expect(dialogs.length).toBe(1);
+		let dialog = dialogs[0];
+		await dialog.close();
+
+		expect(spy).not.toHaveBeenCalled();
+		expect(alertSpy).not.toHaveBeenCalled();
+
+		component.handleContextMenu({action: "queue", data: sampleCG});
+		dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		if (dialogs.length !== 1) {
+			return fail(`dialog should have opened for queuing, actual number of dialogs: ${dialogs.length}`);
+		}
+		dialog = dialogs[0];
+		const selects = await dialog.getAllHarnesses(MatSelectHarness);
+		if (selects.length !== 1) {
+			return fail(`dialog should have contained one select input, got: ${selects.length}`);
+		}
+		const select = selects[0];
+		const cdnSrv = TestBed.inject(CDNService);
+		expect(await cdnSrv.getCDNs()).toHaveSize(2);
+		await select.clickOptions();
+		const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/}));
+		if (buttons.length !== 1) {
+			return fail(`'Confirm' button not found; expected one, got: ${buttons.length}`);
+		}
+		const button = buttons[0];
+		await button.click();
+		expect(spy).toHaveBeenCalled();
+		// Jasmine has trouble with the overload signatures; it thinks you can
+		// only call `newAlerts` with a single `Alert` argument. Otherwise, the
+		// below lines could simply be:
+		// expect(alertSpy).toHaveBeenCalledOnceWith(AlertLevel.SUCCESS, "Queued Updates on 1 server");
+		expect(alertSpy).toHaveBeenCalledTimes(1);
+		const [level, text] = alertSpy.calls.all()[0].args as unknown as [string, string];
+		expect(level).toBe(AlertLevel.SUCCESS);
+		expect(text).toBe("Queued Updates on 1 server");
+	});
+	it("clears Cache Group updates", async () => {
+		const cgSrv = TestBed.inject(CacheGroupService);
+		const spy = spyOn(cgSrv, "queueCacheGroupUpdates").and.returnValue(
+			new Promise(r => r({
+				action: "dequeue",
+				cachegroupID: 1,
+				cachegroupName: "testquest",
+				cdn: "doesn't matter",
+				serverNames: ["testquest"],
+			}))
+		);
+		const alertSrv = TestBed.inject(AlertService);
+		const alertSpy = spyOn(alertSrv, "newAlert");
+
+		expect(() => component.handleContextMenu({action: "dequeue", data: sampleCG})).not.toThrow();
+		let dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		expect(dialogs.length).toBe(1);
+		let dialog = dialogs[0];
+		await dialog.close();
+
+		expect(spy).not.toHaveBeenCalled();
+		expect(alertSpy).not.toHaveBeenCalled();
+
+		component.handleContextMenu({action: "dequeue", data: [sampleCG, sampleCG]});
+		dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		if (dialogs.length !== 1) {
+			return fail(`dialog should have opened for dequeuing, actual number of dialogs: ${dialogs.length}`);
+		}
+		dialog = dialogs[0];
+		const selects = await dialog.getAllHarnesses(MatSelectHarness);
+		if (selects.length !== 1) {
+			return fail(`dialog should have contained one select input, got: ${selects.length}`);
+		}
+		const select = selects[0];
+		const cdnSrv = TestBed.inject(CDNService);
+		expect(await cdnSrv.getCDNs()).toHaveSize(2);
+		await select.clickOptions();
+		const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/}));
+		if (buttons.length !== 1) {
+			return fail(`'Confirm' button not found; expected one, got: ${buttons.length}`);
+		}
+		const button = buttons[0];
+		await button.click();
+		expect(spy).toHaveBeenCalled();
+		// Jasmine has trouble with the overload signatures; it thinks you can
+		// only call `newAlerts` with a single `Alert` argument. Otherwise, the
+		// below lines could simply be:
+		// expect(alertSpy).toHaveBeenCalledOnceWith(AlertLevel.SUCCESS, "Queued Updates on 1 server");
+		expect(alertSpy).toHaveBeenCalledTimes(1);
+		const [level, text] = alertSpy.calls.all()[0].args as unknown as [string, string];
+		expect(level).toBe(AlertLevel.SUCCESS);
+		expect(text).toBe("Cleared Updates on 2 servers");
+	});
 });
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
index fde311adb2..044ace3e9f 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
@@ -13,14 +13,29 @@
 */
 
 import { Component, type OnInit } from "@angular/core";
-import { UntypedFormControl } from "@angular/forms";
+import { FormControl } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
 import { ActivatedRoute } from "@angular/router";
+import type { ColDef } from "ag-grid-community";
 import { BehaviorSubject } from "rxjs";
+import {
+	AlertLevel,
+	LocalizationMethod,
+	localizationMethodToString,
+	type ResponseCacheGroup,
+	type ResponseCDN
+} from "trafficops-types";
 
-import { CacheGroupService } from "src/app/api";
-import type { CacheGroup } from "src/app/models";
+import { CacheGroupService, CDNService } from "src/app/api";
+import { AlertService } from "src/app/shared/alert/alert.service";
+import { CurrentUserService } from "src/app/shared/currentUser/current-user.service";
+import {
+	CollectionChoiceDialogComponent,
+	type CollectionChoiceDialogData
+} from "src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component";
+import { DecisionDialogComponent, type DecisionDialogData } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
 import type { ContextMenuActionEvent, ContextMenuItem } from "src/app/shared/generic-table/generic-table.component";
-import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
+import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
 
 /**
  * CacheGroupTableComponent is the controller for the "Cache Groups" table.
@@ -33,10 +48,13 @@ import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
 export class CacheGroupTableComponent implements OnInit {
 
 	/** All of the servers which should appear in the table. */
-	public readonly cacheGroups: Promise<Array<CacheGroup>>;
+	public cacheGroups: Promise<Array<ResponseCacheGroup>>;
+
+	/** All of the CDNs (on which a user might (de/)queue updates). */
+	public readonly cdns: Promise<Array<ResponseCDN>>;
 
 	/** Definitions of the table's columns according to the ag-grid API */
-	public columnDefs = [
+	public columnDefs: ColDef[] = [
 		{
 			field: "fallbackToClosest",
 			filter: "tpBooleanFilter",
@@ -55,6 +73,20 @@ export class CacheGroupTableComponent implements OnInit {
 			headerName: "Last Updated",
 			hide: false
 		},
+		{
+			field: "localizationMethods",
+			headerName: "Enabled Localization Methods",
+			hide: true,
+			valueGetter: ({data}: {data: ResponseCacheGroup}): string => {
+				let methods;
+				if (data.localizationMethods.length > 0) {
+					methods = data.localizationMethods;
+				} else {
+					methods = [LocalizationMethod.CZ, LocalizationMethod.DEEP_CZ, LocalizationMethod.GEO];
+				}
+				return methods.map(localizationMethodToString).join(", ");
+			},
+		},
 		{
 			field: "latitude",
 			filter: "agNumberColumnFilter",
@@ -73,14 +105,22 @@ export class CacheGroupTableComponent implements OnInit {
 			hide: true,
 		},
 		{
-			field: "parentCacheGroupName",
+			field: "parentCachegroupName",
 			headerName: "Parent",
-			hide: false
+			hide: false,
+			valueFormatter: (
+				{data}: {data: ResponseCacheGroup}
+			): string => data.parentCachegroupId === null ? "" : `${data.parentCachegroupName} (#${data.parentCachegroupId})`,
 		},
 		{
-			field: "secondaryParentCacheGroupName",
+			field: "secondaryParentCachegroupName",
 			headerName: "Secondary Parent",
 			hide: true,
+			valueFormatter: (
+				{data}: {data: ResponseCacheGroup}
+			): string => data.secondaryParentCachegroupId === null ?
+				"" :
+				`${data.secondaryParentCachegroupName} (#${data.secondaryParentCachegroupId})`,
 		},
 		{
 			field: "shortName",
@@ -90,14 +130,23 @@ export class CacheGroupTableComponent implements OnInit {
 		{
 			field: "typeName",
 			headerName: "Type",
-			hide: false
+			hide: false,
+			valueFormatter: ({data}: {data: ResponseCacheGroup}): string => `${data.typeName} (#${data.typeId})`
 		}
 	];
 
-	/** Definitions for the context menu items (which act on augmented cache-group data). */
-	public contextMenuItems: Array<ContextMenuItem<CacheGroup>> = [
+	/**
+	 * Definitions for the context menu items (which act on augmented
+	 * Cache Group data).
+	 */
+	public contextMenuItems: Array<ContextMenuItem<ResponseCacheGroup>> = [
+		{
+			href: (selectedRow): string => `core/cache-groups/${selectedRow.id}`,
+			name: "Open in New Tab",
+			newTab: true
+		},
 		{
-			action: "edit",
+			href: (selectedRow): string => `core/cache-groups/${selectedRow.id}` ,
 			name: "Edit"
 		},
 		{
@@ -116,28 +165,42 @@ export class CacheGroupTableComponent implements OnInit {
 		},
 		{
 			action: "asns",
+			disabled: (): true => true,
 			name: "Manage ASNs"
 		},
 		{
 			action: "parameters",
+			disabled: (): true => true,
 			name: "Manage Parameters"
 		},
 		{
 			action: "servers",
+			disabled: (): true => true,
 			name: "Manage Servers"
 		}
 	];
 
-	/** A subject that child components can subscribe to for access to the fuzzy search query text */
+	/**
+	 * A subject that child components can subscribe to for access to the fuzzy
+	 * search query text.
+	 */
 	public fuzzySubject: BehaviorSubject<string>;
 
 	/** Form controller for the user search input. */
-	public fuzzControl: UntypedFormControl = new UntypedFormControl("");
+	public fuzzControl: FormControl = new FormControl("");
 
-	constructor(private readonly api: CacheGroupService, private readonly route: ActivatedRoute,
-		private readonly headerSvc: TpHeaderService) {
+	constructor(
+		private readonly api: CacheGroupService,
+		private readonly cdnAPI: CDNService,
+		private readonly route: ActivatedRoute,
+		private readonly headerSvc: TpHeaderService,
+		private readonly dialog: MatDialog,
+		private readonly alerts: AlertService,
+		public readonly auth: CurrentUserService
+	) {
 		this.fuzzySubject = new BehaviorSubject<string>("");
 		this.cacheGroups = this.api.getCacheGroups();
+		this.cdns = this.cdnAPI.getCDNs();
 	}
 
 	/** Initializes table data, loading it from Traffic Ops. */
@@ -149,9 +212,6 @@ export class CacheGroupTableComponent implements OnInit {
 					this.fuzzControl.setValue(decodeURIComponent(search));
 					this.updateURL();
 				}
-			},
-			e => {
-				console.error("Failed to get query parameters:", e);
 			}
 		);
 		this.headerSvc.headerTitle.next("Cache Groups");
@@ -162,13 +222,79 @@ export class CacheGroupTableComponent implements OnInit {
 		this.fuzzySubject.next(this.fuzzControl.value);
 	}
 
+	/**
+	 * Queues or clears updates on a group of Cache Groups.
+	 *
+	 * @param cgs The Cache Groups on which to operate.
+	 * @param queue Whether updates should be queued (`true`) or cleared
+	 * (`false`).
+	 */
+	private async queueUpdates(cgs: ResponseCacheGroup[], queue: boolean = true): Promise<void> {
+		const title = `${queue ? "Queue" : "Clear"} Updates on ${cgs.length === 1 ? cgs[0].name : `${cgs.length} Cache Groups`}`;
+		const data = {
+			collection: (await this.cdns).map(c => ({label: c.name, value: c.id})),
+			hint: "Note that 'ALL' does NOT mean 'all CDNs'!",
+			label: "CDN",
+			message: `Select a CDN to which to limit the ${queue ? "Queuing" : "Clearing"} of Updates.`,
+			title,
+		};
+		const ref = this.dialog.open<CollectionChoiceDialogComponent, CollectionChoiceDialogData<number>, number | false>(
+			CollectionChoiceDialogComponent,
+			{data}
+		);
+		const result = await ref.afterClosed().toPromise();
+		if (typeof(result) === "number") {
+			const responses = await Promise.all(cgs.map(async cg => this.api.queueCacheGroupUpdates(cg, result)));
+			const serverNum = responses.map(r => r.serverNames.length).reduce((n, l) => n+l, 0);
+			// This endpoint returns no alerts at the time of this writing, so
+			// we gotta do it by hand.
+			this.alerts.newAlert(
+				AlertLevel.SUCCESS,
+				`${queue ? "Queued" : "Cleared"} Updates on ${serverNum} server${serverNum === 1 ? "" : "s"}`
+			);
+		}
+	}
+
+	/**
+	 * Asks the user for confirmation before deleting a Cache Group.
+	 *
+	 * @param cg The Cache Group (potentially) being deleted.
+	 */
+	private async delete(cg: ResponseCacheGroup): Promise<void> {
+		const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(DecisionDialogComponent, {
+			data: {
+				message: `Are you sure you want to delete the ${cg.name} Cache Group?`,
+				title: `Delete ${cg.name}`
+			}
+		});
+		if (await ref.afterClosed().toPromise()) {
+			await this.api.deleteCacheGroup(cg);
+			this.cacheGroups = this.api.getCacheGroups();
+		}
+	}
+
 	/**
 	 * Handles a context menu event.
 	 *
 	 * @param a The action selected from the context menu.
 	 */
-	public handleContextMenu(a: ContextMenuActionEvent<CacheGroup>): void {
-		console.log("action:", a);
+	public handleContextMenu(a: ContextMenuActionEvent<ResponseCacheGroup>): void {
+		switch(a.action) {
+			case "queue":
+				this.queueUpdates(Array.isArray(a.data) ? a.data : [a.data]);
+				break;
+			case "dequeue":
+				this.queueUpdates(Array.isArray(a.data) ? a.data : [a.data], false);
+				break;
+			case "delete":
+				if (Array.isArray(a.data)) {
+					console.error("cannot delete multiple cache groups at once:", a.data);
+					return;
+				}
+				this.delete(a.data);
+				break;
+			default:
+				console.error("unrecognized context menu action:", a.action);
+		}
 	}
-
 }
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts
index 2d6179d1b2..f38d21ea95 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -20,18 +20,18 @@ import { CommonModule } from "@angular/common";
 import { NgModule } from "@angular/core";
 import { RouterModule, type Routes } from "@angular/router";
 
-import { DivisionDetailComponent } from "src/app/core/cache-groups/divisions/detail/division-detail.component";
-import { DivisionsTableComponent } from "src/app/core/cache-groups/divisions/table/divisions-table.component";
-import { RegionDetailComponent } from "src/app/core/cache-groups/regions/detail/region-detail.component";
-import { RegionsTableComponent } from "src/app/core/cache-groups/regions/table/regions-table.component";
-import { LastDaysComponent } from "src/app/core/change-logs/last-days/last-days.component";
-
 import { AppUIModule } from "../app.ui.module";
 import { AuthenticatedGuard } from "../guards/authenticated-guard.service";
 import { SharedModule } from "../shared/shared.module";
 
+import { CacheGroupDetailsComponent } from "./cache-groups/cache-group-details/cache-group-details.component";
 import { CacheGroupTableComponent } from "./cache-groups/cache-group-table/cache-group-table.component";
+import { DivisionDetailComponent } from "./cache-groups/divisions/detail/division-detail.component";
+import { DivisionsTableComponent } from "./cache-groups/divisions/table/divisions-table.component";
+import { RegionDetailComponent } from "./cache-groups/regions/detail/region-detail.component";
+import { RegionsTableComponent } from "./cache-groups/regions/table/regions-table.component";
 import { ChangeLogsComponent } from "./change-logs/change-logs.component";
+import { LastDaysComponent } from "./change-logs/last-days/last-days.component";
 import { CurrentuserComponent } from "./currentuser/currentuser.component";
 import { UpdatePasswordDialogComponent } from "./currentuser/update-password-dialog/update-password-dialog.component";
 import { DashboardComponent } from "./dashboard/dashboard.component";
@@ -52,24 +52,25 @@ import { UserRegistrationDialogComponent } from "./users/user-registration-dialo
 import { UsersComponent } from "./users/users.component";
 
 export const ROUTES: Routes = [
-	{ canActivate: [AuthenticatedGuard], component: DashboardComponent, path: "" },
-	{ canActivate: [AuthenticatedGuard], component: DivisionsTableComponent, path: "divisions" },
-	{ canActivate: [AuthenticatedGuard], component: DivisionDetailComponent, path: "division/:id" },
-	{ canActivate: [AuthenticatedGuard], component: RegionsTableComponent, path: "regions" },
-	{ canActivate: [AuthenticatedGuard], component: RegionDetailComponent, path: "region/:id" },
-	{ canActivate: [AuthenticatedGuard], component: UsersComponent, path: "users" },
-	{ canActivate: [AuthenticatedGuard], component: UserDetailsComponent, path: "users/:id"},
-	{ canActivate: [AuthenticatedGuard], component: ServersTableComponent, path: "servers" },
-	{ canActivate: [AuthenticatedGuard], component: ServerDetailsComponent, path: "server/:id" },
-	{ canActivate: [AuthenticatedGuard], component: DeliveryserviceComponent, path: "deliveryservice/:id" },
-	{ canActivate: [AuthenticatedGuard], component: InvalidationJobsComponent, path: "deliveryservice/:id/invalidation-jobs" },
-	{ canActivate: [AuthenticatedGuard], component: CurrentuserComponent, path: "me" },
-	{ canActivate: [AuthenticatedGuard], component: NewDeliveryServiceComponent, path: "new.Delivery.Service" },
-	{ canActivate: [AuthenticatedGuard], component: CacheGroupTableComponent, path: "cache-groups" },
-	{ canActivate: [AuthenticatedGuard], component: TenantsComponent, path: "tenants"},
-	{ canActivate: [AuthenticatedGuard], component: ChangeLogsComponent, path: "change-logs" },
-	{ canActivate: [AuthenticatedGuard], component: TenantDetailsComponent, path: "tenants/:id"}
-];
+	{ component: DashboardComponent, path: "" },
+	{ component: DivisionsTableComponent, path: "divisions" },
+	{ component: DivisionDetailComponent, path: "divisions/:id" },
+	{ component: RegionsTableComponent, path: "regions" },
+	{ component: RegionDetailComponent, path: "regions/:id" },
+	{ component: UsersComponent, path: "users" },
+	{ component: UserDetailsComponent, path: "users/:id"},
+	{ component: ServersTableComponent, path: "servers" },
+	{ component: ServerDetailsComponent, path: "server/:id" },
+	{ component: DeliveryserviceComponent, path: "deliveryservice/:id" },
+	{ component: InvalidationJobsComponent, path: "deliveryservice/:id/invalidation-jobs" },
+	{ component: CurrentuserComponent, path: "me" },
+	{ component: NewDeliveryServiceComponent, path: "new.Delivery.Service" },
+	{ component: CacheGroupTableComponent, path: "cache-groups" },
+	{ component: CacheGroupDetailsComponent, path: "cache-groups/:id"},
+	{ component: TenantsComponent, path: "tenants"},
+	{ component: ChangeLogsComponent, path: "change-logs" },
+	{ component: TenantDetailsComponent, path: "tenants/:id"}
+].map(r => ({...r, canActivate: [AuthenticatedGuard]}));
 
 /**
  * CoreModule contains code that only logged in users will be served.
@@ -99,7 +100,8 @@ export const ROUTES: Routes = [
 		DivisionsTableComponent,
 		DivisionDetailComponent,
 		RegionsTableComponent,
-		RegionDetailComponent
+		RegionDetailComponent,
+		CacheGroupDetailsComponent
 	],
 	exports: [],
 	imports: [
diff --git a/experimental/traffic-portal/src/app/core/dashboard/dashboard.component.ts b/experimental/traffic-portal/src/app/core/dashboard/dashboard.component.ts
index da34f6af93..7325f733c8 100644
--- a/experimental/traffic-portal/src/app/core/dashboard/dashboard.component.ts
+++ b/experimental/traffic-portal/src/app/core/dashboard/dashboard.component.ts
@@ -144,5 +144,4 @@ export class DashboardComponent implements OnInit {
 	public tracker(_: number, d: DeliveryService): number {
 		return d.id || 0;
 	}
-
 }
diff --git a/experimental/traffic-portal/src/app/core/new-delivery-service/new-delivery-service.component.ts b/experimental/traffic-portal/src/app/core/new-delivery-service/new-delivery-service.component.ts
index a3df7a8ae4..584e9b88f3 100644
--- a/experimental/traffic-portal/src/app/core/new-delivery-service/new-delivery-service.component.ts
+++ b/experimental/traffic-portal/src/app/core/new-delivery-service/new-delivery-service.component.ts
@@ -16,11 +16,11 @@ import { Component, type OnInit, ViewChild, Inject } from "@angular/core";
 import { UntypedFormControl } from "@angular/forms";
 import type { MatStepper } from "@angular/material/stepper";
 import { Router } from "@angular/router";
+import type { ResponseCDN } from "trafficops-types";
 
 import { CDNService, DeliveryServiceService } from "src/app/api";
 import {
 	bypassable,
-	type CDN,
 	defaultDeliveryService,
 	type DeliveryService,
 	Protocol,
@@ -104,7 +104,7 @@ export class NewDeliveryServiceComponent implements OnInit {
 	];
 
 	/** The available CDNs from which for the user to choose. */
-	public cdns: Array<CDN> = [];
+	public cdns: Array<ResponseCDN> = [];
 
 	/**
 	 * The available useInTable=delivery_service Types from which for the user
@@ -203,9 +203,9 @@ export class NewDeliveryServiceComponent implements OnInit {
 		}
 		this.cdns = [];
 		let def;
-		for (const [name, cdn] of cdns) {
+		for (const cdn of cdns) {
 			// this is a special, magic-value CDN that can't have any DSes
-			if (name !== "ALL") {
+			if (cdn.name !== "ALL") {
 				this.cdns.push(cdn);
 				if (id > 0) {
 					if (cdn.id === id) {
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
index e0ecdc3eda..ce03b83939 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
@@ -16,11 +16,12 @@ import { Component, OnInit } from "@angular/core";
 import { ActivatedRoute, Router } from "@angular/router";
 import { faClock as hollowClock } from "@fortawesome/free-regular-svg-icons";
 import { faClock, faMinus, faPlus, faToggleOff, faToggleOn, IconDefinition } from "@fortawesome/free-solid-svg-icons";
+import type { ResponseCacheGroup, ResponseCDN } from "trafficops-types";
 
 import { CacheGroupService, CDNService, PhysicalLocationService, ProfileService, TypeService } from "src/app/api";
 import { ServerService } from "src/app/api/server.service";
-import { CacheGroup, CDN, DUMMY_SERVER, Interface, PhysicalLocation, Profile, Server, Status, Type } from "src/app/models";
-import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
+import { DUMMY_SERVER, Interface, PhysicalLocation, Profile, Server, Status, Type } from "src/app/models";
+import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
 import { IP, IP_WITH_CIDR, AutocompleteValue } from "src/app/utils";
 
 /**
@@ -99,11 +100,11 @@ export class ServerDetailsComponent implements OnInit {
 	/**
 	 * The set of all Cache Groups.
 	 */
-	public cacheGroups = new Array<CacheGroup>();
+	public cacheGroups = new Array<ResponseCacheGroup>();
 	/**
 	 * The set of all CDNs.
 	 */
-	public cdns = new Array<CDN>();
+	public cdns = new Array<ResponseCDN>();
 	/**
 	 * The set of all Physical Locations.
 	 */
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
index 794bd7c26c..d3c1f96fac 100644
--- a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
@@ -13,7 +13,7 @@ limitations under the License.
 -->
 <mat-card class="table-page-content">
 	<div class="search-container">
-		<input type="search" name="fuzzControl" aria-label="Fuzzy Search Users" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [(ngModel)]="searchText" (input)="updateURL()"/>
+		<input type="search" name="fuzzControl" aria-label="Fuzzy Search Tenants" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [(ngModel)]="searchText" (input)="updateURL()"/>
 	</div>
 	<tp-generic-table
 		[data]="tenants"
diff --git a/experimental/traffic-portal/src/app/models/cache-groups.ts b/experimental/traffic-portal/src/app/models/cache-groups.ts
deleted file mode 100644
index 7cff57db9a..0000000000
--- a/experimental/traffic-portal/src/app/models/cache-groups.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
-* 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.
-*/
-
-/** LocalizationMethod values are those allowed in the 'localizationMethods' of CacheGroups */
-export const enum LocalizationMethod {
-	/** Coverage Zone file lookup. */
-	CZ = "CZ",
-	/** Deep Coverage Zone file lookup. */
-	DEEP_CZ = "DEEP_CZ",
-	/** Geographic database search. */
-	GEO = "GEO"
-}
-
-/**
- * Converts a LocalizationMethod to a human-readable string.
- *
- * @param l The LocalizationMethod to convert.
- * @returns A textual representation of 'l'.
- */
-export function localizationMethodToString(l: LocalizationMethod): string {
-	switch (l) {
-		case LocalizationMethod.CZ:
-			return "Coverage Zone File";
-		case LocalizationMethod.DEEP_CZ:
-			return "Deep Coverage Zone File";
-		case LocalizationMethod.GEO:
-			return "Geo-IP Database";
-	}
-}
-
-/**
- * Represents a Cache Group.
- *
- * Refer to https://traffic-control-cdn.readthedocs.io/en/latest/overview/cache_groups.html
- */
-export interface CacheGroup {
-	fallbacks: Array<string>;
-	fallbackToClosest: boolean;
-	readonly id?: number;
-	lastUpdated?: Date;
-	latitude: number;
-	localizationMethods: Array<LocalizationMethod>;
-	longitude: number;
-	name: string;
-	parentCacheGroupID: number | null;
-	parentCacheGroupName: string | null;
-	secondaryParentCacheGroupID: number | null;
-	secondaryParentCacheGroupName: string | null;
-	shortName: string;
-	typeId: number;
-	typeName: string;
-}
diff --git a/experimental/traffic-portal/src/app/models/index.ts b/experimental/traffic-portal/src/app/models/index.ts
index d7e35f23d9..508128ca8d 100644
--- a/experimental/traffic-portal/src/app/models/index.ts
+++ b/experimental/traffic-portal/src/app/models/index.ts
@@ -12,8 +12,6 @@
 * limitations under the License.
 */
 export * from "./alert.model";
-export * from "./cache-groups";
-export * from "./cdn";
 export * from "./data";
 export * from "./deliveryservice";
 export * from "./invalidation";
diff --git a/experimental/traffic-portal/src/app/models/models.spec.ts b/experimental/traffic-portal/src/app/models/models.spec.ts
index 0425e14a58..606c625bb7 100644
--- a/experimental/traffic-portal/src/app/models/models.spec.ts
+++ b/experimental/traffic-portal/src/app/models/models.spec.ts
@@ -11,10 +11,6 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-import {
-	LocalizationMethod,
-	localizationMethodToString
-} from "./cache-groups";
 import {
 	bypassable,
 	defaultDeliveryService,
@@ -25,14 +21,6 @@ import {
 } from "./deliveryservice";
 import { checkMap, type Servercheck } from "./server";
 
-describe("Cache Group utilities", () => {
-	it("converts localization methods to human-readable strings", () => {
-		expect(localizationMethodToString(LocalizationMethod.CZ)).toBe("Coverage Zone File");
-		expect(localizationMethodToString(LocalizationMethod.DEEP_CZ)).toBe("Deep Coverage Zone File");
-		expect(localizationMethodToString(LocalizationMethod.GEO)).toBe("Geo-IP Database");
-	});
-});
-
 describe("Delivery Service utilities", () => {
 	it("converts Query String Handlings to human-readable strings", () => {
 		let output = qStringHandlingToString(QStringHandling.USE);
diff --git a/experimental/traffic-portal/src/app/shared/alert/alert.component.scss b/experimental/traffic-portal/src/app/shared/alert/alert.component.scss
index b34042b0cd..ebe77042d3 100644
--- a/experimental/traffic-portal/src/app/shared/alert/alert.component.scss
+++ b/experimental/traffic-portal/src/app/shared/alert/alert.component.scss
@@ -11,37 +11,3 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-
-dialog {
-	border-width: 0;
-	border-radius: 15px;
-	max-width: 80vw;
-	opacity: 0.85;
-	position: fixed;
-	top: 50px;
-	margin: auto;
-	padding-right: 3em;
-	z-index: 102;
-
-	button {
-		position: absolute;
-		text-align: center;
-		height: 100%;
-		right: 0;
-		top: 0;
-		padding: 0 2px 0 0;
-		width: 3em;
-		cursor: pointer;
-		border: none;
-		border-left: 1px solid rgba(64,64,64,0.4);
-		color: #555;
-		background-color: transparent;
-		font-size: 1em;
-		border-top-right-radius: 15px;
-		border-bottom-right-radius: 15px;
-	}
-
-	span {
-		margin-right: 8px;
-	}
-}
diff --git a/experimental/traffic-portal/src/app/shared/alert/alert.service.ts b/experimental/traffic-portal/src/app/shared/alert/alert.service.ts
index ebf36bdd9a..5920ce28e6 100644
--- a/experimental/traffic-portal/src/app/shared/alert/alert.service.ts
+++ b/experimental/traffic-portal/src/app/shared/alert/alert.service.ts
@@ -20,10 +20,12 @@ import type { Alert, AlertLevel } from "src/app/models/alert.model";
  * This class is responsible for populating an alerts Observable that can be
  * subscribed to by the `AlertComponent`.
  */
-@Injectable()
+@Injectable({
+	providedIn: "root"
+})
 export class AlertService {
 	/** A BehaviorSubject that emits Alerts. */
-	public alertsSubject: BehaviorSubject<Alert | null>;
+	private readonly alertsSubject: BehaviorSubject<Alert | null>;
 	/** An Observable that emits Alerts. */
 	public alerts: Observable<Alert | null>;
 
diff --git a/experimental/traffic-portal/src/app/shared/currentUser/current-user.service.ts b/experimental/traffic-portal/src/app/shared/currentUser/current-user.service.ts
index 39b9259b3e..7c687af071 100644
--- a/experimental/traffic-portal/src/app/shared/currentUser/current-user.service.ts
+++ b/experimental/traffic-portal/src/app/shared/currentUser/current-user.service.ts
@@ -26,7 +26,9 @@ import { type Capability, type CurrentUser, ADMIN_ROLE } from "src/app/models";
  * an implicitly injected ErrorInterceptor which clears the authenticated user
  * value when it hits a 401 error - so that would be a circular dependency.
  */
-@Injectable()
+@Injectable({
+	providedIn: "root"
+})
 export class CurrentUserService {
 	/** Makes updateCurrentUser able to be called from multiple places without regard to order */
 	private updatingUserPromise: Promise<boolean> | null = null;
diff --git a/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.html b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.html
new file mode 100644
index 0000000000..b60e1656f9
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.html
@@ -0,0 +1,31 @@
+<!--
+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.
+-->
+
+<h2 mat-dialog-title>{{dialogData.title}}</h2>
+<mat-dialog-content>
+	<p>{{dialogData.message}}</p>
+	<mat-form-field>
+		<label>{{dialogData.label}}</label>
+		<mat-select name="{{dialogData.label}}" [(ngModel)]="selected" required>
+			<mat-option *ngFor="let item of dialogData.collection" [value]="item.value">{{item.label}}</mat-option>
+		</mat-select>
+		<mat-hint *ngIf="dialogData.hint">
+			{{dialogData.hint}}
+		</mat-hint>
+	</mat-form-field>
+</mat-dialog-content>
+<mat-dialog-actions>
+	<button mat-button [disabled]="!selected" [mat-dialog-close]="selected">Confirm</button>
+	<button mat-button [mat-dialog-close]="false">Cancel</button>
+</mat-dialog-actions>
diff --git a/experimental/traffic-portal/src/app/models/cdn.ts b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.scss
similarity index 54%
rename from experimental/traffic-portal/src/app/models/cdn.ts
rename to experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.scss
index 5132bb5bce..2b0b2de999 100644
--- a/experimental/traffic-portal/src/app/models/cdn.ts
+++ b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.scss
@@ -12,20 +12,6 @@
 * limitations under the License.
 */
 
-/**
- * This file is for modeling and functionality related to CDN objects
- */
-
-/**
- * Represents a CDN as exposed by the Traffic Ops API
- */
-export interface CDN {
-	/** Whether or not DNSSEC is enabled within this CDN. */
-	dnssecEnabled: boolean;
-	/** The Top-Level Domain within which the CDN operates. */
-	domainName:    string;
-	/** An integral, unique identifier for the CDN. */
-	id:            number;
-	/** The name of the CDN. */
-	name:          string;
+mat-form-field {
+	width: 100%;
 }
diff --git a/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.spec.ts b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.spec.ts
new file mode 100644
index 0000000000..e16d3b0448
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.spec.ts
@@ -0,0 +1,58 @@
+/*
+ * 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 { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatDialogModule, MAT_DIALOG_DATA } from "@angular/material/dialog";
+
+import { CollectionChoiceDialogComponent, type CollectionChoiceDialogData } from "./collection-choice-dialog.component";
+
+describe("CollectionChoiceDialogComponent", () => {
+	let component: CollectionChoiceDialogComponent;
+	let fixture: ComponentFixture<CollectionChoiceDialogComponent>;
+	const data: CollectionChoiceDialogData = {
+		collection: [],
+		message: "Choose something",
+		title: "Choose"
+	};
+
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [ CollectionChoiceDialogComponent ],
+			imports: [
+				MatDialogModule
+			],
+			providers: [
+				{provide: MAT_DIALOG_DATA, useValue: data}
+			]
+		}).compileComponents();
+
+		fixture = TestBed.createComponent(CollectionChoiceDialogComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+	});
+
+	it("should create", () => {
+		expect(component).toBeTruthy();
+	});
+
+	it("gets an appropriate input label", () => {
+		expect(component.label).toBe(data.message);
+		data.label = "test";
+		expect(component.label).toBe(data.label);
+	});
+});
diff --git a/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.ts b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.ts
new file mode 100644
index 0000000000..69e30e6b42
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.ts
@@ -0,0 +1,63 @@
+/*
+ * 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 { Component, Inject } from "@angular/core";
+import { MAT_DIALOG_DATA } from "@angular/material/dialog";
+
+import type { DecisionDialogData } from "../decision-dialog/decision-dialog.component";
+
+/**
+ * Contains the structure of the data that CollectionChoiceDialogComponent
+ * accepts.
+ */
+export interface CollectionChoiceDialogData<T = unknown> extends DecisionDialogData {
+	/** The collection from which the user will choose. */
+	collection: {
+		/** A user-friendly name for the item. */
+		label: string;
+		/** The actual value of the item that you want back. */
+		value: T;
+	}[];
+	/** If given, will be displayed as a hint to the user. */
+	hint?: string | null | undefined;
+	/** Used as a label for the input. Defaults to `message`. */
+	label?: string | null | undefined;
+	/** A prompt for the user so they know what they're choosing and why. */
+	message: string;
+}
+
+/**
+ * This dialog facilitates asking the user to choose an item from a list.
+ */
+@Component({
+	selector: "tp-collection-choice-dialog",
+	styleUrls: ["./collection-choice-dialog.component.scss"],
+	templateUrl: "./collection-choice-dialog.component.html",
+})
+export class CollectionChoiceDialogComponent<T = unknown> {
+
+	public selected: T | null = null;
+
+	/** The label to use for the selection input. */
+	public get label(): string {
+		return this.dialogData.label ?? this.dialogData.message;
+	}
+
+	constructor(@Inject(MAT_DIALOG_DATA) public readonly dialogData: CollectionChoiceDialogData<T>) { }
+
+}
diff --git a/experimental/traffic-portal/src/app/shared/interceptor/alerts.interceptor.ts b/experimental/traffic-portal/src/app/shared/interceptor/alerts.interceptor.ts
index 93e045621e..1e81a131c5 100644
--- a/experimental/traffic-portal/src/app/shared/interceptor/alerts.interceptor.ts
+++ b/experimental/traffic-portal/src/app/shared/interceptor/alerts.interceptor.ts
@@ -44,7 +44,7 @@ export class AlertInterceptor implements HttpInterceptor {
 				if (Object.prototype.hasOwnProperty.call(r, "body") &&
 				    Object.prototype.hasOwnProperty.call((r as {body: unknown}).body, "alerts")) {
 					for (const a of (r as {body: {alerts: Array<unknown>}}).body.alerts) {
-						this.alertService.alertsSubject.next(a as Alert);
+						this.alertService.newAlert(a as Alert);
 					}
 				}
 			}
diff --git a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
index 3d303386a4..af7a004ca0 100644
--- a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
+++ b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
@@ -48,7 +48,7 @@ export class ErrorInterceptor implements HttpInterceptor {
 
 			if (err.hasOwnProperty("error") && (err as {error: object}).error.hasOwnProperty("alerts")) {
 				for (const a of (err as {error: {alerts: Alert[]}}).error.alerts) {
-					this.alerts.alertsSubject.next(a);
+					this.alerts.newAlert(a);
 				}
 			}
 
diff --git a/experimental/traffic-portal/src/app/shared/shared.module.ts b/experimental/traffic-portal/src/app/shared/shared.module.ts
index 491f3a7f6f..c2aedab557 100644
--- a/experimental/traffic-portal/src/app/shared/shared.module.ts
+++ b/experimental/traffic-portal/src/app/shared/shared.module.ts
@@ -20,9 +20,8 @@ import { RouterModule } from "@angular/router";
 import { AppUIModule } from "src/app/app.ui.module";
 
 import { AlertComponent } from "./alert/alert.component";
-import { AlertService } from "./alert/alert.service";
 import { LinechartDirective } from "./charts/linechart.directive";
-import { CurrentUserService } from "./currentUser/current-user.service";
+import { CollectionChoiceDialogComponent } from "./dialogs/collection-choice-dialog/collection-choice-dialog.component";
 import { DecisionDialogComponent } from "./dialogs/decision-dialog/decision-dialog.component";
 import { TextDialogComponent } from "./dialogs/text-dialog/text-dialog.component";
 import { GenericTableComponent } from "./generic-table/generic-table.component";
@@ -58,7 +57,8 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
 		ObscuredTextInputComponent,
 		TreeSelectComponent,
 		TextDialogComponent,
-		DecisionDialogComponent
+		DecisionDialogComponent,
+		CollectionChoiceDialogComponent
 	],
 	exports: [
 		AlertComponent,
@@ -81,8 +81,6 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
 	providers: [
 		{ multi: true, provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor },
 		{ multi: true, provide: HTTP_INTERCEPTORS, useClass: AlertInterceptor },
-		AlertService,
-		CurrentUserService
 	]
 })
 export class SharedModule { }
diff --git a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
index 48c7a6e157..cd8b548a18 100644
--- a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
+++ b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
@@ -35,7 +35,8 @@ export interface HeaderNavigation {
 }
 
 /**
- *
+ * Provides an interface that allows modules and components to dynamically
+ * manipulate the header.
  */
 @Injectable({
 	providedIn: "root"
diff --git a/experimental/traffic-portal/src/app/utils/time.spec.ts b/experimental/traffic-portal/src/app/utils/time.spec.ts
index 99680571e2..1013fcbd4a 100644
--- a/experimental/traffic-portal/src/app/utils/time.spec.ts
+++ b/experimental/traffic-portal/src/app/utils/time.spec.ts
@@ -30,40 +30,40 @@ describe("RelativeTimeString", () => {
 		expect(str.substring(0, 4)).toBeCloseTo(5.8, 1);
 	});
 	it("Months", () => {
-		fromDate.setFullYear(toDate.getFullYear());
+		fromDate.setUTCFullYear(toDate.getUTCFullYear());
 		const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
 		expect(str.substring(4)).toBe(" months ago");
 		expect(str.substring(0, 4)).toBeCloseTo(9.5, 1);
 	});
 	it("Weeks", () => {
-		fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth());
+		fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth());
 		const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
 		expect(str.substring(4)).toBe(" weeks ago");
 		expect(str.substring(0, 4)).toBeCloseTo(1.8, 1);
 	});
 	it("Days", () => {
-		fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth(), toDate.getDate()-3);
+		fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate()-3);
 		const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
 		expect(str.substring(4)).toBe(" days ago");
 		expect(str.substring(0, 4)).toBeCloseTo(3.9, 1);
 	});
 	it("Hours", () => {
-		fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth(), toDate.getDate());
-		fromDate.setHours(toDate.getHours()-3);
+		fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate());
+		fromDate.setUTCHours(toDate.getUTCHours()-3);
 		const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
 		expect(str.substring(4)).toBe(" hours ago");
 		expect(str.substring(0, 4)).toBeCloseTo(3.7, 1);
 	});
 	it("Minutes", () => {
-		fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth(), toDate.getDate());
-		fromDate.setHours(toDate.getHours());
+		fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate());
+		fromDate.setUTCHours(toDate.getUTCHours());
 		const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
 		expect(str.substring(5)).toBe(" minutes ago");
 		expect(str.substring(0, 5)).toBeCloseTo(44.9, 1);
 	});
 	it("Seconds", () => {
-		fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth(), toDate.getDate());
-		fromDate.setHours(toDate.getHours(), toDate.getMinutes());
+		fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate());
+		fromDate.setUTCHours(toDate.getUTCHours(), toDate.getUTCMinutes());
 		const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
 		expect(str.substring(2)).toBe(" seconds ago");
 		expect(str.substring(0, 2)).toBe("56");