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

[trafficcontrol] branch master updated: TPv2 bring Servers Detail/Table to parity with TPv1 (#7497)

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

ocket8888 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 b514a3bc45 TPv2 bring Servers Detail/Table to parity with TPv1 (#7497)
b514a3bc45 is described below

commit b514a3bc45f7bd92f716a085a8fd502bab903daa
Author: Steve Hamrick <sh...@gmail.com>
AuthorDate: Mon May 22 15:30:10 2023 -0600

    TPv2 bring Servers Detail/Table to parity with TPv1 (#7497)
    
    * Servers detail parity
    
    * Cleanup
    
    * Code review fixes
    
    * Missed a comment
    
    * console.log
    
    * Wrong branch
    
    * Revert ISO changes, fix other comments
    
    * Update Types
    
    * Fix merge
    
    * Lint fix
    
    * Change selector to not rely on mat-card elements
    
    * Update selector
    
    * Missed a newline
    
    * Address comments
    
    * More logical width
    
    * Add test coverage
    
    * Regenerate package-lock
---
 CHANGELOG.md                                       |   1 -
 .../traffic-portal/build/package-lock.json         |   2 +-
 experimental/traffic-portal/build/package.json     |   2 +-
 .../traffic-portal/build/traffic_portal_v2.spec    |   2 +-
 .../traffic-portal/nightwatch/dataClient.ts        | 374 +++++++++++++++++++++
 .../traffic-portal/nightwatch/globals/globals.ts   | 359 +++-----------------
 .../nightwatch/globals/tables/index.ts             |  98 +++++-
 .../traffic-portal/nightwatch/nightwatch.conf.js   |   1 +
 .../page_objects/cacheGroups/asnDetail.ts          |  21 +-
 .../page_objects/cacheGroups/cacheGroupDetails.ts  |  32 +-
 .../page_objects/cacheGroups/coordinateDetail.ts   |  24 +-
 .../page_objects/cacheGroups/divisionDetail.ts     |  16 +-
 .../page_objects/cacheGroups/regionDetail.ts       |  21 +-
 .../nightwatch/page_objects/cdns/cdnDetail.ts      |  24 +-
 .../deliveryServices/deliveryServiceDetail.ts      |  36 +-
 .../deliveryServiceInvalidationJobs.ts             |   4 +-
 .../nightwatch/page_objects/login.ts               |  20 +-
 .../page_objects/servers/physLocDetail.ts          |  56 +--
 .../page_objects/servers/serversDetail.ts          |  68 ++++
 .../servers/{servers.ts => serversTable.ts}        |  27 +-
 .../page_objects/statuses/statusDetail.ts          |  20 +-
 .../nightwatch/page_objects/types/typeDetail.ts    |  24 +-
 .../nightwatch/page_objects/users/tenantDetail.ts  |  16 +-
 .../tests/servers/servers.detail.spec.ts           | 101 ++++++
 .../{servers.spec.ts => servers.table.spec.ts}     |   8 +-
 .../nightwatch/tests/users/users.spec.ts           |   2 +-
 experimental/traffic-portal/package-lock.json      |  16 +-
 experimental/traffic-portal/package.json           |   4 +-
 .../traffic-portal/src/app/api/cdn.service.spec.ts |  40 +++
 .../traffic-portal/src/app/api/cdn.service.ts      |  26 +-
 .../src/app/api/server.service.spec.ts             |  47 ++-
 .../traffic-portal/src/app/api/server.service.ts   |  36 ++
 .../src/app/api/testing/cdn.service.ts             |  35 +-
 .../src/app/api/testing/server.service.ts          |  48 ++-
 .../traffic-portal/src/app/app.ui.module.ts        |   9 +-
 .../traffic-portal/src/app/core/core.module.ts     |   2 +-
 .../server-details/_server-details-theme.scss      |   5 +
 .../server-details/server-details.component.html   | 239 ++++++++-----
 .../server-details/server-details.component.scss   | 181 +++++-----
 .../server-details.component.spec.ts               |  34 +-
 .../server-details/server-details.component.ts     | 241 +++++++++----
 .../servers-table/servers-table.component.html     |  33 +-
 .../servers-table/servers-table.component.spec.ts  |   4 +-
 .../servers-table/servers-table.component.ts       |  84 ++++-
 .../update-status/update-status.component.html     |   6 +-
 .../update-status/update-status.component.spec.ts  |   6 +-
 .../update-status/update-status.component.ts       |   5 +-
 .../generic-table/generic-table.component.html     |  30 +-
 .../generic-table/generic-table.component.scss     |  10 +-
 .../generic-table/generic-table.component.ts       |  63 +++-
 .../navigation/tp-header/tp-header.component.scss  |   5 +-
 .../tp-sidebar/tp-sidebar.component.scss           |   8 +-
 experimental/traffic-portal/src/styles.scss        | 117 ++++++-
 .../styles/vars.scss}                              |  37 +-
 54 files changed, 1788 insertions(+), 942 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c63c242c7..bed1df5502 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,7 +55,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 
 ### Fixed
 - [#7511](https://github.com/apache/trafficcontrol/pull/7511) *Traffic Ops* Fixed the changelog registration message to include the username instead of duplicate email entry.
-- [#7505](https://github.com/apache/trafficcontrol/pull/7505) *Traffic Portal* Fix an issue where a Delivery Service with Geo Limit Countries Set was unable to be updated.
 - [#7465](https://github.com/apache/trafficcontrol/issues/7465) *Traffic Ops* Fixes server_capabilities v5 apis to respond with RFC3339 date/time Format
 - [#7441](https://github.com/apache/trafficcontrol/pull/7441) *Traffic Ops* Fixed the invalidation jobs endpoint to respect CDN locks.
 - [#7413](https://github.com/apache/trafficcontrol/issues/7413) *Traffic Ops* Fixes service_category apis to respond with RFC3339 date/time Format
diff --git a/experimental/traffic-portal/build/package-lock.json b/experimental/traffic-portal/build/package-lock.json
index 0ee55d1b57..c8607e0ad7 100644
--- a/experimental/traffic-portal/build/package-lock.json
+++ b/experimental/traffic-portal/build/package-lock.json
@@ -12,7 +12,7 @@
         "pm2": "^5.2.2"
       },
       "engines": {
-        "node": ">=16.20"
+        "node": ">=16.14"
       }
     },
     "node_modules/@opencensus/core": {
diff --git a/experimental/traffic-portal/build/package.json b/experimental/traffic-portal/build/package.json
index 539fd2f709..9cd93d38ac 100644
--- a/experimental/traffic-portal/build/package.json
+++ b/experimental/traffic-portal/build/package.json
@@ -16,7 +16,7 @@
 		"url": "https://trafficcontrol.apache.org"
 	},
 	"engines": {
-		"node": ">=16.20"
+		"node": ">=16.14"
 	},
 	"private": true,
 	"license": "Apache-2.0",
diff --git a/experimental/traffic-portal/build/traffic_portal_v2.spec b/experimental/traffic-portal/build/traffic_portal_v2.spec
index 939468d6fe..4484747f3c 100644
--- a/experimental/traffic-portal/build/traffic_portal_v2.spec
+++ b/experimental/traffic-portal/build/traffic_portal_v2.spec
@@ -25,7 +25,7 @@ License:  Apache License, Version 2.0
 URL:      https://github.com/apache/trafficcontrol/
 Source:   %{_sourcedir}/traffic-portal-%{traffic_control_version}.tgz
 AutoReqProv: no
-Requires: nodejs >= 2:16.20.0
+Requires: nodejs >= 2:16.14.0
 Requires(pre): /usr/sbin/useradd, /usr/bin/getent
 
 %define traffic_portal_home /opt/traffic-portal
diff --git a/experimental/traffic-portal/nightwatch/dataClient.ts b/experimental/traffic-portal/nightwatch/dataClient.ts
new file mode 100644
index 0000000000..f7af8b1729
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/dataClient.ts
@@ -0,0 +1,374 @@
+/*
+ * 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 * as https from "https";
+
+import axios, { AxiosError, AxiosInstance } from "axios";
+import { CreatedData } from "nightwatch/globals/globals";
+import {
+	CDN,
+	GeoLimit,
+	GeoProvider,
+	LoginRequest,
+	ProfileType,
+	Protocol,
+	RequestASN,
+	RequestCacheGroup,
+	RequestCoordinate,
+	RequestDeliveryService,
+	RequestDivision,
+	RequestPhysicalLocation,
+	RequestProfile,
+	RequestRegion,
+	RequestRole,
+	RequestServer,
+	RequestServerCapability,
+	RequestStatus,
+	RequestSteeringTarget,
+	RequestTenant,
+	RequestType,
+	ResponseCacheGroup,
+	ResponseDeliveryService,
+	ResponseDivision,
+	ResponsePhysicalLocation,
+	ResponseProfile,
+	ResponseRegion,
+	ResponseStatus,
+	TypeFromResponse
+} from "trafficops-types";
+
+/**
+ * Generates a unique string used for tests, uses the current epoch time.
+ *
+ * @returns a unique string
+ */
+export function generateUniqueString(): string {
+	return new Date().getTime().toString();
+}
+
+/**
+ * Defines the class used to create test data for the E2E environment
+ */
+export class DataClient {
+	private readonly toURL: string;
+	private readonly apiVersion: string;
+	private readonly adminUser: string;
+	private readonly adminPass: string;
+	/** Tracks if the client has logged in */
+	public loggedIn = false;
+	/** Client used to talk to the TO API */
+	private readonly client: AxiosInstance;
+
+	public constructor(toURL: string, apiVersion: string, adminUser: string, adminPass: string) {
+		this.toURL = toURL;
+		this.apiVersion = apiVersion;
+		this.adminUser = adminUser;
+		this.adminPass = adminPass;
+
+		this.client = axios.create({
+			httpsAgent: new https.Agent({
+				rejectUnauthorized: false
+			})
+		});
+	}
+
+	/**
+	 * Creates data needed for the E2E tests
+	 *
+	 * @param id ID added to various fields to ensure that creation occurs regardless of environment
+	 */
+	public async createData(id: string): Promise<CreatedData> {
+		const apiUrl = `${this.toURL}/api/${this.apiVersion}`;
+		if (Object.keys(this.client.defaults.headers.common).indexOf("Cookie") === -1) {
+			this.loggedIn = false;
+			let accessToken = "";
+			const loginReq: LoginRequest = {
+				p: this.adminPass,
+				u: this.adminUser
+			};
+			try {
+				const logResp = await this.client.post(`${apiUrl}/user/login`, JSON.stringify(loginReq));
+				if (logResp.headers["set-cookie"]) {
+					for (const cookie of logResp.headers["set-cookie"]) {
+						if (cookie.indexOf("access_token") > -1) {
+							accessToken = cookie;
+							break;
+						}
+					}
+				}
+			} catch (e) {
+				console.error((e as AxiosError).message);
+				throw e;
+			}
+			if (accessToken === "") {
+				const e = new Error("Access token is not set");
+				console.error(e.message);
+				throw e;
+			}
+			this.loggedIn = true;
+			this.client.defaults.headers.common = {Cookie: accessToken};
+		}
+
+		const cdn: CDN = {
+			dnssecEnabled: false, domainName: `tests${id}.com`, name: `testCDN${id}`
+		};
+
+		let resp = await this.client.get(`${apiUrl}/types`);
+		const types: Array<TypeFromResponse> = resp.data.response;
+		const httpType = types.find(typ => typ.name === "HTTP" && typ.useInTable === "deliveryservice");
+		if (httpType === undefined) {
+			throw new Error("Unable to find `HTTP` type");
+		}
+		const steeringType = types.find(typ => typ.name === "STEERING" && typ.useInTable === "deliveryservice");
+		if (steeringType === undefined) {
+			throw new Error("Unable to find `STEERING` type");
+		}
+		const steeringWeightType = types.find(typ => typ.name === "STEERING_WEIGHT" && typ.useInTable === "steering_target");
+		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");
+		}
+		const edgeType = types.find(typ => typ.useInTable === "server" && typ.name === "EDGE");
+		if (edgeType === undefined) {
+			throw new Error("Unable to find `EDGE` type");
+		}
+
+		const data = {} as CreatedData;
+		let url = `${apiUrl}/cdns`;
+		try {
+			resp = await this.client.post(url, JSON.stringify(cdn));
+			const respCDN = resp.data.response;
+			data.cdn = respCDN;
+
+			const ds: RequestDeliveryService = {
+				active: false,
+				cacheurl: null,
+				cdnId: respCDN.id,
+				displayName: `test DS${id}`,
+				dscp: 0,
+				ecsEnabled: false,
+				edgeHeaderRewrite: null,
+				fqPacingRate: null,
+				geoLimit: GeoLimit.NONE,
+				geoProvider: GeoProvider.MAX_MIND,
+				httpBypassFqdn: null,
+				infoUrl: null,
+				initialDispersion: 1,
+				ipv6RoutingEnabled: false,
+				logsEnabled: false,
+				maxOriginConnections: 0,
+				maxRequestHeaderBytes: 0,
+				midHeaderRewrite: null,
+				missLat: 0,
+				missLong: 0,
+				multiSiteOrigin: false,
+				orgServerFqdn: "http://test.com",
+				profileId: 1,
+				protocol: Protocol.HTTP,
+				qstringIgnore: 0,
+				rangeRequestHandling: 0,
+				regionalGeoBlocking: false,
+				remapText: null,
+				routingName: "test",
+				signed: false,
+				tenantId: 1,
+				typeId: httpType.id,
+				xmlId: `testDS${id}`
+			};
+			url = `${apiUrl}/deliveryservices`;
+			resp = await this.client.post(url, JSON.stringify(ds));
+			let respDS: ResponseDeliveryService = resp.data.response[0];
+			data.ds = respDS;
+
+			ds.displayName = `test DS2${id}`;
+			ds.xmlId = `testDS2${id}`;
+			resp = await this.client.post(url, JSON.stringify(ds));
+			respDS = resp.data.response[0];
+			data.ds2 = respDS;
+
+			ds.displayName = `test steering DS${id}`;
+			ds.xmlId = `testSDS${id}`;
+			ds.typeId = steeringType.id;
+			resp = await this.client.post(url, JSON.stringify(ds));
+			respDS = resp.data.response[0];
+			data.steeringDS = respDS;
+
+			const target: RequestSteeringTarget = {
+				targetId: data.ds.id,
+				typeId: steeringWeightType.id,
+				value: 1
+			};
+			url = `${apiUrl}/steering/${data.steeringDS.id}/targets`;
+			await this.client.post(url, JSON.stringify(target));
+			target.targetId = data.ds2.id;
+			await this.client.post(url, JSON.stringify(target));
+
+			const tenant: RequestTenant = {
+				active: true,
+				name: `testT${id}`,
+				parentId: 1
+			};
+			url = `${apiUrl}/tenants`;
+			resp = await this.client.post(url, JSON.stringify(tenant));
+			data.tenant = resp.data.response;
+
+			const division: RequestDivision = {
+				name: `testD${id}`
+			};
+			url = `${apiUrl}/divisions`;
+			resp = await this.client.post(url, JSON.stringify(division));
+			const respDivision: ResponseDivision = resp.data.response;
+			data.division = respDivision;
+
+			const region: RequestRegion = {
+				division: respDivision.id,
+				name: `testR${id}`
+			};
+			url = `${apiUrl}/regions`;
+			resp = await this.client.post(url, JSON.stringify(region));
+			const respRegion: ResponseRegion = resp.data.response;
+			data.region = respRegion;
+
+			const cacheGroup: RequestCacheGroup = {
+				name: `test${id}`,
+				shortName: `test${id}`,
+				typeId: cgType.id
+			};
+			url = `${apiUrl}/cachegroups`;
+			resp = await this.client.post(url, JSON.stringify(cacheGroup));
+			const responseCG: ResponseCacheGroup = resp.data.response;
+			data.cacheGroup = responseCG;
+
+			const asn: RequestASN = {
+				asn: +id,
+				cachegroupId: responseCG.id
+			};
+			url = `${apiUrl}/asns`;
+			resp = await this.client.post(url, JSON.stringify(asn));
+			data.asn = resp.data.response;
+
+			const physLoc: RequestPhysicalLocation = {
+				address: "street",
+				city: "city",
+				comments: "someone set us up the bomb",
+				email: "email@test.com",
+				name: `phys${id}`,
+				phone: "111-867-5309",
+				poc: "me",
+				regionId: respRegion.id,
+				shortName: `short${id}`,
+				state: "CA",
+				zip: "80000"
+			};
+			url = `${apiUrl}/phys_locations`;
+			resp = await this.client.post(url, JSON.stringify(physLoc));
+			const respPhysLoc: ResponsePhysicalLocation = resp.data.response;
+			respPhysLoc.region = respRegion.name;
+			data.physLoc = respPhysLoc;
+
+			const coordinate: RequestCoordinate = {
+				latitude: 0,
+				longitude: 0,
+				name: `coord${id}`
+			};
+			url = `${apiUrl}/coordinates`;
+			resp = await this.client.post(url, JSON.stringify(coordinate));
+			data.coordinate = resp.data.response;
+
+			const type: RequestType = {
+				description: "blah",
+				name: `type${id}`,
+				useInTable: "server"
+			};
+			url = `${apiUrl}/types`;
+			resp = await this.client.post(url, JSON.stringify(type));
+
+			data.type = resp.data.response;
+			const status: RequestStatus = {
+				description: "blah",
+				name: `status${id}`,
+			};
+			url = `${apiUrl}/statuses`;
+			resp = await this.client.post(url, JSON.stringify(status));
+			const respStatus: ResponseStatus = resp.data.response;
+			data.statuses = respStatus;
+
+			const profile: RequestProfile = {
+				cdn: respCDN.id,
+				description: "blah",
+				name: `profile${id}`,
+				routingDisabled: false,
+				type: ProfileType.ATS_PROFILE,
+			};
+			url = `${apiUrl}/profiles`;
+			resp = await this.client.post(url, JSON.stringify(profile));
+			const respProfile: ResponseProfile = resp.data.response;
+			data.profile = respProfile;
+
+			const server: RequestServer = {
+				cachegroupId: responseCG.id,
+				cdnId: respCDN.id,
+				domainName: "domain.com",
+				hostName: id,
+				interfaces: [{
+					ipAddresses: [{
+						address: "192.160.1.0",
+						gateway: null,
+						serviceAddress: true
+					}],
+					maxBandwidth: 0,
+					monitor: true,
+					mtu: 1500,
+					name: "eth0"
+				}],
+				physLocationId: respPhysLoc.id,
+				profileNames: [respProfile.name],
+				statusId: respStatus.id,
+				typeId: edgeType.id
+
+			};
+			url = `${apiUrl}/servers`;
+			resp = await this.client.post(url, JSON.stringify(server));
+			data.edgeServer = resp.data.response;
+
+			const capability: RequestServerCapability = {
+				name: `test${id}`
+			};
+			url = `${apiUrl}/server_capabilities`;
+			resp = await this.client.post(url, JSON.stringify(capability));
+			data.capability = resp.data.response;
+
+			const role: RequestRole = {
+				description: "Has access to everything - cannot be modified or deleted",
+				name: `admin${id}`,
+				permissions: [
+					"ALL"
+				]
+			};
+			url = `${apiUrl}/roles`;
+			resp = await this.client.post(url, JSON.stringify(role));
+			data.role = resp.data.response;
+		} catch (e) {
+			const ae = e as AxiosError;
+			ae.message = `Request (${ae.config.method}) failed to ${url}`;
+			ae.message += ae.response ? ` with response code ${ae.response.status}` : " with no response";
+			throw ae;
+		}
+
+		return data;
+	}
+}
diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts
index 3ca3773ea6..b365baa2f6 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -12,9 +12,7 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-import * as https from "https";
-
-import axios, { AxiosError } from "axios";
+import { AxiosError } from "axios";
 import { NightwatchBrowser } from "nightwatch";
 import type { AsnDetailPageObject } from "nightwatch/page_objects/cacheGroups/asnDetail";
 import type { AsnsPageObject } from "nightwatch/page_objects/cacheGroups/asnsTable";
@@ -36,7 +34,8 @@ import type { ProfileDetailPageObject } from "nightwatch/page_objects/profiles/p
 import type { ProfilePageObject } from "nightwatch/page_objects/profiles/profilesTable";
 import type { PhysLocDetailPageObject } from "nightwatch/page_objects/servers/physLocDetail";
 import type { PhysLocTablePageObject } from "nightwatch/page_objects/servers/physLocTable";
-import type { ServersPageObject } from "nightwatch/page_objects/servers/servers";
+import type { ServersDetailPageObject } from "nightwatch/page_objects/servers/serversDetail";
+import type { ServersTablePageObject } from "nightwatch/page_objects/servers/serversTable";
 import type { StatusDetailPageObject } from "nightwatch/page_objects/statuses/statusDetail";
 import type { StatusesTablePageObject } from "nightwatch/page_objects/statuses/statusesTable";
 import type { ChangeLogsPageObject } from "nightwatch/page_objects/users/changeLogs";
@@ -45,44 +44,23 @@ import type { TenantDetailPageObject } from "nightwatch/page_objects/users/tenan
 import type { TenantsPageObject } from "nightwatch/page_objects/users/tenants";
 import type { UsersPageObject } from "nightwatch/page_objects/users/users";
 import {
-	GeoLimit,
-	GeoProvider,
-	ProfileType,
-	Protocol,
-
-	type CDN,
-	type LoginRequest,
-	type RequestASN,
-	type RequestCacheGroup,
-	type RequestCoordinate,
-	type RequestDeliveryService,
-	type RequestDivision,
-	type RequestPhysicalLocation,
-	type RequestProfile,
-	type RequestRegion,
-	type RequestRole,
-	type RequestServerCapability,
-	type RequestStatus,
-	type RequestSteeringTarget,
-	type RequestTenant,
-	type RequestType,
-	type ResponseASN,
-	type ResponseCacheGroup,
-	type ResponseCDN,
-	type ResponseCoordinate,
-	type ResponseDeliveryService,
-	type ResponseDivision,
-	type ResponsePhysicalLocation,
-	type ResponseProfile,
-	type ResponseRegion,
-	type ResponseRole,
-	type ResponseServerCapability,
-	type ResponseStatus,
-	type ResponseTenant,
-	type TypeFromResponse,
+	ResponseCDN,
+	ResponseDeliveryService,
+	ResponseTenant,
+	TypeFromResponse,
+	ResponseASN,
+	ResponseDivision,
+	ResponseRegion,
+	ResponseCacheGroup,
+	ResponsePhysicalLocation,
+	ResponseCoordinate,
+	ResponseStatus,
+	ResponseProfile,
+	ResponseServer, ResponseServerCapability, ResponseRole,
 } from "trafficops-types";
 
 import * as config from "../config.json";
+import { DataClient, generateUniqueString } from "../dataClient";
 import type { CapabilitiesPageObject } from "../page_objects/servers/capabilities/capabilitiesTable";
 import type { CapabilityDetailsPageObject } from "../page_objects/servers/capabilities/capabilityDetails";
 import type { TypeDetailPageObject } from "../page_objects/types/typeDetail";
@@ -126,7 +104,8 @@ declare module "nightwatch" {
 			};
 			physLocDetail: () => PhysLocDetailPageObject;
 			physLocTable: () => PhysLocTablePageObject;
-			servers: () => ServersPageObject;
+			serversTable: () => ServersTablePageObject;
+			serversDetail: () => ServersDetailPageObject;
 		};
 		statuses: {
 			statusesTable: () => StatusesTablePageObject;
@@ -170,21 +149,40 @@ export interface CreatedData {
 	division: ResponseDivision;
 	ds: ResponseDeliveryService;
 	ds2: ResponseDeliveryService;
-	profile: ResponseProfile;
+	edgeServer: ResponseServer;
 	physLoc: ResponsePhysicalLocation;
 	region: ResponseRegion;
 	role: ResponseRole;
-	statuses: ResponseStatus;
 	steeringDS: ResponseDeliveryService;
 	tenant: ResponseTenant;
 	type: TypeFromResponse;
+	statuses: ResponseStatus;
+	profile: ResponseProfile;
 }
 
-const testData = {};
+let testData = {};
+let client: DataClient;
+let dataCreateFailed = false;
 
 const globals = {
 	adminPass: config.adminPass,
 	adminUser: config.adminUser,
+	after: async (done: () => void): Promise<void> => {
+		if (dataCreateFailed){
+			return done();
+		} else if(client.loggedIn) {
+			try {
+				await client.createData(generateUniqueString());
+			} catch(e) {
+				console.error("Idempotency test failed, err:", e);
+				throw e;
+			}
+			console.log("Data creation is idempotent");
+		} else {
+			console.log("Client not logged in, skipping idempotency test");
+		}
+		done();
+	},
 	afterEach: (browser: NightwatchBrowser, done: () => void): void => {
 		browser.end(() => {
 			done();
@@ -192,277 +190,12 @@ const globals = {
 	},
 	apiVersion: "4.0",
 	before: async (done: () => void): Promise<void> => {
-		const apiUrl = `${globals.trafficOpsURL}/api/${globals.apiVersion}`;
-		const client = axios.create({
-			httpsAgent: new https.Agent({
-				rejectUnauthorized: false
-			})
-		});
-		let accessToken = "";
-		const loginReq: LoginRequest = {
-			p: globals.adminPass,
-			u: globals.adminUser
-		};
+		client = new DataClient(globals.trafficOpsURL, globals.apiVersion, globals.adminUser, globals.adminPass);
 		try {
-			const logResp = await client.post(`${apiUrl}/user/login`, JSON.stringify(loginReq));
-			if(logResp.headers["set-cookie"]) {
-				for (const cookie of logResp.headers["set-cookie"]) {
-					if(cookie.indexOf("access_token") > -1) {
-						accessToken = cookie;
-						break;
-					}
-				}
-			}
-		} catch (e) {
-			console.error((e as AxiosError).message);
-			throw e;
-		}
-		if(accessToken === "") {
-			const e = new Error("Access token is not set");
-			console.error(e.message);
-			throw e;
-		}
-		client.defaults.headers.common = { Cookie: accessToken };
-
-		const cdn: CDN = {
-			dnssecEnabled: false, domainName: `tests${globals.uniqueString}.com`, name: `testCDN${globals.uniqueString}`
-		};
-		let respCDN: ResponseCDN;
-
-		let resp = await client.get(`${apiUrl}/types`);
-		const types: Array<TypeFromResponse> = resp.data.response;
-		const httpType = types.find(typ => typ.name === "HTTP" && typ.useInTable === "deliveryservice");
-		if(httpType === undefined) {
-			throw new Error("Unable to find `HTTP` type");
-		}
-		const steeringType = types.find(typ => typ.name === "STEERING" && typ.useInTable === "deliveryservice");
-		if(steeringType === undefined) {
-			throw new Error("Unable to find `STEERING` type");
-		}
-		const steeringWeightType = types.find(typ => typ.name === "STEERING_WEIGHT" && typ.useInTable === "steering_target");
-		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");
-		}
-
-		let url = `${apiUrl}/cdns`;
-		try {
-			const data = testData as CreatedData;
-			resp = await client.post(url, JSON.stringify(cdn));
-			respCDN = resp.data.response;
-			console.log(`Successfully created CDN ${respCDN.name}`);
-			data.cdn = respCDN;
-
-			const ds: RequestDeliveryService = {
-				active: false,
-				cacheurl: null,
-				cdnId: respCDN.id,
-				displayName: `test DS${globals.uniqueString}`,
-				dscp: 0,
-				ecsEnabled: false,
-				edgeHeaderRewrite: null,
-				fqPacingRate: null,
-				geoLimit: GeoLimit.NONE,
-				geoProvider: GeoProvider.MAX_MIND,
-				httpBypassFqdn: null,
-				infoUrl: null,
-				initialDispersion: 1,
-				ipv6RoutingEnabled: false,
-				logsEnabled: false,
-				maxOriginConnections: 0,
-				maxRequestHeaderBytes: 0,
-				midHeaderRewrite: null,
-				missLat: 0,
-				missLong: 0,
-				multiSiteOrigin: false,
-				orgServerFqdn: "http://test.com",
-				profileId: 1,
-				protocol: Protocol.HTTP,
-				qstringIgnore: 0,
-				rangeRequestHandling: 0,
-				regionalGeoBlocking: false,
-				remapText: null,
-				routingName: "test",
-				signed: false,
-				tenantId: 1,
-				typeId: httpType.id,
-				xmlId: `testDS${globals.uniqueString}`
-			};
-			url = `${apiUrl}/deliveryservices`;
-			resp = await client.post(url, JSON.stringify(ds));
-			let respDS: ResponseDeliveryService = resp.data.response[0];
-			console.log(`Successfully created DS '${respDS.displayName}'`);
-			data.ds = respDS;
-
-			ds.displayName = `test DS2${globals.uniqueString}`;
-			ds.xmlId = `testDS2${globals.uniqueString}`;
-			resp = await client.post(url, JSON.stringify(ds));
-			respDS = resp.data.response[0];
-			console.log(`Successfully created DS '${respDS.displayName}'`);
-			data.ds2 = respDS;
-
-			ds.displayName = `test steering DS${globals.uniqueString}`;
-			ds.xmlId = `testSDS${globals.uniqueString}`;
-			ds.typeId = steeringType.id;
-			resp = await client.post(url, JSON.stringify(ds));
-			respDS = resp.data.response[0];
-			console.log(`Successfully created DS '${respDS.displayName}'`);
-			data.steeringDS = respDS;
-
-			const target: RequestSteeringTarget = {
-				targetId: data.ds.id,
-				typeId: steeringWeightType.id,
-				value: 1
-			};
-			url = `${apiUrl}/steering/${data.steeringDS.id}/targets`;
-			await client.post(url, JSON.stringify(target));
-			target.targetId = data.ds2.id;
-			await client.post(url, JSON.stringify(target));
-			console.log(`Created steering targets for ${data.steeringDS.displayName}`);
-
-			const tenant: RequestTenant = {
-				active: true,
-				name: `testT${globals.uniqueString}`,
-				parentId: 1
-			};
-			url = `${apiUrl}/tenants`;
-			resp = await client.post(url, JSON.stringify(tenant));
-			const respTenant: ResponseTenant = resp.data.response;
-			console.log(`Successfully created Tenant ${respTenant.name}`);
-			data.tenant = respTenant;
-
-			const division: RequestDivision = {
-				name: `testD${globals.uniqueString}`
-			};
-			url = `${apiUrl}/divisions`;
-			resp = await client.post(url, JSON.stringify(division));
-			const respDivision: ResponseDivision = resp.data.response;
-			console.log(`Successfully created Division ${respDivision.name}`);
-			data.division = respDivision;
-
-			const region: RequestRegion = {
-				division: respDivision.id,
-				name: `testR${globals.uniqueString}`
-			};
-			url = `${apiUrl}/regions`;
-			resp = await client.post(url, JSON.stringify(region));
-			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
-			};
-			url = `${apiUrl}/cachegroups`;
-			resp = await client.post(url, JSON.stringify(cacheGroup));
-			const responseCG: ResponseCacheGroup = resp.data.response;
-			console.log("Successfully created Cache Group:", responseCG.name);
-			data.cacheGroup = responseCG;
-
-			const asn: RequestASN = {
-				asn: +globals.uniqueString,
-				cachegroupId: responseCG.id
-			};
-			url = `${apiUrl}/asns`;
-			resp = await client.post(url, JSON.stringify(asn));
-			const respAsn: ResponseASN = resp.data.response;
-			console.log(`Successfully created ASN ${respAsn.asn}`);
-			data.asn = respAsn;
-
-			const physLoc: RequestPhysicalLocation = {
-				address: "street",
-				city: "city",
-				comments: "someone set us up the bomb",
-				email: "email@test.com",
-				name: `phys${globals.uniqueString}`,
-				phone: "111-867-5309",
-				poc: "me",
-				regionId: respRegion.id,
-				shortName: `short${globals.uniqueString}`,
-				state: "CA",
-				zip: "80000"
-			};
-			url = `${apiUrl}/phys_locations`;
-			resp = await client.post(url, JSON.stringify(physLoc));
-			const respPhysLoc: ResponsePhysicalLocation = resp.data.response;
-			respPhysLoc.region = respRegion.name;
-			console.log(`Successfully created Phys Loc ${respPhysLoc.name}`);
-			data.physLoc = respPhysLoc;
-
-			const coordinate: RequestCoordinate = {
-				latitude: 0,
-				longitude: 0,
-				name: `coord${globals.uniqueString}`
-			};
-			url = `${apiUrl}/coordinates`;
-			resp = await client.post(url, JSON.stringify(coordinate));
-			const respCoordinate: ResponseCoordinate = resp.data.response;
-			console.log(`Successfully created Coordinate ${respCoordinate.name}`);
-			data.coordinate = respCoordinate;
-
-			const type: RequestType = {
-				description: "blah",
-				name: `type${globals.uniqueString}`,
-				useInTable: "server"
-			};
-			url = `${apiUrl}/types`;
-			resp = await client.post(url, JSON.stringify(type));
-			const respType: TypeFromResponse = resp.data.response;
-			console.log(`Successfully created Type ${respType.name}`);
-			data.type = respType;
-
-			const status: RequestStatus = {
-				description: "blah",
-				name: `status${globals.uniqueString}`,
-			};
-			url = `${apiUrl}/statuses`;
-			resp = await client.post(url, JSON.stringify(status));
-			const respStatus: ResponseStatus = resp.data.response;
-			console.log(`Successfully created Status ${respStatus.name}`);
-			data.statuses = respStatus;
-
-			const profile: RequestProfile = {
-				cdn: 1,
-				description: "blah",
-				name: `profile${globals.uniqueString}`,
-				routingDisabled: false,
-				type: ProfileType.ATS_PROFILE,
-			};
-			url = `${apiUrl}/profiles`;
-			resp = await client.post(url, JSON.stringify(profile));
-			const respProfile: ResponseProfile = resp.data.response;
-			console.log(`Successfully created Profile ${respProfile.name}`);
-			data.profile = respProfile;
-
-			const capability: RequestServerCapability = {
-				name: `test${globals.uniqueString}`
-			};
-			url = `${apiUrl}/server_capabilities`;
-			resp = await client.post(url, JSON.stringify(capability));
-			const respCap: ResponseServerCapability = resp.data.response;
-			console.log("Successfully created Capability:", respCap);
-			data.capability = respCap;
-
-			const role: RequestRole = {
-				description: "Has access to everything - cannot be modified or deleted",
-				name: `admin${globals.uniqueString}`,
-				permissions: [
-					"ALL"
-				]
-			};
-			url = `${apiUrl}/roles`;
-			resp = await client.post(url, JSON.stringify(role));
-			const respRole: ResponseRole = resp.data.response;
-			console.log(`Successfully created Roles ${respRole.name}`);
-			data.role = respRole;
-
+			testData = await client.createData(globals.uniqueString);
 		} catch(e) {
-			console.error("Request for", url, "failed:", (e as AxiosError).message);
+			dataCreateFailed = true;
+			console.error("Request for", globals.trafficOpsURL, "failed:", (e as AxiosError).message);
 			throw e;
 		}
 		done();
@@ -480,7 +213,7 @@ const globals = {
 	retryAssertionTimeout: config.retryAssertionTimeoutMS,
 	testData,
 	trafficOpsURL: config.to_url,
-	uniqueString: new Date().getTime().toString(),
+	uniqueString: generateUniqueString(),
 	waitForConditionTimeout:config.waitForConditionTimeoutMS
 };
 
diff --git a/experimental/traffic-portal/nightwatch/globals/tables/index.ts b/experimental/traffic-portal/nightwatch/globals/tables/index.ts
index e9b5ce4b67..b9c5d1b0d3 100644
--- a/experimental/traffic-portal/nightwatch/globals/tables/index.ts
+++ b/experimental/traffic-portal/nightwatch/globals/tables/index.ts
@@ -12,29 +12,55 @@
 * limitations under the License.
 */
 
-import type { Awaitable, EnhancedElementInstance, EnhancedPageObject, EnhancedSectionInstance } from "nightwatch";
+import type {
+	Awaitable,
+	EnhancedElementInstance,
+	EnhancedPageObject,
+	EnhancedSectionInstance,
+	WebDriverProtocolUserActions
+} from "nightwatch";
 
 /**
  * TableSectionCommands is the base type for page object sections representing
  * pages containing AG-Grid generic tables.
  */
-export interface TableSectionCommands extends EnhancedSectionInstance, EnhancedElementInstance<EnhancedPageObject> {
+export interface TableSectionCommands extends EnhancedSectionInstance,
+	EnhancedElementInstance<EnhancedPageObject>, WebDriverProtocolUserActions {
+	doubleClickRow<T extends this>(row: number): Promise<T>;
+
+	filterTableByColumn<T extends this>(column: string, search: string): Promise<T>;
+
+	gotoRowByColumn<T extends this>(column: string, search: string): Promise<T>;
+
 	getColumnState(column: string): Promise<boolean>;
+
 	searchText<T extends this>(text: string): T;
-	toggleColumn<T extends this>(column: string): T;
+
+	toggleColumn(column: string): Promise<this>;
 }
 
 /**
  * A CSS selector for an AG-Grid generic table's column visibility dropdown
  * menu.
  */
-export const columnMenuSelector = "div.toggle-columns > button.mat-mdc-menu-trigger";
+export const columnMenuBtnSelector = "div.toggle-columns > button.mat-mdc-menu-trigger";
+
+/**
+ * A CSS selector for an AG-Grid generic table's column visibility dropdown
+ * menu.
+ */
+export const columnMenuCloseSelector = ".cdk-overlay-backdrop";
 
 /**
  * A CSS selector for an AG-Grid generic table's "Fuzzy Search" input text box.
  */
 export const searchboxSelector = "input[name='fuzzControl']";
 
+/**
+ * CSS selector for the AG-Grid row(s).
+ */
+export const tableRowsSelector = ".ag-center-cols-clipper .ag-row";
+
 /**
  * Gets the state of an AG-Grid column by checking whether it's checked
  * in the column visibility menu (doesn't actually verify that this means the
@@ -48,9 +74,55 @@ export const searchboxSelector = "input[name='fuzzControl']";
  */
 export async function getColumnState(this: TableSectionCommands, column: string): Promise<boolean> {
 	const selector = `input[type='checkbox'][name='${column}']`;
-	return this.click(columnMenuSelector).parent
-		.getLocationInView(selector)
-		.isSelected(selector);
+	await this.click(columnMenuBtnSelector);
+	const selected = await browser.isSelected(selector);
+	return Promise.resolve(selected);
+}
+
+/**
+ * Filters a table by a column
+ *
+ * @param this Special parameter that tells the compiler what `this` is in a
+ * valid context for this function.
+ * @param column Which column to filter
+ * @param text Text to filter by
+ */
+export async function filterTableByColumn<T extends TableSectionCommands>(
+	this: TableSectionCommands,
+	column: string,
+	text: string): Promise<T> {
+	if (!await this.getColumnState(column)) {
+		await this.toggleColumn(column);
+	}
+	this.searchText(text);
+	return Promise.resolve(this) as Promise<T>;
+}
+
+/**
+ * Double-clicks the nth row on a table
+ *
+ * @param this Special parameter that tells the compiler what `this` is in a
+ * valid context for this function.
+ * @param rowNumber Which row to click
+ * @returns The calling command section for call-chaining the way Nightwatch
+ * likes to do.
+ */
+export function doubleClickRow<T extends TableSectionCommands>(this: TableSectionCommands, rowNumber: number): Awaitable<T, null> {
+	return this.doubleClick("css selector", `${tableRowsSelector}:nth-of-type(${rowNumber})`) as Awaitable<T, null>;
+}
+
+/**
+ * Filters a table by a column, then double-clicks the first row resulting from the filtering.
+ *
+ * @param this Special parameter that tells the compiler what `this` is in a
+ * valid context for this function.
+ * @param column Which column to filter
+ * @param text Text to filter by
+ */
+export async function gotoRowByColumn<T extends TableSectionCommands>(
+	this: TableSectionCommands, column: string, text: string): Promise<T> {
+	await this.filterTableByColumn(column, text);
+	return this.doubleClickRow(1);
 }
 
 /**
@@ -75,8 +147,13 @@ export function searchText<T extends TableSectionCommands>(this: T, text: string
  * @returns The calling command section for call-chaining the way Nightwatch
  * likes to do.
  */
-export function toggleColumn<T extends TableSectionCommands>(this: T, column: string): Awaitable<T, null> {
-	return this.click(columnMenuSelector).click(`mat-input[name='${column}']`).click(columnMenuSelector) as Awaitable<T, null>;
+export async function toggleColumn<T extends TableSectionCommands>(this: T, column: string): Promise<T> {
+	const selector = `input[type='checkbox'][name='${column}']`;
+	await browser.findElement(".mat-mdc-menu-panel")
+		.getLocationInView(selector)
+		.click(selector);
+	await browser.click(columnMenuCloseSelector);
+	return Promise.resolve(this);
 }
 
 /**
@@ -84,7 +161,10 @@ export function toggleColumn<T extends TableSectionCommands>(this: T, column: st
  * to most easily provide all the functionality of a table.
  */
 export const TABLE_COMMANDS = {
+	doubleClickRow,
+	filterTableByColumn,
 	getColumnState,
+	gotoRowByColumn,
 	searchText,
 	toggleColumn
 };
diff --git a/experimental/traffic-portal/nightwatch/nightwatch.conf.js b/experimental/traffic-portal/nightwatch/nightwatch.conf.js
index 4f2f1ac590..0d91229e3e 100644
--- a/experimental/traffic-portal/nightwatch/nightwatch.conf.js
+++ b/experimental/traffic-portal/nightwatch/nightwatch.conf.js
@@ -95,6 +95,7 @@ module.exports = {
 				"goog:chromeOptions": {
 					args: [
 						"--ignore-certificate-errors",
+						"--window-size=1920,1080",
 						"--allow-insecure-localhost"
 					]
 				}
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/asnDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/asnDetail.ts
index 5ed7516fa0..4548996aa0 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/asnDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/asnDetail.ts
@@ -21,22 +21,11 @@ export type AsnDetailPageObject = EnhancedPageObject<{}, typeof asnDetailPageObj
 
 const asnDetailPageObject = {
 	elements: {
-		asn: {
-			selector: "input[name='asn']"
-		},
-		cachegroup: {
-			selector: "mat-select[name='cachegroup']"
-		},
-		id: {
-			selector: "input[name='id']"
-		},
-		lastUpdated: {
-			selector: "input[name='lastUpdated']"
-		},
-
-		saveBtn: {
-			selector: "button[type='submit']"
-		}
+		asn: "input[name='asn']",
+		cachegroup: "mat-select[name='cachegroup']",
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		saveBtn: "button[type='submit']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/cacheGroupDetails.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/cacheGroupDetails.ts
index 973b66bded..96a9f29a87 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/cacheGroupDetails.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/cacheGroupDetails.ts
@@ -21,30 +21,14 @@ export type CacheGroupDetailPageObject = EnhancedPageObject<{}, typeof cacheGrou
 
 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]"
-		},
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		latitude: "input[name='latitude']",
+		longitude: "input[name='longitude']",
+		name: "input[name='name']",
+		parent: "mat-select[name='parentCacheGroup']",
+		saveBtn: "button[type='submit']",
+		secondaryParent: "mat-select[name='secondaryParentCacheGroup]",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinateDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinateDetail.ts
index a9823030f9..18a2db2e05 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinateDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinateDetail.ts
@@ -21,24 +21,12 @@ export type CoordinateDetailPageObject = EnhancedPageObject<{}, typeof coordinat
 
 const coordinateDetailPageObject = {
 	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']"
-		},
-		saveBtn: {
-			selector: "button[type='submit']"
-		}
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		latitude: "input[name='latitude']",
+		longitude: "input[name='longitude']",
+		name: "input[name='name']",
+		saveBtn: "button[type='submit']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/divisionDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/divisionDetail.ts
index 71cd31f849..9bdbdeb1bd 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/divisionDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/divisionDetail.ts
@@ -21,18 +21,10 @@ export type DivisionDetailPageObject = EnhancedPageObject<{}, typeof divisionDet
 
 const divisionDetailPageObject = {
 	elements: {
-		id: {
-			selector: "input[name='id']"
-		},
-		lastUpdated: {
-			selector: "input[name='lastUpdated']"
-		},
-		name: {
-			selector: "input[name='name']"
-		},
-		saveBtn: {
-			selector: "button[type='submit']"
-		}
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		name: "input[name='name']",
+		saveBtn: "button[type='submit']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/regionDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/regionDetail.ts
index 3f2d6b4de6..62d8cd5593 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/regionDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/regionDetail.ts
@@ -21,22 +21,11 @@ export type RegionDetailPageObject = EnhancedPageObject<{}, typeof regionDetailP
 
 const regionDetailPageObject = {
 	elements: {
-		division: {
-			selector: "mat-select[name='division']"
-		},
-		id: {
-			selector: "input[name='id']"
-		},
-		lastUpdated: {
-			selector: "input[name='lastUpdated']"
-		},
-		name: {
-			selector: "input[name='name']"
-		},
-
-		saveBtn: {
-			selector: "button[type='submit']"
-		}
+		division: "mat-select[name='division']",
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		name: "input[name='name']",
+		saveBtn: "button[type='submit']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cdns/cdnDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/cdns/cdnDetail.ts
index cad2861acd..f33c90de0c 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/cdns/cdnDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/cdns/cdnDetail.ts
@@ -21,24 +21,12 @@ export type CDNDetailPageObject = EnhancedPageObject<{}, typeof cdnDetailPageObj
 
 const cdnDetailPageObject = {
 	elements: {
-		dnssecEnabled: {
-			selector: "input[name='dnssecEnabled']"
-		},
-		domainName: {
-			selector: "input[name='domainName']"
-		},
-		id: {
-			selector: "input[name='id']"
-		},
-		lastUpdated: {
-			selector: "input[name='lastUpdated']"
-		},
-		name: {
-			selector: "input[name='name']"
-		},
-		saveBtn: {
-			selector: "button[type='submit']"
-		},
+		dnssecEnabled: "input[name='dnssecEnabled']",
+		domainName: "input[name='domainName']",
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		name: "input[name='name']",
+		saveBtn: "button[type='submit']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceDetail.ts
index 2c9639aee1..661846f3f7 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceDetail.ts
@@ -23,37 +23,19 @@ export type DeliveryServiceDetailPageObject = EnhancedPageObject<{}, typeof deli
 
 const deliveryServiceDetailPageObject = {
 	elements: {
-		bandwidthChart: {
-			selector: "canvas#bandwidthData"
-		},
-		invalidateJobs: {
-			selector: "a#invalidate"
-		},
-		tpsChart: {
-			selector: "canvas#tpsChartData"
-		},
+		bandwidthChart: "canvas#bandwidthData",
+		invalidateJobs: "a#invalidate",
+		tpsChart: "canvas#tpsChartData",
 	},
 	sections: {
 		dateInputForm: {
 			elements: {
-				fromDate: {
-					selector: "input[name='fromdate']"
-				},
-				fromTime: {
-					selector: "input[name='fromtime']"
-				},
-				refreshBtn: {
-					selector: "button[name='timespanRefresh']"
-				},
-				steeringIcon: {
-					selector: "div.actions > mat-icon"
-				},
-				toDate: {
-					selector: "input[name='todate']"
-				},
-				toTime: {
-					selector: "input[name='totime']"
-				}
+				fromDate: "input[name='fromdate']",
+				fromTime: "input[name='fromtime']",
+				refreshBtn: "button[name='timespanRefresh']",
+				steeringIcon: "div.actions > mat-icon",
+				toDate: "input[name='todate']",
+				toTime: "input[name='totime']",
 			},
 			selector: "form[name='timespan']"
 		}
diff --git a/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceInvalidationJobs.ts b/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceInvalidationJobs.ts
index e9e3f80812..5ce3e4117b 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceInvalidationJobs.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceInvalidationJobs.ts
@@ -23,9 +23,7 @@ export type DeliveryServiceInvalidPageObject = EnhancedPageObject<{}, typeof del
 
 const deliveryServiceInvalidPageObject = {
 	elements: {
-		addButton: {
-			selector: "button#new"
-		}
+		addButton: "button#new",
 	}
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/login.ts b/experimental/traffic-portal/nightwatch/page_objects/login.ts
index fdd68cb0d2..1a598ba0c4 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/login.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/login.ts
@@ -57,21 +57,11 @@ const loginPageObject = {
 				}
 			} as LoginFormSectionCommands,
 			elements: {
-				clearBtn: {
-					selector: "button[name='clear']"
-				},
-				loginBtn: {
-					selector: "button[name='login']"
-				},
-				passwordTxt: {
-					selector: "input[name='p']"
-				},
-				resetBtn: {
-					selector: "button[name='reset']"
-				},
-				usernameTxt: {
-					selector: "input[name='u']"
-				}
+				clearBtn: "button[name='clear']",
+				loginBtn: "button[name='login']",
+				passwordTxt: "input[name='p']",
+				resetBtn: "button[name='reset']",
+				usernameTxt: "input[name='u']",
 			},
 			selector: "form[name='login']"
 		}
diff --git a/experimental/traffic-portal/nightwatch/page_objects/servers/physLocDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/servers/physLocDetail.ts
index 0ffe4a8c7b..a1f3a0922e 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/servers/physLocDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/servers/physLocDetail.ts
@@ -21,48 +21,20 @@ export type PhysLocDetailPageObject = EnhancedPageObject<{}, typeof physLocDetai
 
 const physLocDetailPageObject = {
 	elements: {
-		address: {
-			selector: "input[name='address']"
-		},
-		city: {
-			selector: "input[name='city']"
-		},
-		comments: {
-			selector: "textarea[name='comments']"
-		},
-		email: {
-			selector: "input[name='email']"
-		},
-		id: {
-			selector: "input[name='id']"
-		},
-		lastUpdated: {
-			selector: "input[name='lastUpdated']"
-		},
-		name: {
-			selector: "input[name='name']"
-		},
-		phone: {
-			selector: "input[name='phone']"
-		},
-		poc: {
-			selector: "input[name='poc']"
-		},
-		region: {
-			selector: "mat-select[name='region']"
-		},
-		saveBtn: {
-			selector: "button[type='submit']"
-		},
-		shortName: {
-			selector: "input[name='shortName']"
-		},
-		state: {
-			selector: "input[name='state']"
-		},
-		zip: {
-			selector: "input[name='postalCode']"
-		}
+		address: "input[name='address']",
+		city: "input[name='city']",
+		comments: "textarea[name='comments']",
+		email: "input[name='email']",
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		name: "input[name='name']",
+		phone: "input[name='phone']",
+		poc: "input[name='poc']",
+		region: "mat-select[name='region']",
+		saveBtn: "button[type='submit']",
+		shortName: "input[name='shortName']",
+		state: "input[name='state']",
+		zip: "input[name='postalCode']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/servers/serversDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/servers/serversDetail.ts
new file mode 100644
index 0000000000..790b6c17c0
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/servers/serversDetail.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 } from "nightwatch";
+
+/**
+ * Defines the Section Instance for the Servers Details.
+ */
+type ServersDetailSection = EnhancedSectionInstance<
+EnhancedSectionInstance<typeof serversDetailPageObject.sections.detailCard.commands,
+		typeof serversDetailPageObject.sections.detailCard.elements>>;
+
+/**
+ * Defines the PageObject for Servers Details.
+ */
+export type ServersDetailPageObject = EnhancedPageObject<{}, {}, { detailCard: ServersDetailSection}>;
+
+const serversDetailPageObject = {
+	sections: {
+		detailCard: {
+			commands: {
+			},
+			elements: {
+				cacheGroup: "mat-select[name='cachegroup']",
+				cdn: "mat-select[name='cdn']",
+				deleteBtn: "button[aria-label='Delete Server']",
+				domainName: "input[name='domainname']",
+				hostName: "input[name='hostname']",
+				httpPort: "input[name='httpport']",
+				httpsPort: "input[name='httpsport']",
+				id: "input[name='serverId']",
+				iloGateway: "input[name='iloGateway']",
+				iloIP: "input[name='iloIP']",
+				iloNetmask: "input[name='iloNetmask']",
+				iloPassword: "input[name='iloPassword']",
+				iloUsername: "input[name='iloUsername']",
+				intfAddBtn: "button[aria-label='Add An Interface']",
+				lastUpdated: "input[name='lastUpdated']",
+				mgmtGateway: "input[name='mgmtIpGateway']",
+				mgmtIP: "input[name='mgmtIP']",
+				mgmtNetmask: "input[name='mgmtIpNetmask']",
+				offlineReason: "mat-select[name='offlineReason']",
+				physLoc: "mat-select[name='physLocation']",
+				profileNames: "mat-select[name='profiles']",
+				rack: "input[name='rack']",
+				status: "mat-select[name='status']",
+				statusDisabled: "input[name='status']",
+				statusLastUpdated: "input[name='statusLastUpdated']",
+				submitBtn: "button[aria-label='Submit Server']",
+				type: "mat-select[name='type']"
+			},
+			selector: "mat-card.page-content"
+		}
+	}
+};
+
+export default serversDetailPageObject;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/servers/servers.ts b/experimental/traffic-portal/nightwatch/page_objects/servers/serversTable.ts
similarity index 59%
rename from experimental/traffic-portal/nightwatch/page_objects/servers/servers.ts
rename to experimental/traffic-portal/nightwatch/page_objects/servers/serversTable.ts
index bb57414631..5e3f2e93d6 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/servers/servers.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/servers/serversTable.ts
@@ -16,24 +16,45 @@ import {
 	EnhancedSectionInstance,
 	NightwatchAPI
 } from "nightwatch";
+import { type ResponseServer } from "trafficops-types";
 
 import { TableSectionCommands, TABLE_COMMANDS } from "../../globals/tables";
 
 /**
  * Defines the commands for the servers table section.
  */
-type ServersTableSectionCommands = TableSectionCommands;
+interface ServersTableSectionCommands extends TableSectionCommands {
+	createNew(): Promise<void>;
+	openDetails(s: ResponseServer): Promise<void>;
+	open(): Promise<void>;
+}
 
 const serversPageObject = {
 	api: {} as NightwatchAPI,
 	sections: {
 		serversTable: {
 			commands: {
+				async createNew(): Promise<void> {
+					await this.open();
+					await browser.click("a.page-fab[routerLink='new']");
+				},
+				async open(): Promise<void> {
+					await browser.page.common()
+						.section.sidebar
+						.navigateToNode("servers", ["serversContainer"]);
+				},
+				async openDetails(server: ResponseServer): Promise<void> {
+					await this.open();
+					const table = browser.page.servers.serversTable().section.serversTable;
+					await table
+						.filterTableByColumn("Host", server.hostName);
+					await table.doubleClickRow(1);
+				},
 				...TABLE_COMMANDS
 			} as ServersTableSectionCommands,
 			elements: {
 			},
-			selector: "servers-table main"
+			selector: "mat-card"
 		}
 	},
 	url(): string {
@@ -50,6 +71,6 @@ type ServersTableSection = EnhancedSectionInstance<ServersTableSectionCommands,
  * The type of the servers table page object as provided by the Nightwatch API at
  * runtime.
  */
-export type ServersPageObject = EnhancedPageObject<{}, {}, { serversTable: ServersTableSection }>;
+export type ServersTablePageObject = EnhancedPageObject<{}, {}, { serversTable: ServersTableSection }>;
 
 export default serversPageObject;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/statuses/statusDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/statuses/statusDetail.ts
index 133be79e6d..78358cdcaa 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/statuses/statusDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/statuses/statusDetail.ts
@@ -21,21 +21,11 @@ export type StatusDetailPageObject = EnhancedPageObject<{}, typeof statusDetailP
 
 const statusDetailPageObject = {
 	elements: {
-		description: {
-			selector: "input[name='description']"
-		},
-		id: {
-			selector: "input[name='id']"
-		},
-		lastUpdated: {
-			selector: "input[name='lastUpdated']"
-		},
-		name: {
-			selector: "input[name='name']"
-		},
-		saveBtn: {
-			selector: "button[type='submit']"
-		}
+		description: "input[name='description']",
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		name: "input[name='name']",
+		saveBtn: "button[type='submit']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/types/typeDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/types/typeDetail.ts
index 598025c0fb..29383bf717 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/types/typeDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/types/typeDetail.ts
@@ -21,24 +21,12 @@ export type TypeDetailPageObject = EnhancedPageObject<{}, typeof typeDetailPageO
 
 const typeDetailPageObject = {
 	elements: {
-		description: {
-			selector: "input[name='description']"
-		},
-		id: {
-			selector: "input[name='id']"
-		},
-		lastUpdated: {
-			selector: "input[name='lastUpdated']"
-		},
-		name: {
-			selector: "input[name='name']"
-		},
-		saveBtn: {
-			selector: "button[type='submit']"
-		},
-		useInTable: {
-			selector: "input[name='useInTable']"
-		}
+		description: "input[name='description']",
+		id: "input[name='id']",
+		lastUpdated: "input[name='lastUpdated']",
+		name: "input[name='name']",
+		saveBtn: "button[type='submit']",
+		useInTable: "input[name='useInTable']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/page_objects/users/tenantDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/users/tenantDetail.ts
index dcc65a0880..3ea46f7eef 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/users/tenantDetail.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/users/tenantDetail.ts
@@ -21,18 +21,10 @@ export type TenantDetailPageObject = EnhancedPageObject<{}, typeof tenantDetailP
 
 const tenantDetailPageObject = {
 	elements: {
-		active: {
-			selector: "input[name='active']",
-		},
-		name: {
-			selector: "input[name='name']"
-		},
-		parent: {
-			selector: "input[name='parentTenant-tree-select']"
-		},
-		saveBtn: {
-			selector: "button[type='submit']"
-		}
+		active: "input[name='active']",
+		name: "input[name='name']",
+		parent: "input[name='parentTenant-tree-select']",
+		saveBtn: "button[type='submit']",
 	},
 };
 
diff --git a/experimental/traffic-portal/nightwatch/tests/servers/servers.detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/servers/servers.detail.spec.ts
new file mode 100644
index 0000000000..c526f7708a
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/servers/servers.detail.spec.ts
@@ -0,0 +1,101 @@
+/*
+ * 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("Servers Detail Spec", () => {
+	it("New server loads correctly", async () => {
+		await browser.page.servers.serversTable()
+			.section.serversTable
+			.createNew();
+		await browser.assert.urlContains("core/servers/new");
+		const page = browser.page.servers.serversDetail();
+		await page.section.detailCard
+			.assert.enabled("@hostName")
+			.assert.enabled("@cdn")
+			.assert.enabled("@cacheGroup")
+			.assert.enabled("@physLoc")
+			.assert.enabled("@status")
+			.assert.not.elementPresent("@offlineReason")
+			.assert.enabled("@type")
+			.assert.enabled("@httpPort")
+			.assert.enabled("@httpsPort")
+			.assert.enabled("@rack")
+			.assert.not.elementPresent("@id")
+			.assert.not.elementPresent("@lastUpdated")
+			.assert.not.elementPresent("@statusLastUpdated")
+			.assert.enabled("@profileNames")
+			.assert.enabled("@intfAddBtn")
+			.assert.enabled("@iloIP")
+			.assert.enabled("@iloGateway")
+			.assert.enabled("@iloNetmask")
+			.assert.enabled("@iloUsername")
+			.assert.enabled("@iloPassword")
+			.assert.enabled("@mgmtIP")
+			.assert.enabled("@mgmtGateway")
+			.assert.enabled("@mgmtNetmask")
+			.assert.not.elementPresent("@deleteBtn")
+			.assert.enabled("@submitBtn");
+	});
+
+	it("Fields are loaded correctly", async () => {
+		await browser.page.servers.serversTable()
+			.section.serversTable
+			.openDetails(browser.globals.testData.edgeServer);
+		await browser.assert.urlContains(`core/servers/${  browser.globals.testData.edgeServer.id}`);
+		const page = browser.page.servers.serversDetail();
+		await page.section.detailCard
+			.assert.enabled("@hostName")
+			.assert.valueEquals("@hostName", browser.globals.testData.edgeServer.hostName)
+			.assert.enabled("@cdn")
+			.assert.textEquals("@cdn", browser.globals.testData.edgeServer.cdnName)
+			.assert.enabled("@cacheGroup")
+			.assert.textEquals("@cacheGroup", browser.globals.testData.edgeServer.cachegroup)
+			.assert.enabled("@physLoc")
+			.assert.textEquals("@physLoc", browser.globals.testData.edgeServer.physLocation)
+			.assert.not.enabled("@statusDisabled")
+			.assert.valueEquals("@statusDisabled", browser.globals.testData.edgeServer.status)
+			.assert.not.elementPresent("@offlineReason")
+			.assert.enabled("@type")
+			.assert.textEquals("@type", browser.globals.testData.edgeServer.type)
+			.assert.enabled("@httpPort")
+			.assert.enabled("@httpsPort")
+			.assert.textEquals("@httpsPort", String(browser.globals.testData.edgeServer.httpsPort ?? ""))
+			.assert.enabled("@rack")
+			.assert.textEquals("@rack", browser.globals.testData.edgeServer.rack ?? "")
+			.assert.not.enabled("@id")
+			.assert.valueEquals("@id", String(browser.globals.testData.edgeServer.id))
+			.assert.not.enabled("@lastUpdated")
+			.assert.not.elementPresent("@statusLastUpdated")
+			.assert.enabled("@profileNames")
+			.assert.textEquals("@profileNames", browser.globals.testData.edgeServer.profileNames[0])
+			.assert.enabled("@intfAddBtn")
+			.assert.enabled("@iloIP")
+			.assert.valueEquals("@iloIP", browser.globals.testData.edgeServer.iloIpAddress ?? "")
+			.assert.enabled("@iloGateway")
+			.assert.valueEquals("@iloGateway", browser.globals.testData.edgeServer.iloIpGateway ?? "" )
+			.assert.enabled("@iloNetmask")
+			.assert.valueEquals("@iloNetmask", browser.globals.testData.edgeServer.iloIpNetmask ?? "")
+			.assert.enabled("@iloUsername")
+			.assert.valueEquals("@iloUsername", browser.globals.testData.edgeServer.iloUsername ?? "")
+			.assert.enabled("@iloPassword")
+			.assert.valueEquals("@iloPassword", browser.globals.testData.edgeServer.iloPassword ?? "")
+			.assert.enabled("@mgmtIP")
+			.assert.valueEquals("@mgmtIP", browser.globals.testData.edgeServer.mgmtIpAddress ?? "")
+			.assert.enabled("@mgmtGateway")
+			.assert.valueEquals("@mgmtGateway", browser.globals.testData.edgeServer.mgmtIpGateway ?? "")
+			.assert.enabled("@mgmtNetmask")
+			.assert.valueEquals("@mgmtNetmask", browser.globals.testData.edgeServer.mgmtIpNetmask ?? "")
+			.assert.enabled("@deleteBtn")
+			.assert.enabled("@submitBtn");
+	});
+});
diff --git a/experimental/traffic-portal/nightwatch/tests/servers/servers.spec.ts b/experimental/traffic-portal/nightwatch/tests/servers/servers.table.spec.ts
similarity index 80%
rename from experimental/traffic-portal/nightwatch/tests/servers/servers.spec.ts
rename to experimental/traffic-portal/nightwatch/tests/servers/servers.table.spec.ts
index 62d0b66dc6..5715f4416a 100644
--- a/experimental/traffic-portal/nightwatch/tests/servers/servers.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/servers/servers.table.spec.ts
@@ -12,13 +12,11 @@
 * limitations under the License.
 */
 
-describe("Servers Spec", () => {
+describe("Servers Table Spec", () => {
 	it("Filter by hostname", async () => {
-		await browser.page.common()
-			.section.sidebar
-			.navigateToNode("servers", ["serversContainer"]);
+		const page = browser.page.servers.serversTable();
+		await page.section.serversTable.open();
 		await browser.waitForElementPresent("input[name=fuzzControl]");
-		const page = browser.page.servers.servers();
 		page.section.serversTable.searchText("edge");
 		await page.assert.urlContains("search=edge");
 	});
diff --git a/experimental/traffic-portal/nightwatch/tests/users/users.spec.ts b/experimental/traffic-portal/nightwatch/tests/users/users.spec.ts
index 16620206eb..8f701f88b4 100644
--- a/experimental/traffic-portal/nightwatch/tests/users/users.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/users/users.spec.ts
@@ -24,7 +24,7 @@ describe("Users Spec", () => {
 		await browser.waitForElementPresent(".ag-row");
 		let tbl = page.section.usersTable;
 		if (! await tbl.getColumnState("Username")) {
-			tbl = tbl.toggleColumn("Username");
+			await tbl.toggleColumn("Username");
 		}
 
 		tbl = tbl.searchText(username);
diff --git a/experimental/traffic-portal/package-lock.json b/experimental/traffic-portal/package-lock.json
index 9eeaf4f11e..3835924eea 100644
--- a/experimental/traffic-portal/package-lock.json
+++ b/experimental/traffic-portal/package-lock.json
@@ -31,7 +31,7 @@
         "chart.js": "^2.9.4",
         "express": "^4.15.2",
         "rxjs": "~6.6.0",
-        "trafficops-types": "4.0.7",
+        "trafficops-types": "^4.0.10",
         "tslib": "^2.0.0",
         "zone.js": "~0.11.4"
       },
@@ -74,7 +74,7 @@
         "typescript": "^4.9.5"
       },
       "engines": {
-        "node": ">=16.20.0"
+        "node": ">=16.14.0"
       },
       "optionalDependencies": {
         "@compodoc/compodoc": "^1.1.18"
@@ -18214,9 +18214,9 @@
       }
     },
     "node_modules/trafficops-types": {
-      "version": "4.0.7",
-      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.7.tgz",
-      "integrity": "sha512-O5cMK33T/hVXgAC90zt/gIHZ1Yl2Lz+AS4MFcLb5s+TVWibKBB8z5mjoYeNxLBnI3xMrnEiQNqVa8Abis/AoIA=="
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.10.tgz",
+      "integrity": "sha512-/GW+PgT7Rg5WhGtbkJdvTIXLga68wx3dy9crmXmOrrB6sPVcX7vbrQ9TAxGqRbsC52ZbZSxahqsmZgQZv8KK3A=="
     },
     "node_modules/traverse": {
       "version": "0.6.7",
@@ -33479,9 +33479,9 @@
       }
     },
     "trafficops-types": {
-      "version": "4.0.7",
-      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.7.tgz",
-      "integrity": "sha512-O5cMK33T/hVXgAC90zt/gIHZ1Yl2Lz+AS4MFcLb5s+TVWibKBB8z5mjoYeNxLBnI3xMrnEiQNqVa8Abis/AoIA=="
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.10.tgz",
+      "integrity": "sha512-/GW+PgT7Rg5WhGtbkJdvTIXLga68wx3dy9crmXmOrrB6sPVcX7vbrQ9TAxGqRbsC52ZbZSxahqsmZgQZv8KK3A=="
     },
     "traverse": {
       "version": "0.6.7",
diff --git a/experimental/traffic-portal/package.json b/experimental/traffic-portal/package.json
index 7e5db4322b..ee540b9490 100644
--- a/experimental/traffic-portal/package.json
+++ b/experimental/traffic-portal/package.json
@@ -24,7 +24,7 @@
     "Traffic Portal"
   ],
   "engines": {
-    "node": ">=16.20.0"
+    "node": ">=16.14.0"
   },
   "engineStrict": true,
   "scripts": {
@@ -71,7 +71,7 @@
     "chart.js": "^2.9.4",
     "express": "^4.15.2",
     "rxjs": "~6.6.0",
-    "trafficops-types": "4.0.7",
+    "trafficops-types": "^4.0.10",
     "tslib": "^2.0.0",
     "zone.js": "~0.11.4"
   },
diff --git a/experimental/traffic-portal/src/app/api/cdn.service.spec.ts b/experimental/traffic-portal/src/app/api/cdn.service.spec.ts
index d3875c2961..e332a7cf0a 100644
--- a/experimental/traffic-portal/src/app/api/cdn.service.spec.ts
+++ b/experimental/traffic-portal/src/app/api/cdn.service.spec.ts
@@ -129,6 +129,46 @@ describe("CDNService", () => {
 		await expectAsync(responseP).toBeResolved();
 	});
 
+	it("Queues Updates by CDN", async () => {
+		const resp = service.queueServerUpdates(cdn);
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/cdns/${cdn.id}/queue_update`);
+		expect(req.request.method).toBe("POST");
+		expect(req.request.params.keys().length).toBe(0);
+		expect(req.request.body).toEqual({action: "queue"});
+		req.flush({response: { action: "queue", cdnId: cdn.id }});
+		await expectAsync(resp).toBeResolved();
+	});
+
+	it("Queues Updates by CDN ID", async () => {
+		const resp = service.queueServerUpdates(cdn.id);
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/cdns/${cdn.id}/queue_update`);
+		expect(req.request.method).toBe("POST");
+		expect(req.request.params.keys().length).toBe(0);
+		expect(req.request.body).toEqual({action: "queue"});
+		req.flush({response: { action: "queue", cdnId: cdn.id }});
+		await expectAsync(resp).toBeResolved();
+	});
+
+	it("Dequeues Updates by CDN", async () => {
+		const resp = service.dequeueServerUpdates(cdn);
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/cdns/${cdn.id}/queue_update`);
+		expect(req.request.method).toBe("POST");
+		expect(req.request.params.keys().length).toBe(0);
+		expect(req.request.body).toEqual({action: "dequeue"});
+		req.flush({response: { action: "dequeue", cdnId: cdn.id }});
+		await expectAsync(resp).toBeResolved();
+	});
+
+	it("Dequeues Updates by CDN ID", async () => {
+		const resp = service.dequeueServerUpdates(cdn.id);
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/cdns/${cdn.id}/queue_update`);
+		expect(req.request.method).toBe("POST");
+		expect(req.request.params.keys().length).toBe(0);
+		expect(req.request.body).toEqual({action: "dequeue"});
+		req.flush({response: { action: "dequeue", cdnId: cdn.id }});
+		await expectAsync(resp).toBeResolved();
+	});
+
 	afterEach(() => {
 		httpTestingController.verify();
 	});
diff --git a/experimental/traffic-portal/src/app/api/cdn.service.ts b/experimental/traffic-portal/src/app/api/cdn.service.ts
index 88ae3c5d30..799d973d01 100644
--- a/experimental/traffic-portal/src/app/api/cdn.service.ts
+++ b/experimental/traffic-portal/src/app/api/cdn.service.ts
@@ -13,7 +13,7 @@
 */
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
-import type { RequestCDN, ResponseCDN } from "trafficops-types";
+import type { CDNQueueResponse, RequestCDN, ResponseCDN } from "trafficops-types";
 
 import { APIService } from "./base-api.service";
 
@@ -78,6 +78,30 @@ export class CDNService extends APIService {
 		return this.post<ResponseCDN>("cdns", cdn).toPromise();
 	}
 
+	/**
+	 * Queue updates to servers by a CDN
+	 *
+	 * @param cdn The CDN or ID to queue from
+	 */
+	public async queueServerUpdates(cdn: ResponseCDN | number): Promise<CDNQueueResponse> {
+		const id = typeof cdn === "number" ? cdn : cdn.id;
+		const path = `cdns/${id}/queue_update`;
+		const action = {action: "queue"};
+		return this.post<CDNQueueResponse>(path, action).toPromise();
+	}
+
+	/**
+	 * Dequeue updates to servers by a CDN
+	 *
+	 * @param cdn The CDN or ID to dequeue from
+	 */
+	public async dequeueServerUpdates(cdn: ResponseCDN | number): Promise<CDNQueueResponse> {
+		const id = typeof cdn === "number" ? cdn : cdn.id;
+		const path = `cdns/${id}/queue_update`;
+		const action = {action: "dequeue"};
+		return this.post<CDNQueueResponse>(path, action).toPromise();
+	}
+
 	/**
 	 * Replaces an existing CDN with the provided new definition of a
 	 * CDN.
diff --git a/experimental/traffic-portal/src/app/api/server.service.spec.ts b/experimental/traffic-portal/src/app/api/server.service.spec.ts
index af84013733..f8c2973e58 100644
--- a/experimental/traffic-portal/src/app/api/server.service.spec.ts
+++ b/experimental/traffic-portal/src/app/api/server.service.spec.ts
@@ -14,13 +14,14 @@
  */
 import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
 import { TestBed } from "@angular/core/testing";
+import { type ResponseServer } from "trafficops-types";
 
 import { ServerService } from "./server.service";
 
 describe("ServerService", () => {
 	let service: ServerService;
 	let httpTestingController: HttpTestingController;
-	const server = {
+	const server: ResponseServer = {
 		cachegroup: "cachegroup",
 		cachegroupId: 1,
 		cdnId: 1,
@@ -43,9 +44,7 @@ describe("ServerService", () => {
 		offlineReason: null,
 		physLocation: "physicalLocation",
 		physLocationId: 1,
-		profile: "profile",
-		profileDesc: "profileDesc",
-		profileId: 1,
+		profileNames: ["profile"],
 		rack: null,
 		revalPending: false,
 		routerHostName: null,
@@ -134,6 +133,46 @@ describe("ServerService", () => {
 			req.flush({response: server});
 			await expectAsync(responseP).toBeResolvedTo(server);
 		});
+
+		it("updates a server by ID", async ()  => {
+			const resp = service.updateServer(server.id, server);
+			const req = httpTestingController.expectOne(`/api/${service.apiVersion}/servers/${server.id}`);
+			expect(req.request.method).toBe("PUT");
+			expect(req.request.body).toEqual(server);
+
+			req.flush({response: server});
+			await expectAsync(resp).toBeResolvedTo(server);
+		});
+
+		it("updates a server", async ()  => {
+			const resp = service.updateServer(server);
+			const req = httpTestingController.expectOne(`/api/${service.apiVersion}/servers/${server.id}`);
+			expect(req.request.method).toBe("PUT");
+			expect(req.request.body).toEqual(server);
+
+			req.flush({response: server});
+			await expectAsync(resp).toBeResolvedTo(server);
+		});
+
+		it("delete a server", async ()  => {
+			const resp = service.deleteServer(server);
+			const req = httpTestingController.expectOne(`/api/${service.apiVersion}/servers/${server.id}`);
+			expect(req.request.method).toBe("DELETE");
+			expect(req.request.body).toBeNull();
+
+			req.flush({response: server});
+			await expectAsync(resp).toBeResolvedTo(server);
+		});
+
+		it("delete a server by ID", async ()  => {
+			const resp = service.deleteServer(server.id);
+			const req = httpTestingController.expectOne(`/api/${service.apiVersion}/servers/${server.id}`);
+			expect(req.request.method).toBe("DELETE");
+			expect(req.request.body).toBeNull();
+
+			req.flush({response: server});
+			await expectAsync(resp).toBeResolvedTo(server);
+		});
 	});
 
 	describe("Status-related methods", () => {
diff --git a/experimental/traffic-portal/src/app/api/server.service.ts b/experimental/traffic-portal/src/app/api/server.service.ts
index 7126930085..7d1fa291d7 100644
--- a/experimental/traffic-portal/src/app/api/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/server.service.ts
@@ -101,6 +101,29 @@ export class ServerService extends APIService {
 		return this.post<ResponseServer>("servers", s).toPromise();
 	}
 
+	/**
+	 * Updates a server by the given payload
+	 *
+	 * @param serverOrID The server object or id to be deleted
+	 * @param payload The server payload to update with.
+	 */
+	public async updateServer(serverOrID: ResponseServer | number, payload?: RequestServer): Promise<ResponseServer> {
+		let id;
+		let body;
+		if (typeof(serverOrID) === "number") {
+			if(!payload) {
+				throw new TypeError("invalid call signature - missing request paylaod");
+			}
+			body = payload;
+			id = +serverOrID;
+		} else {
+			body = serverOrID;
+			id = serverOrID.id;
+		}
+
+		return this.put<ResponseServer>(`servers/${id}`, body).toPromise();
+	}
+
 	public async getServerChecks(): Promise<Servercheck[]>;
 	public async getServerChecks(id: number): Promise<Servercheck>;
 	/**
@@ -235,6 +258,19 @@ export class ServerService extends APIService {
 		return this.delete<ResponseStatus>(`statuses/${id}`).toPromise();
 	}
 
+	/**
+	 * Deletes an existing server.
+	 *
+	 * @param server The Server to be deleted, or just its ID.
+	 * @returns The deleted server.
+	 */
+	public async deleteServer(server: number | ResponseServer): Promise<ResponseServer> {
+		const id =  typeof(server) === "number" ? server : server.id;
+		const path = `servers/${id}`;
+		return this.delete<ResponseServer>(path).toPromise();
+
+	}
+
 	/**
 	 * Retrieves Server Capabilities from Traffic Ops.
 	 *
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 2bcf2afa20..a93c5cc947 100644
--- a/experimental/traffic-portal/src/app/api/testing/cdn.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/cdn.service.ts
@@ -13,7 +13,7 @@
 */
 
 import { Injectable } from "@angular/core";
-import { RequestCDN, ResponseCDN } from "trafficops-types";
+import { CDNQueueResponse, RequestCDN, ResponseCDN } from "trafficops-types";
 
 /**
  * CDNService expose API functionality relating to CDNs.
@@ -88,6 +88,39 @@ export class CDNService {
 		this.cdns.push(c);
 		return c;
 	}
+	/**
+	 * Queue updates to servers by a CDN
+	 *
+	 * @param cdn The CDN or id to queue server updates for
+	 */
+	public async queueServerUpdates(cdn: ResponseCDN | number): Promise<CDNQueueResponse> {
+		const id = typeof cdn === "number" ? cdn : cdn.id;
+		const realCDN = this.cdns.find(c => c.id === id);
+		if (!realCDN) {
+			throw new Error(`No CDN ${id}`);
+		}
+		return {
+			action: "queue",
+			cdnId: id
+		};
+	}
+
+	/**
+	 * Dequeue updates to servers by a CDN
+	 *
+	 * @param cdn The CDN or id to dequeue server updates for
+	 */
+	public async dequeueServerUpdates(cdn: ResponseCDN | number): Promise<CDNQueueResponse> {
+		const id = typeof cdn === "number" ? cdn : cdn.id;
+		const realCDN = this.cdns.find(c => c.id === id);
+		if (!realCDN) {
+			throw new Error(`No CDN ${id}`);
+		}
+		return {
+			action: "dequeue",
+			cdnId: id
+		};
+	}
 
 	/**
 	 * Replaces an existing CDN with the provided new definition of a
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 d6b74f7f6f..bda7efb260 100644
--- a/experimental/traffic-portal/src/app/api/testing/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/server.service.ts
@@ -41,7 +41,7 @@ function serverCheck(server: ResponseServer): Servercheck {
 		cacheGroup: server.cachegroup ?? "SERVER HAD NO CACHE GROUP",
 		hostName: server.hostName ?? "SERVER HAD NO HOST NAME",
 		id: server.id,
-		profile: server.profile ?? "SERVER HAD NO PROFILE",
+		profile: server.profileNames[0] ?? "SERVER HAD NO PROFILE",
 		revalPending: server.revalPending,
 		type: server.type ?? "SERVER HAD NO TYPE",
 		updPending: server.updPending
@@ -146,7 +146,7 @@ export class ServerService {
 	public async createServer(server: RequestServer): Promise<ResponseServer> {
 		const cdn = await this.cdnService.getCDNs(server.cdnId);
 		const physLoc = await this.physLocService.getPhysicalLocations(server.physLocationId);
-		const profile = await this.profileService.getProfiles(server.profileId);
+		const profile = await this.profileService.getProfiles(server.profileNames[0]);
 		const type = await this.typeService.getTypes(server.typeId);
 		const status = await this.getStatuses(server.statusId);
 		const newServer = {
@@ -185,6 +185,33 @@ export class ServerService {
 		return newServer;
 	}
 
+	/**
+	 * Updates a server by the given payload
+	 *
+	 * @param serverOrID The server object or id to be deleted
+	 * @param payload The server payload to update with.
+	 */
+	public async updateServer(serverOrID: ResponseServer | number, payload?: RequestServer): Promise<ResponseServer> {
+		let id: number;
+		let body: ResponseServer;
+		if (typeof(serverOrID) === "number") {
+			if(!payload) {
+				throw new TypeError("invalid call signature - missing request paylaod");
+			}
+			id = +serverOrID;
+			body = payload as ResponseServer;
+		} else {
+			id = serverOrID.id;
+			body = serverOrID;
+		}
+		const index = this.servers.findIndex(s => s.id === id);
+		if (index < 0) {
+			throw new Error(`Unknown server ${id}`);
+		}
+		this.servers[index] = body;
+		return this.servers[index];
+	}
+
 	public async getServerChecks(): Promise<Servercheck[]>;
 	public async getServerChecks(id: number): Promise<Servercheck>;
 	/**
@@ -460,4 +487,21 @@ export class ServerService {
 		this.capabilities.push(created);
 		return created;
 	}
+
+	/**
+	 * Deletes an existing server.
+	 *
+	 * @param server The Server to be deleted, or just its ID.
+	 * @returns The deleted server.
+	 */
+	public async deleteServer(server: number | ResponseServer): Promise<ResponseServer> {
+		const id =  typeof(server) === "number" ? server : server.id;
+		const index = this.servers.findIndex(s => s.id === id);
+		if(index < 0) {
+			throw new Error(`no such Server ${id}`);
+		}
+		const ret = this.servers[index];
+		this.servers.splice(index, 1);
+		return ret;
+	}
 }
diff --git a/experimental/traffic-portal/src/app/app.ui.module.ts b/experimental/traffic-portal/src/app/app.ui.module.ts
index f92c3310ec..714127ebd6 100644
--- a/experimental/traffic-portal/src/app/app.ui.module.ts
+++ b/experimental/traffic-portal/src/app/app.ui.module.ts
@@ -12,6 +12,7 @@
 * limitations under the License.
 */
 
+import { CdkDrag, CdkDropList } from "@angular/cdk/drag-drop";
 import { CdkMenuModule } from "@angular/cdk/menu";
 import { NgOptimizedImage } from "@angular/common";
 import { NgModule } from "@angular/core";
@@ -80,10 +81,14 @@ import { AgGridModule } from "ag-grid-angular";
 		MatTooltipModule,
 		MatTreeModule,
 		MatSlideToggleModule,
-		NgOptimizedImage
+		NgOptimizedImage,
+		CdkDrag,
+		CdkDropList
 	],
 	imports: [
-		NgOptimizedImage
+		NgOptimizedImage,
+		CdkDrag,
+		CdkDropList
 	]
 })
 export class AppUIModule {}
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts
index 93fa833903..5f4b42d098 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -160,7 +160,7 @@ export const ROUTES: Routes = [
 		SharedModule,
 		AppUIModule,
 		CommonModule,
-		RouterModule.forChild(ROUTES)
+		RouterModule.forChild(ROUTES),
 	]
 })
 export class CoreModule { }
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/_server-details-theme.scss b/experimental/traffic-portal/src/app/core/servers/server-details/_server-details-theme.scss
index 586739854f..46c23c739f 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/_server-details-theme.scss
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/_server-details-theme.scss
@@ -20,6 +20,11 @@
 	$error: map.get($color, error);
 	$background: map.get($color, background);
 	$foreground: map.get($color, foreground);
+
+	.mat-expansion-panel-body {
+		padding: 0 12px 16px !important;
+	}
+
 	tp-server-details form fieldset {
 		color: mat.get-color-from-palette($foreground, text);
 		background-color: mat.get-color-from-palette($background, background);
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
index 0bc3061f43..cd37ec7b54 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
@@ -11,17 +11,17 @@ 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 appearance="outlined">
+<mat-card appearance="outlined" class="page-content">
+	<mat-card-header class="actions-container" *ngIf="!isNew && server">
+		<button [disabled]="!isCache() || true" mat-button type="button">Manage Capabilities</button>
+		<button [disabled]="!isCache() || true" mat-button type="button">Manage Delivery Services</button>
+		<button [disabled]="!isCache()" mat-icon-button type="button" title="queue server updates" *ngIf="!server.updPending" (click)="queue()"><fa-icon [icon]="updateIcon"></fa-icon></button>
+		<button [disabled]="!isCache()" mat-icon-button type="button" title="clear server updates" *ngIf="server.updPending" (click)="dequeue()"><fa-icon [icon]="clearUpdatesIcon"></fa-icon></button>
+		<button type="button" mat-icon-button title="change server status" (click)="changeStatus($event)"><fa-icon [icon]="statusChangeIcon"></fa-icon></button>
+	</mat-card-header>
 	<tp-loading *ngIf="!server"></tp-loading>
-	<mat-card-content *ngIf="server">
-		<div class="actions-container" *ngIf="!isNew">
-			<button [disabled]="!isCache()" mat-button type="button">Manage Capabilities</button>
-			<button [disabled]="!isCache()" mat-button type="button">Manage Delivery Services</button>
-			<button [disabled]="!isCache()" mat-icon-button type="button" title="queue server updates" *ngIf="!server.updPending"><fa-icon [icon]="updateIcon"></fa-icon></button>
-			<button [disabled]="!isCache()" mat-icon-button type="button" title="clear server updates" *ngIf="server.updPending"><fa-icon [icon]="clearUpdatesIcon"></fa-icon></button>
-			<button type="button" mat-icon-button title="change server status" (click)="changeStatus($event)"><fa-icon [icon]="statusChangeIcon"></fa-icon></button>
-		</div>
-		<form ngNativeValidate #serverForm="ngForm" (ngSubmit)="submit($event)">
+	<form ngNativeValidate #serverForm="ngForm" class="triple-column-responsive" (ngSubmit)="submit($event)">
+		<mat-card-content *ngIf="server" class="container">
 			<mat-form-field>
 				<mat-label>Host Name</mat-label>
 				<input matInput [(ngModel)]="server.hostName" name="hostname" required maxlength="50"/>
@@ -35,24 +35,32 @@ limitations under the License.
 				<mat-select name="cdn" [(ngModel)]="server.cdnId" required>
 					<mat-option *ngFor="let cdn of cdns" [value]="cdn.id">{{cdn.name}}</mat-option>
 				</mat-select>
+				<mat-hint>
+					<a mat-icon-button [disabled]="!server.cdnId" class="small-icon-button" matTooltip="View CDN Details" aria-label="View CDN Details" color="primary" [routerLink]="'/core/cdns/' + server.cdnId" target="_blank">
+						<mat-icon>link</mat-icon>
+					</a>
+				</mat-hint>
 			</mat-form-field>
 			<mat-form-field>
 				<mat-label>Cache Group</mat-label>
 				<mat-select name="cachegroup" [(ngModel)]="server.cachegroupId" required>
 					<mat-option *ngFor="let cg of cacheGroups" [value]="cg.id">{{cg.name}}</mat-option>
 				</mat-select>
-			</mat-form-field>
-			<mat-form-field>
-				<mat-label>Profile</mat-label>
-				<mat-select name="profile" [(ngModel)]="server.profileId" required>
-					<mat-option *ngFor="let profile of profiles" [value]="profile.id">{{profile.name}}</mat-option>
-				</mat-select>
+				<mat-hint>
+					<a mat-icon-button [disabled]="!server.cachegroupId" class="small-icon-button" matTooltip="View Cache Group Details" aria-label="View Cache Group Details" color="primary" [href]="'/core/cache-groups/' + server.cachegroupId" target="_blank">
+						<mat-icon>link</mat-icon>
+					</a>
+				</mat-hint>
 			</mat-form-field>
 			<mat-form-field>
 				<mat-label>Physical Location</mat-label>
 				<mat-select name="physLocation" [(ngModel)]="server.physLocationId" required>
 					<mat-option *ngFor="let physLocation of physicalLocations" [value]="physLocation.id">{{physLocation.name}}</mat-option>
 				</mat-select>
+				<mat-hint>
+					<a mat-icon-button [disabled]="!server.physLocationId" class="small-icon-button" matTooltip="View Physical Location Details" aria-label="View Physical Location Details" color="primary" [href]="'/core/phys-locs/' + server.physLocationId" target="_blank">
+						<mat-icon>link</mat-icon>
+					</a></mat-hint>
 			</mat-form-field>
 			<mat-form-field>
 				<mat-label>Status</mat-label>
@@ -70,6 +78,10 @@ limitations under the License.
 				<mat-select name="type" [(ngModel)]="server.typeId" required>
 					<mat-option *ngFor="let type of types" [value]="type.id">{{type.name}}</mat-option>
 				</mat-select>
+				<mat-hint>
+					<a mat-icon-button [disabled]="!server.typeId" class="small-icon-button" color="primary" aria-label="View Type Details" matTooltip="View Type Details" [href]="'/core/types/' + server.typeId" target="_blank">
+						<mat-icon>link</mat-icon>
+					</a></mat-hint>
 			</mat-form-field>
 			<div>
 				<mat-form-field>
@@ -91,7 +103,7 @@ limitations under the License.
 			</mat-form-field>
 			<mat-form-field *ngIf="!isNew && server.lastUpdated">
 				<mat-label>Last Updated</mat-label>
-				<input matInput [value]="server.lastUpdated.toLocaleString()" disabled/>
+				<input matInput name="lastUpdated" [value]="server.lastUpdated.toLocaleString()" disabled/>
 			</mat-form-field>
 			<mat-form-field *ngIf="!isNew">
 				<mat-label>Hash ID</mat-label>
@@ -101,96 +113,159 @@ limitations under the License.
 				<mat-label>Status Last Updated</mat-label>
 				<input matInput name="statusLastUpdated" disabled [value]="server.statusLastUpdated.toLocaleString()"/>
 			</mat-form-field>
-			<fieldset>
-				<legend (click)="hideInterfaces=!hideInterfaces">Interfaces<button name="addInterfaceBtn" class="add-button" type="button" title="add a new interface" (click)="addInterface($event)"><fa-icon [icon]="addIcon"></fa-icon></button></legend>
-				<fieldset [hidden]="hideInterfaces" *ngFor="let inf of server.interfaces; index as infInd">
-					<legend>
-						<label for="{{inf.name}}-name">Name</label>
-						<input [(ngModel)]="inf.name" required aria-label="Interface name" id="{{inf.name}}-name" name="{{inf.name}}-name" class="interface-name-input"/>
-						<button class="remove-button" type="button" title="delete this interface" (click)="deleteInterface(infInd)"><fa-icon [icon]="removeIcon"></fa-icon></button>
-					</legend>
-
-					<div>
-						<mat-checkbox [labelPosition]="'before'" id="{{inf.name}}-monitor" name="{{inf.name}}-monitor" [(ngModel)]="inf.monitor">Monitor this interface</mat-checkbox>
-						<mat-form-field>
-							<mat-label><abbr title="Maximum Transmission Unit">MTU</abbr></mat-label>
-							<input matInput id="{{inf.name}}-mtu" name="{{inf.name}}-mtu" type="number" min="1500" max="9000" step="7500" [(ngModel)]="inf.mtu"/>
-						</mat-form-field>
-						<!-- <small class="input-error" ng-show="hasPropertyError(serverForm[inf.name+'-mtu'], 'min') || hasPropertyError(serverForm[inf.name+'-mtu'], 'max') || hasPropertyError(serverForm[inf.name+'-mtu'], 'step')">Invalid MTU - must be 1500 or 9000</small> -->
-						<mat-form-field>
-							<mat-label>Maximum Bandwidth</mat-label>
-							<input matInput id="{{inf.name}}-max-bandwidth" [(ngModel)]="inf.maxBandwidth" min="0" type="number" name="{{inf.name}}-max-bandwidth"/>
-							<mat-hint class="input-warning" *ngIf="inf.maxBandwidth !== 0">Setting Max Bandwidth to zero will cause cache servers to always be unavailable</mat-hint>
-						</mat-form-field>
-						<!-- <small class="input-error" ng-show="hasPropertyError(serverForm[inf.name+'-max-bandwidth'], 'min')">Cannot be negative</small> -->
+			<mat-expansion-panel [expanded]="true">
+				<mat-expansion-panel-header>
+					<mat-panel-title>
+						Profiles
+					</mat-panel-title>
+				</mat-expansion-panel-header>
+				<div class="expansion-content-profile">
+					<mat-form-field>
+						<mat-label>Profiles</mat-label>
+						<mat-select name="profiles" [(ngModel)]="server.profileNames" multiple required>
+							<mat-option *ngFor="let profile of profiles"  [value]="profile.name">{{profile.name}}</mat-option>
+						</mat-select>
+					</mat-form-field>
+					<div class="profile-order">
+						Ordering
+						<mat-list class="drop-list mat-elevation-z3" cdkDropList (cdkDropListDropped)="drop($event)">
+							<mat-list-item class="drop-list-item mat-elevation-z1" *ngFor="let profile of server.profileNames; index as i" cdkDrag>
+								{{i+1}}: {{profile}}
+								<a class="small-icon-button" aria-label="View Profile Details" matTooltip="View Profile Details" color="primary" mat-icon-button [href]="'/core/profiles/' + profileNameToId(profile)" target="_blank">
+									<mat-icon>link</mat-icon>
+								</a>
+							</mat-list-item>
+						</mat-list>
 					</div>
-
-					<fieldset>
-						<legend>IP Addresses<button name="addIPBtn" type="button" title="add new IP Address" class="add-button" (click)="addIP(inf)"><fa-icon [icon]="addIcon"></fa-icon></button></legend>
-						<div *ngFor="let ip of inf.ipAddresses; index as ipInd" [ngClass]="{'bordered-item': ipInd !== 0}">
-							<mat-checkbox [labelPosition]="'before'" name="{{inf.name}}-{{ip.address}}-serviceAddress" id="{{inf.name}}-{{ip.address}}-serviceAddress" class="service-addr-cb" [(ngModel)]="ip.serviceAddress">Is a Service Address</mat-checkbox>
-							<mat-form-field>
-								<mat-label>Address</mat-label>
-								<input matInput id="{{inf.name}}-{{ip.address}}" name="{{inf.name}}-{{ip.address}}" class="ip-input" [(ngModel)]="ip.address" [pattern]="validIPPattern" required placeholder="192.0.2.0/27" />
-							</mat-form-field>
-							<!-- <small class="input-error" ng-show="hasPropertyError(serverForm[inf.name+'-'+ip.address], 'pattern')">Invalid address</small>
-							<small class="input-error" ng-show="hasPropertyError(serverForm[inf.name+'-'+ip.address], 'required')">Required</small>
-							<small class="input-warning" ng-show="isLargeCIDR(ip.address)">Large CIDR detected. IPv4 with CIDR &lt; 27 or IPv6 with CIDR &lt; 64 can be problematic.</small> -->
-							<mat-form-field>
-								<mat-label>Gateway</mat-label>
-								<input matInput id="{{inf.name}}-{{ip.address}}-gateway" name="{{inf.name}}-{{ip.address}}-gateway" [(ngModel)]="ip.gateway" [pattern]="validGatewayPattern"/>
-							</mat-form-field>
-							<!-- <small class="input-error" ng-show="hasPropertyError(serverForm[inf.name+'-'+ip.address+'-gateway'], 'pattern')">Invalid gateway</small> -->
-							<button type="button" title="delete this IP address" class="remove-button" style="justify-self: start;" (click)="deleteIP(inf, ipInd)"><fa-icon [icon]="removeIcon"></fa-icon></button>
-						</div>
-					</fieldset>
-				</fieldset>
-			</fieldset>
-			<fieldset>
-				<legend (click)="hideILO=!hideILO"><abbr title="Integrated Lights-Out Management">ILO</abbr> Details</legend>
-				<div [hidden]="hideILO">
+				</div>
+			</mat-expansion-panel>
+			<mat-expansion-panel [expanded]="true">
+				<mat-expansion-panel-header>
+					<mat-panel-title>
+						Interfaces
+					</mat-panel-title>
+					<mat-panel-description class="expansion-description">
+						<button aria-label="Add An Interface" class="panel-button" color="primary" mat-icon-button type="button" (click)="addInterface($event)">
+							<mat-icon>add</mat-icon>
+						</button>
+					</mat-panel-description>
+				</mat-expansion-panel-header>
+				<div class="expansion-container">
+					<mat-accordion multi>
+						<mat-expansion-panel *ngFor="let inf of server.interfaces; index as infInd" [expanded]="true">
+							<mat-expansion-panel-header>
+								<mat-panel-title>
+									{{getInterfaceName(inf)}}
+								</mat-panel-title>
+								<mat-panel-description class="expansion-description">
+									<button class="panel-button" color="warn" mat-icon-button type="button" (click)="deleteInterface($event, infInd)">
+										<mat-icon>delete</mat-icon>
+									</button>
+								</mat-panel-description>
+							</mat-expansion-panel-header>
+							<div class="expansion-content">
+								<mat-form-field>
+									<mat-label>Name</mat-label>
+									<input matInput id="{{inf.name}}-name" name="{{inf.name}}-name" [(ngModel)]="inf.name" />
+								</mat-form-field>
+								<mat-form-field>
+									<mat-label><abbr title="Maximum Transmission Unit">MTU</abbr></mat-label>
+									<input matInput id="{{inf.name}}-mtu" name="{{inf.name}}-mtu" type="number" min="1500" max="9000" step="7500" [(ngModel)]="inf.mtu"/>
+								</mat-form-field>
+								<mat-form-field>
+									<mat-label>Maximum Bandwidth</mat-label>
+									<input matInput id="{{inf.name}}-max-bandwidth" [(ngModel)]="inf.maxBandwidth" min="0" type="number" name="{{inf.name}}-max-bandwidth"/>
+									<mat-hint class="input-warning" *ngIf="inf.maxBandwidth !== 0">Cache servers will be unavailable</mat-hint>
+								</mat-form-field>
+								<mat-checkbox [labelPosition]="'before'" id="{{inf.name}}-monitor" name="{{inf.name}}-monitor" [(ngModel)]="inf.monitor">Monitor this interface</mat-checkbox>
+							</div>
+							<div class="expansion-container">
+								<mat-expansion-panel [expanded]="true">
+									<mat-expansion-panel-header>
+										<mat-panel-title>
+											IP Addresses
+										</mat-panel-title>
+										<mat-panel-description class="expansion-description">
+											<button class="panel-button" color="primary" mat-icon-button type="button" (click)="addIP($event, inf)">
+												<mat-icon>add</mat-icon>
+											</button>
+										</mat-panel-description>
+									</mat-expansion-panel-header>
+									<div class="expansion-ip-content" *ngFor="let ip of inf.ipAddresses; index as ipInd">
+										<mat-checkbox [labelPosition]="'before'" name="{{inf.name}}-{{ip.address}}-serviceAddress" id="{{inf.name}}-{{ip.address}}-serviceAddress" class="service-addr-cb" [(ngModel)]="ip.serviceAddress">Is a Service Address</mat-checkbox>
+										<mat-form-field>
+											<mat-label>Address</mat-label>
+											<input matInput id="{{inf.name}}-{{ip.address}}" name="{{inf.name}}-{{ip.address}}" class="ip-input" [(ngModel)]="ip.address" [pattern]="validIPPattern" required placeholder="192.0.2.0/27" />
+										</mat-form-field>
+										<mat-form-field>
+											<mat-label>Gateway</mat-label>
+											<input matInput id="{{inf.name}}-{{ip.address}}-gateway" name="{{inf.name}}-{{ip.address}}-gateway" [(ngModel)]="ip.gateway" [pattern]="validGatewayPattern"/>
+										</mat-form-field>
+										<button mat-icon-button type="button" color="warn" title="delete this IP address" class="remove-button" (click)="deleteIP($event, inf, ipInd)"><mat-icon>delete</mat-icon></button>
+									</div>
+								</mat-expansion-panel>
+							</div>
+						</mat-expansion-panel>
+					</mat-accordion>
+				</div>
+			</mat-expansion-panel>
+			<mat-expansion-panel [expanded]="true">
+				<mat-expansion-panel-header>
+					<mat-panel-title>
+						<p><abbr title="Integrated Lights-Out Management">ILO</abbr> Details</p>
+					</mat-panel-title>
+				</mat-expansion-panel-header>
+				<div class="expansion-content">
 					<mat-form-field>
-						<mat-label><abbr title="Integrated Lights-Out Management">ILO</abbr> IP Address</mat-label>
+						<mat-label>IP Address</mat-label>
 						<input matInput name="iloIP" [(ngModel)]="server.iloIpAddress"/>
 					</mat-form-field>
 					<mat-form-field>
-						<mat-label><abbr title="Integrated Lights-Out Management">ILO</abbr> Gateway IP Address</mat-label>
+						<mat-label>Gateway IP Address</mat-label>
 						<input matInput name="iloGateway" [(ngModel)]="server.iloIpGateway"/>
 					</mat-form-field>
 					<mat-form-field>
-						<mat-label><abbr title="Integrated Lights-Out Management">ILO</abbr> IP Netmask</mat-label>
+						<mat-label>IP Netmask</mat-label>
 						<input matInput name="iloNetmask" [(ngModel)]="server.iloIpNetmask"/>
 					</mat-form-field>
 					<mat-form-field>
-						<mat-label><abbr title="Integrated Lights-Out Management">ILO</abbr> Username</mat-label>
+						<mat-label>Username</mat-label>
 						<input matInput name="iloUsername" [(ngModel)]="server.iloUsername"/>
 					</mat-form-field>
 					<mat-form-field>
-						<mat-label><abbr title="Integrated Lights-Out Management">ILO</abbr> Password</mat-label>
+						<mat-label>Password</mat-label>
 						<tp-obscured-text-input name="iloPassword" [autocomplete]="autocompleteNew" [(value)]="server.iloPassword"></tp-obscured-text-input>
 					</mat-form-field>
 				</div>
-			</fieldset>
-			<fieldset>
-				<legend (click)="hideManagement=!hideManagement">Management Interface Details</legend>
-				<div [hidden]="hideManagement">
+			</mat-expansion-panel>
+			<mat-expansion-panel [expanded]="true">
+				<mat-expansion-panel-header>
+					<mat-panel-title>
+						<p>Management Interface Details</p>
+					</mat-panel-title>
+				</mat-expansion-panel-header>
+				<div class="expansion-content">
 					<mat-form-field>
-						<mat-label>Management IP Address</mat-label>
+						<mat-label>IP Address</mat-label>
 						<input matInput name="mgmtIP" [(ngModel)]="server.mgmtIpAddress"/>
+						<mat-icon matSuffix [matTooltipClass]="'multi-line-tooltip'" [matTooltip]="'The IP Address for the server\'s management interface\n\nDeprecated:\nThis field has been deprecated and will be removed in a future version of Traffic Control'">help_outline</mat-icon>
 					</mat-form-field>
 					<mat-form-field>
-						<mat-label>Management Gateway IP Address</mat-label>
+						<mat-label>Gateway IP Address</mat-label>
 						<input matInput name="mgmtIpGateway" [(ngModel)]="server.mgmtIpGateway"/>
+						<mat-icon matSuffix [matTooltipClass]="'multi-line-tooltip'" [matTooltip]="'The IPv4 Gateway for the server\'s management interface\n\nDeprecated:\nThis field has been deprecated and will be removed in a future version of Traffic Control'">help_outline</mat-icon>
 					</mat-form-field>
 					<mat-form-field>
-						<mat-label>Management IP Netmask</mat-label>
+						<mat-label>IP Netmask</mat-label>
 						<input matInput name="mgmtIpNetmask" [(ngModel)]="server.mgmtIpNetmask"/>
+						<mat-icon matSuffix [matTooltipClass]="'multi-line-tooltip'" [matTooltip]="'The IPv4 Netmask for the server\'s management interface\n\nDeprecated:\nThis field has been deprecated and will be removed in a future version of Traffic Control'">help_outline</mat-icon>
 					</mat-form-field>
 				</div>
-			</fieldset>
-			<div class="buttons">
-				<button mat-raised-button type="submit">Submit</button>
-			</div>
-		</form>
-	</mat-card-content>
+			</mat-expansion-panel>
+		</mat-card-content>
+		<mat-card-actions align="end">
+			<button mat-raised-button type="button" aria-label="Delete Server" *ngIf="!isNew" color="warn" (click)="delete()">Delete</button>
+			<button mat-raised-button type="submit" aria-label="Submit Server" color="primary">Submit</button>
+		</mat-card-actions>
+	</form>
 </mat-card>
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.scss b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.scss
index 577cb5a1ae..98fb894d50 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.scss
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.scss
@@ -11,139 +11,120 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-
-.mat-mdc-card {
-	width: fit-content;
-	margin: 15px auto auto;
-	min-width: 685px;
-}
+@import "../../../../styles/vars";
 
 .actions-container {
-	width: 85vw;
-	margin: 25px auto 0;
-	display: flex;
 	justify-content: flex-end;
-	padding-right: 1em;
 
-	button {
-		border: none;
-		background: none;
-		font-size: large;
+	button[mat-icon-button] {
+		padding: 0;
+		height: 36px;
 	}
 }
 
-form {
-	display: grid;
-	grid-template-columns: 1fr 1fr 1fr;
-	grid-column-gap: 15px;
-	grid-row-gap: 1em;
-	width: 85vw;
-	margin: 1em auto 50px;
-
-	small {
-		grid-column: 2;
-		justify-self: start;
+.drop-list-item {
+	.small-icon-button {
+		float: right;
+	}
+}
 
-		&.input-warning {
-			border-radius: 4px;
+@include small-icon-button();
 
-			&::before {
-				content: "⚠";
-			}
-		}
+form {
+	.mat-mdc-card-actions .mdc-button {
+		margin: 0 8px;
 	}
 
-	output {
-		border: 1px solid darkgray;
-		padding: 2px 3px;
-		background-color: white;
-		display: inline-block;
-		appearance: textfield;
-		background-color: -moz-field;
-		box-shadow: 1px 1px 1px 0 lightgray inset;
-		font: -moz-field;
-		font: -webkit-small-control;
+	.cdk-drag-preview {
+		box-sizing: border-box;
+		border-radius: 4px;
+		box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
+		0 8px 10px 1px rgba(0, 0, 0, 0.14),
+		0 3px 14px 2px rgba(0, 0, 0, 0.12);
 	}
 
-	legend {
-		font-size: 14px;
-		font-weight: bold;
-		margin-bottom: 0px;
-		border: 1px solid #ddd;
-		border-radius: 4px;
-		padding: 5px 5px 5px 10px;
-		width: 99%;
-		user-select: none;
+	.cdk-drag-placeholder {
+		opacity: 0;
 	}
 
-	div {
-		grid-template-columns: 1fr 1fr;
-		grid-column-gap: 5px;
-		display: grid;
+	.cdk-drag-animating {
+		transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
 	}
 
-	fieldset {
-		border: 1px solid #ddd;
-		border-radius: 4px;
+	.mat-expansion-panel {
 		grid-column: 1 / -1;
 
-		button {
-			float: right;
-			color: white;
-			padding: 1px 5px;
-			font-size: 12px;
-			line-height: 1.5;
-			border-radius: 3px;
-			text-align: center;
+		.expansion-container {
+			display: grid;
+			grid-template-columns: 1fr;
 		}
 
-		fieldset {
-			margin-top: 10px;
-
-			div {
-				grid-template-columns: auto 1fr 1fr auto;
-			}
-
-			input {
-				margin-left: 5px;
-			}
+		.expansion-content {
+			display: grid;
+			grid-template-columns: 1fr 1fr 1fr;
+			grid-column-gap: 15px;
+		}
 
-			.mat-mdc-checkbox {
-				margin: auto;
-			}
+		.expansion-ip-content {
+			display: grid;
+			grid-template-columns: auto 1fr 1fr auto;
+			grid-column-gap: 10px;
 		}
 
-		legend {
-			cursor: pointer;
+		.expansion-description {
+			justify-content: end;
 		}
 
-		li {
+		.expansion-content-profile {
 			display: grid;
-			grid-template-columns: auto 1fr;
-			grid-column-gap: 10px;
-			grid-row-gap: 0.75em;
+			grid-template-columns: 1fr 1fr;
+			grid-column-gap: 15px;
+
+			.mat-mdc-form-field {
+				height: fit-content;
+			}
 
-			&.bordered-item {
-				border-top: 1px solid gray;
-				margin-top: 10px;
-				padding-top: 10px;
+			div.profile-order {
+				display: grid;
+				grid-template-columns: 1fr;
+
+				.drop-list {
+					display: grid;
+					grid-template-columns: 1fr;
+					padding: 0;
+
+					&:last-child {
+						border: none;
+						box-shadow: none;
+					}
+
+					.drop-list.cdk-drop-list-dragging .drop-list-item:not(.cdk-drag-placeholder) {
+						transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+					}
+				}
 			}
 		}
+	}
 
-		div {
-			width: 100%;
-			display: grid;
-			grid-template-columns: 1fr 1fr 1fr;
-			grid-column-gap: 15px;
-			grid-row-gap: 1em;
+	small {
+		grid-column: 2;
+		justify-self: start;
+
+		&.input-warning {
+			border-radius: 4px;
 
-			&[hidden] {
-				display: none;
-				visibility: hidden;
+			&::before {
+				content: "⚠";
 			}
 		}
 	}
 
+	div {
+		grid-template-columns: 1fr 1fr;
+		grid-column-gap: 5px;
+		display: grid;
+	}
+
 	.buttons {
 		grid-column-end: -1;
 		justify-self: end;
@@ -160,7 +141,11 @@ form {
 	form {
 		grid-template-columns: 1fr;
 
-		fieldset div,  {
+		.mat-expansion-panel .expansion-content-profile {
+			grid-template-columns: 1fr;
+		}
+
+		fieldset div, .mat-expansion-panel .expansion-content {
 			grid-template-columns: 1fr 1fr;
 		}
 	}
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
index 6aa18221fc..685c8a831a 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
@@ -15,6 +15,7 @@
 import { HttpClientModule } from "@angular/common/http";
 import { type ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
 import { FormsModule, ReactiveFormsModule } from "@angular/forms";
+import { MatDialog, MatDialogRef } from "@angular/material/dialog";
 import { MatFormFieldModule } from "@angular/material/form-field";
 import { MatInputModule } from "@angular/material/input";
 import { MatSelectModule } from "@angular/material/select";
@@ -43,7 +44,7 @@ describe("ServerDetailsComponent", () => {
 				HttpClientModule,
 				RouterTestingModule.withRoutes([
 					{component: ServerDetailsComponent, path: "server/:id"},
-					{component: ServerDetailsComponent, path: "server/new"}
+					{component: ServerDetailsComponent, path: "server/new"},
 				]),
 				FormsModule,
 				ReactiveFormsModule,
@@ -78,7 +79,7 @@ describe("ServerDetailsComponent", () => {
 			mgmtIpNetmask: null,
 			offlineReason: null,
 			physLocationId: 1,
-			profileId: 1,
+			profileNames: ["GLOBAL"],
 			statusId: 1,
 			typeId: 1
 		});
@@ -106,22 +107,22 @@ describe("ServerDetailsComponent", () => {
 		expect(component.server.interfaces.length).toBe(1);
 		component.addInterface(new MouseEvent("click"));
 		expect(component.server.interfaces.length).toBe(2);
-		component.deleteInterface(1);
+		component.deleteInterface(new MouseEvent("click"), 1);
 		expect(component.server.interfaces.length).toBe(1);
-		component.deleteInterface(0);
+		component.deleteInterface(new MouseEvent("click"), 0);
 		expect(component.server.interfaces.length).toBe(0);
 	});
 
 	it("adds and removes IP addresses to/from an interface", () => {
 		component.addInterface(new MouseEvent("click"));
 		expect(component.server.interfaces[0].ipAddresses.length).toBe(0);
-		component.addIP(component.server.interfaces[0]);
+		component.addIP(new MouseEvent("click"), component.server.interfaces[0]);
 		expect(component.server.interfaces[0].ipAddresses.length).toBe(1);
-		component.addIP(component.server.interfaces[0]);
+		component.addIP(new MouseEvent("click"), component.server.interfaces[0]);
 		expect(component.server.interfaces[0].ipAddresses.length).toBe(2);
-		component.deleteIP(component.server.interfaces[0], 1);
+		component.deleteIP(new MouseEvent("click"), component.server.interfaces[0], 1);
 		expect(component.server.interfaces[0].ipAddresses.length).toBe(1);
-		component.deleteIP(component.server.interfaces[0], 0);
+		component.deleteIP(new MouseEvent("click"), component.server.interfaces[0], 0);
 		expect(component.server.interfaces[0].ipAddresses.length).toBe(0);
 	});
 
@@ -157,16 +158,15 @@ describe("ServerDetailsComponent", () => {
 	}));
 
 	it("opens the 'change status' dialog", () => {
-		expect(component.changeStatusDialogOpen).toBeFalse();
-		component.changeStatus(new MouseEvent("click"));
-		expect(component.changeStatusDialogOpen).toBeTrue();
+		const mockMatDialog = TestBed.inject(MatDialog);
+		const openSpy = spyOn(mockMatDialog, "open").and.returnValue({
+			afterClosed: () => of(true)
+		} as MatDialogRef<unknown>);
 		component.isNew = true;
 		expect(() => component.changeStatus(new MouseEvent("click"))).toThrow();
-	});
-
-	it("closes the 'change status' dialog when done", () => {
-		component.changeStatusDialogOpen = true;
-		component.doneUpdatingStatus(true);
-		expect(component.changeStatusDialogOpen).toBeFalse();
+		expect(openSpy).not.toHaveBeenCalled();
+		component.isNew = false;
+		component.changeStatus(new MouseEvent("click"));
+		expect(openSpy).toHaveBeenCalled();
 	});
 });
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 cbd99f220d..89b497adcd 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
@@ -12,7 +12,9 @@
 * limitations under the License.
 */
 
+import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
 import { Component, OnInit } from "@angular/core";
+import { MatDialog } from "@angular/material/dialog";
 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";
@@ -29,6 +31,11 @@ import type {
 
 import { CacheGroupService, CDNService, PhysicalLocationService, ProfileService, TypeService } from "src/app/api";
 import { ServerService } from "src/app/api/server.service";
+import { UpdateStatusComponent } from "src/app/core/servers/update-status/update-status.component";
+import {
+	DecisionDialogComponent,
+	DecisionDialogData
+} from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 import { IP, IP_WITH_CIDR, AutocompleteValue } from "src/app/utils";
 
@@ -58,24 +65,6 @@ export class ServerDetailsComponent implements OnInit {
 	 * A Regular Expression that matches valid IP addresses.
 	 */
 	public validGatewayPattern = IP;
-	/**
-	 * Controls whether or not the "change status" dialog is open
-	 */
-	public changeStatusDialogOpen = false;
-
-	/**
-	 * Tracks whether ILO details should be hidden.
-	 */
-	public hideILO = false;
-	/**
-	 * Tracks whether management interface details should be hidden.
-	 */
-	public hideManagement = false;
-	/**
-	 * Tracks whether network interface details should be hidden.
-	 */
-	public hideInterfaces = false;
-
 	/**
 	 * Icon for adding to a collection.
 	 */
@@ -144,7 +133,8 @@ export class ServerDetailsComponent implements OnInit {
 		private readonly profileService: ProfileService,
 		private readonly typeService: TypeService,
 		private readonly physlocService: PhysicalLocationService,
-		private readonly navSvc: NavigationService
+		private readonly navSvc: NavigationService,
+		private readonly dialog: MatDialog
 	) {
 	}
 
@@ -152,7 +142,6 @@ export class ServerDetailsComponent implements OnInit {
 	 * Initializes the controller based on route query parameters.
 	 */
 	public ngOnInit(): void {
-
 		const handleErr = (obj: string): (e: unknown) => void =>
 			(e: unknown): void => {
 				console.error(`Failed to get ${obj}:`, e);
@@ -202,7 +191,7 @@ export class ServerDetailsComponent implements OnInit {
 			this.serverService.getServers(Number(ID)).then(
 				s => {
 					this.server = s;
-					this.navSvc.headerTitle.next(`Server #${this.server.id}`);
+					this.updateTitlebar();
 				}
 			).catch(
 				e => {
@@ -210,18 +199,67 @@ export class ServerDetailsComponent implements OnInit {
 				}
 			);
 		} else {
-			this.server.interfaces = [{
-				ipAddresses: [{
-					address: "",
-					gateway: null,
-					serviceAddress: true
+			this.server = {
+				cachegroup: "",
+				cachegroupId: 0,
+				cdnId: 0,
+				cdnName: "",
+				domainName: "",
+				guid: null,
+				hostName: "",
+				httpsPort: null,
+				id: 0,
+				iloIpAddress: null,
+				iloIpGateway: null,
+				iloIpNetmask: null,
+				iloPassword: null,
+				iloUsername: null,
+				interfaces: [{
+					ipAddresses: [{
+						address: "",
+						gateway: null,
+						serviceAddress: true
+					}],
+					maxBandwidth: null,
+					monitor: true,
+					mtu: null,
+					name: "",
 				}],
-				maxBandwidth: null,
-				monitor: false,
-				mtu: null,
-				name: "",
-			}];
+				lastUpdated: new Date(),
+				mgmtIpAddress: null,
+				mgmtIpGateway: null,
+				mgmtIpNetmask: null,
+				offlineReason: null,
+				physLocation: "",
+				physLocationId: 0,
+				profileNames: [],
+				rack: null,
+				revalPending: false,
+				routerHostName: null,
+				routerPortName: null,
+				status: "",
+				statusId: 0,
+				statusLastUpdated: null,
+				tcpPort: null,
+				type: "",
+				typeId: 0,
+				updPending: false,
+				xmppId: ""
+			};
+			this.updateTitlebar();
+		}
+	}
+
+	/**
+	 * Updates the headerTitle based on current server state.
+	 *
+	 * @private
+	 */
+	private updateTitlebar(): void {
+		if (this.isNew) {
 			this.navSvc.headerTitle.next("New Server");
+		} else {
+			this.navSvc.headerTitle.next(`Server: ${this.server.hostName}`);
 		}
 	}
 
@@ -247,7 +285,78 @@ export class ServerDetailsComponent implements OnInit {
 					console.error("failed to create server:", err);
 				}
 			);
+		} else {
+			this.serverService.updateServer(this.server).then(
+				responseServer => {
+					this.server = responseServer;
+					this.updateTitlebar();
+				},
+				err => {
+					console.error(`failed to update server: ${err}`);
+				}
+			);
+		}
+	}
+	/**
+	 * Deletes the Server.
+	 */
+	public delete(): void {
+		if (this.isNew) {
+			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 Server ${this.server.hostName} (#${this.server.id})?`,
+					title: "Confirm Delete"
+				}
+			}
+		);
+		ref.afterClosed().subscribe(result => {
+			if (result) {
+				this.serverService.deleteServer(this.server);
+				this.router.navigate(["core/servers"]);
+			}
+		});
+	}
+
+	/**
+	 * Handles when a profile list item is 'dropped'
+	 *
+	 * @param $event The Drop event that is emitted.
+	 */
+	public drop($event: CdkDragDrop<string[]>): void {
+		moveItemInArray(this.server.profileNames, $event.previousIndex, $event.currentIndex);
+	}
+
+	/**
+	 * Queues updates for the server
+	 */
+	public async queue(): Promise<void> {
+		this.serverService.queueUpdates(this.server).then(result => {
+			if(result.action === "queue") {
+				this.server.updPending = true;
+			}
+		},
+		err => {
+			console.error(`failed to queue updates: ${err}`);
+		});
+	}
+
+	/**
+	 * Dequeues updates for the server
+	 */
+	public async dequeue(): Promise<void> {
+		this.serverService.clearUpdates(this.server).then(result => {
+			if(result.action === "dequeue") {
+				this.server.updPending = false;
+			}
+		},
+		err => {
+			console.error(`failed to dequeue updates: ${err}`);
+		});
 	}
 
 	/**
@@ -268,12 +377,34 @@ export class ServerDetailsComponent implements OnInit {
 		this.server.interfaces.push(newInf);
 	}
 
+	/**
+	 * Returns a user-friendly name for an interface.
+	 *
+	 * @param inf The Interface to get the name from
+	 * @returns Friendly interface name
+	 */
+	public getInterfaceName(inf: Interface): string {
+		return inf.name === "" ? "<un-named>" : inf.name;
+	}
+
+	/**
+	 * Finds the ID of a given profile name
+	 *
+	 * @param profileName The profileName to find the id of.
+	 * @returns Profile id
+	 */
+	public profileNameToId(profileName: string): number {
+		return (this.profiles.find(p => p.name === profileName) ?? {id: -1}).id;
+	}
+
 	/**
 	 * Adds a new IP address to the server.
 	 *
+	 * @param event The triggering DOM event; its propagation is stopped.
 	 * @param inf The specific network interface to which to add the new IP address.
 	 */
-	public addIP(inf: Interface): void {
+	public addIP(event: MouseEvent, inf: Interface): void {
+		event.stopPropagation();
 		inf.ipAddresses.push({
 			address: "",
 			gateway: null,
@@ -284,19 +415,23 @@ export class ServerDetailsComponent implements OnInit {
 	/**
 	 * Removes an IP address from the server.
 	 *
+	 * @param event The triggering DOM event; its propagation is stopped.
 	 * @param inf The specific network interface from which to remove an IP address.
 	 * @param ip The index in the `ipAddresses` of `inf` to delete.
 	 */
-	public deleteIP(inf: Interface, ip: number): void {
+	public deleteIP(event: MouseEvent, inf: Interface, ip: number): void {
+		event.stopPropagation();
 		inf.ipAddresses.splice(ip, 1);
 	}
 
 	/**
 	 * Removes a network interface from the server.
 	 *
+	 * @param e The triggering DOM event; its propagation is stopped.
 	 * @param inf The index of the interface to remove.
 	 */
-	public deleteInterface(inf: number): void {
+	public deleteInterface(e: MouseEvent, inf: number): void {
+		e.stopPropagation();
 		this.server.interfaces.splice(inf, 1);
 	}
 
@@ -323,29 +458,19 @@ export class ServerDetailsComponent implements OnInit {
 		if (this.isNew) {
 			throw new Error("cannot update the status of a server that doesn't exist yet");
 		}
-		this.changeStatusDialogOpen = true;
-	}
-
-	/**
-	 * Handles the completion of a server update, closing the dialog and updating the view if necessary.
-	 *
-	 * @param reload Whether or not the server was actually changed (and thus needs to be reloaded)
-	 */
-	public doneUpdatingStatus(reload: boolean): void {
-		this.changeStatusDialogOpen = false;
-		if (this.isNew || !this.server.id) {
-			console.error("done fired on server with no ID");
-			return;
-		}
-		if (reload) {
-			this.serverService.getServers(this.server.id).then(
-				s => this.server = s
-			).catch(
-				e => {
-					console.error("Failed to reload servers:", e);
-				}
-			);
-		}
+		const ref = this.dialog.open(UpdateStatusComponent, {
+			data: [this.server]
+		});
+		ref.afterClosed().subscribe(res => {
+			if (res) {
+				this.serverService.getServers(this.server.id).then(
+					s => this.server = s
+				).catch(
+					err => {
+						console.error("Failed to reload servers:", err);
+					}
+				);
+			}
+		});
 	}
-
 }
diff --git a/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.html b/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.html
index cc133b3273..586477ca2c 100644
--- a/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.html
+++ b/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.html
@@ -11,18 +11,21 @@ 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.
 -->
-<main>
-	<mat-card appearance="outlined" class="table-page-content">
-		<div class="search-container">
-			<input type="search" name="fuzzControl" aria-label="Fuzzy Search Servers" inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()"/>
-		</div>
-		<tp-generic-table
-			[data]="servers | async"
-			[cols]="columnDefs"
-			[fuzzySearch]="fuzzySubject"
-			context="servers"
-			[contextMenuItems]="contextMenuItems"
-			(contextMenuAction)="handleContextMenu($event)">
-		</tp-generic-table>
-	</mat-card>
-</main>
+<mat-card appearance="outlined" class="page-content">
+	<div class="search-container">
+		<input type="search" name="fuzzControl" aria-label="Fuzzy Search Servers" inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()"/>
+	</div>
+	<tp-generic-table
+		[data]="servers | async"
+		[doubleClickLink]="doubleClickLink"
+		[cols]="columnDefs"
+		[fuzzySearch]="fuzzySubject"
+		context="servers"
+		[moreMenuButtons]="moreMenuButtons"
+		(moreMenuButtonAction)="handleMoreMenu($event)"
+		[contextMenuItems]="contextMenuItems"
+		(contextMenuAction)="handleContextMenu($event)">
+	</tp-generic-table>
+</mat-card>
+
+<a class="page-fab" mat-fab title="Create a new Server" *ngIf="auth.hasPermission('SERVER:CREATE')" routerLink="new"><mat-icon>add</mat-icon></a>
diff --git a/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.spec.ts b/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.spec.ts
index 055c3ac191..a813c184e2 100644
--- a/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.spec.ts
@@ -82,9 +82,7 @@ const defaultServer: ResponseServer = {
 	offlineReason: null,
 	physLocation: "",
 	physLocationId: 1,
-	profile: "",
-	profileDesc: "",
-	profileId: 1,
+	profileNames: ["GLOBAL"],
 	rack: null,
 	revalPending: false,
 	routerHostName: null,
diff --git a/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.ts b/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.ts
index 72eb466a4d..d1bea8b259 100644
--- a/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/servers-table/servers-table.component.ts
@@ -13,16 +13,25 @@
 */
 
 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 { ITooltipParams } from "ag-grid-community";
 import { BehaviorSubject } from "rxjs";
-import { type ResponseServer, serviceAddresses } from "trafficops-types";
+import { ResponseCDN, type ResponseServer, serviceAddresses } from "trafficops-types";
 
-import { ServerService } from "src/app/api";
+import { CDNService, ServerService } from "src/app/api";
 import { UpdateStatusComponent } from "src/app/core/servers/update-status/update-status.component";
-import type { ContextMenuActionEvent, ContextMenuItem } from "src/app/shared/generic-table/generic-table.component";
+import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import {
+	CollectionChoiceDialogComponent, CollectionChoiceDialogData
+} from "src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component";
+import type {
+	ContextMenuActionEvent,
+	ContextMenuItem,
+	DoubleClickLink,
+	TableTitleButton
+} from "src/app/shared/generic-table/generic-table.component";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
 
 /**
@@ -76,7 +85,9 @@ export class ServersTableComponent implements OnInit {
 
 	/** All of the servers which should appear in the table. */
 	public servers: Promise<Array<AugmentedServer>> | null = null;
-	// public servers: Array<Server>;
+
+	/** 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 = [
@@ -278,6 +289,23 @@ export class ServersTableComponent implements OnInit {
 		}
 	];
 
+	/** Definitions for the more menu buttons */
+	public moreMenuButtons: Array<TableTitleButton> = [
+		{
+			action: "queue",
+			text: "Queue Server Updates"
+		},
+		{
+			action: "dequeue",
+			text: "Clear Server Updates"
+		}
+	];
+
+	/** Defines what the table should do when a row is double-clicked. */
+	public doubleClickLink: DoubleClickLink<AugmentedServer> = {
+		href: (row: AugmentedServer): string => `/core/servers/${row.id}`
+	};
+
 	/** Definitions for the context menu items (which act on augmented server data). */
 	public contextMenuItems: Array<ContextMenuItem<AugmentedServer>> = [
 		{
@@ -320,23 +348,17 @@ export class ServersTableComponent implements OnInit {
 	public fuzzySubject: BehaviorSubject<string>;
 
 	/** Form controller for the user search input. */
-	public fuzzControl: UntypedFormControl = new UntypedFormControl("");
+	public fuzzControl: FormControl = new FormControl("");
 
-	/**
-	 * Constructs the component with its required injections.
-	 *
-	 * @param api The Servers API which is used to provide row data.
-	 * @param route A reference to the route of this view which is used to set the fuzzy search box text from the 'search' query parameter.
-	 * @param router Angular router
-	 * @param navSvc Manages the header
-	 * @param dialog Dialog manager
-	 */
 	constructor(private readonly api: ServerService,
+		public readonly auth: CurrentUserService,
 		private readonly route: ActivatedRoute,
 		private readonly navSvc: NavigationService,
+		private readonly cdn: CDNService,
 		private readonly dialog: MatDialog) {
 		this.fuzzySubject = new BehaviorSubject<string>("");
 		this.navSvc.headerTitle.next("Servers");
+		this.cdns = this.cdn.getCDNs();
 	}
 
 	/** Initializes table data, loading it from Traffic Ops. */
@@ -360,6 +382,38 @@ export class ServersTableComponent implements OnInit {
 		this.fuzzySubject.next(this.fuzzControl.value);
 	}
 
+	/**
+	 * Handles user selection of a more menu action button.
+	 *
+	 * @param action The emitted more menu button action event.
+	 */
+	public async handleMoreMenu(action: string): Promise<void> {
+		const data: CollectionChoiceDialogData<number> = {
+			collection: (await this.cdns).map(cdn => ({label: cdn.name, value: cdn.id})),
+			label: "Please Select a CDN",
+			message: "",
+			title: "Queue Server Updates"
+		};
+		switch(action) {
+			case "dequeue":
+				data.title = "Clear Server Updates";
+			case "queue":
+				const ref = this.dialog.open<CollectionChoiceDialogComponent, CollectionChoiceDialogData<number>, number | false>(
+					CollectionChoiceDialogComponent,
+					{data}
+				);
+				const result = await ref.afterClosed().toPromise();
+				if (typeof(result) === "number") {
+					if (data.title.indexOf("Clear") > -1) {
+						await this.cdn.dequeueServerUpdates(result);
+					} else {
+						await this.cdn.queueServerUpdates(result);
+					}
+				}
+				break;
+		}
+	}
+
 	/**
 	 * Handles user selection of a context menu action item.
 	 *
diff --git a/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.html b/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.html
index 0677d96f39..1b9d913f23 100644
--- a/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.html
+++ b/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.html
@@ -16,9 +16,9 @@ limitations under the License.
 	<div mat-dialog-content>
 		<mat-form-field appearance="fill" *ngIf="true">
 			<mat-label>New Status</mat-label>
-			<select name="status" [(ngModel)]="status" required matNativeControl>
-				<option *ngFor="let status of statuses" [value]="status" [disabled]="status.id === currentStatus">{{status.name}}</option>
-			</select>
+			<mat-select name="status" [(ngModel)]="status" required>
+				<mat-option *ngFor="let s of statuses" [value]="s" [disabled]="s.id === currentStatus">{{s.name}}</mat-option>
+			</mat-select>
 		</mat-form-field>
 		<mat-form-field *ngIf="isOffline" appearance="fill">
 			<mat-label>Offline Reason</mat-label>
diff --git a/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.spec.ts b/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.spec.ts
index 4fe28be8f0..5472db66ff 100644
--- a/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.spec.ts
@@ -22,7 +22,7 @@ import { APITestingModule } from "src/app/api/testing";
 
 import { UpdateStatusComponent } from "./update-status.component";
 
-const defaultServer = {
+const defaultServer: ResponseServer = {
 	cachegroup: "",
 	cachegroupId: 1,
 	cdnId: 1,
@@ -59,9 +59,7 @@ const defaultServer = {
 	offlineReason: null,
 	physLocation: "",
 	physLocationId: 1,
-	profile: "",
-	profileDesc: "",
-	profileId: 1,
+	profileNames: ["GLOBAL"],
 	rack: null,
 	revalPending: false,
 	routerHostName: null,
diff --git a/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.ts b/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.ts
index f3aff528bb..4f8d0627b7 100644
--- a/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/update-status/update-status.component.ts
@@ -40,7 +40,8 @@ export class UpdateStatusComponent implements OnInit {
 
 	/** Tells whether the user's selected status is considered "OFFLINE". */
 	public get isOffline(): boolean {
-		return this.status !== null && this.status !== undefined && this.status.name !== "ONLINE" && this.status.name !== "REPORTED";
+		return this.status !== null && this.status !== undefined &&
+			this.status.name !== "ONLINE" && this.status.name !== "REPORTED";
 	}
 
 	/** An appropriate title for the server or collection of servers being updated. */
@@ -100,7 +101,7 @@ export class UpdateStatusComponent implements OnInit {
 		let observables;
 		if (this.isOffline) {
 			observables = this.servers.map(
-				async x=>this.api.updateStatus(x, this.status?.name ?? "", this.offlineReason)
+				async x=> this.api.updateStatus(x, this.status?.name ?? "", this.offlineReason)
 			);
 		} else {
 			observables = this.servers.map(async x=>this.api.updateStatus(x, this.status?.name ?? ""));
diff --git a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.html b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.html
index 20dbf15054..5309d0427a 100644
--- a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.html
+++ b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.html
@@ -16,19 +16,31 @@ limitations under the License.
 	<button mat-flat-button type="button" title="de-select all rows" (click)="selectAll(true)">De-Select All</button>
 	<button mat-flat-button type="button" title="clear all active filters" (click)="clearFilters()"><mat-icon>filter_list</mat-icon> Clear</button>
 	<button mat-flat-button type="button"*ngFor="let btn of tableTitleButtons" (click)="emitTitleButtonAction(btn.action)">{{btn.text}}</button>
-	<button mat-flat-button type="button" (click)="download()" title="save as CSV"><fa-icon [icon]="downloadIcon"></fa-icon></button>
-	<button mat-flat-button type="button" (click)="gridAPI.sizeColumnsToFit()">Resize</button>
 	<div class="toggle-columns" role="group" title="Select Table Columns">
-		<button type="button" mat-flat-button [matMenuTriggerFor]="menu">
+		<button type="button" mat-flat-button [matMenuTriggerFor]="menu" #trigger="matMenuTrigger">
 			<fa-icon [icon]="columnsIcon"></fa-icon>&nbsp;
-			<fa-icon [icon]="caretIcon" class="caret" [ngClass]="{'rotate': showMenu}"></fa-icon>
+			<fa-icon [icon]="caretIcon" class="caret" [ngClass]="{'rotate': trigger.menuOpen}"></fa-icon>
 		</button>
 		<mat-menu #menu="matMenu">
-			<button type="button" mat-menu-item *ngFor="let c of columns" (click)="toggleVisibility($event, c.getColId())">
-				<mat-checkbox [checked]="c.isVisible()" (click)="$event.preventDefault()" [name]="c.getColDef().headerName">
-					{{c.getColDef().headerName}}
-				</mat-checkbox>
-			</button>
+			<div class="column-menu">
+				<button type="button" mat-menu-item *ngFor="let c of columns" (click)="toggleVisibility($event, c.getColId())">
+					<mat-checkbox [checked]="c.isVisible()" [name]="c.getColDef().headerName">
+						{{c.getColDef().headerName}}
+					</mat-checkbox>
+				</button>
+			</div>
+		</mat-menu>
+	</div>
+	<div class="toggle-columns" role="group" title="Extra Table Actions">
+		<button type="button" mat-flat-button [matMenuTriggerFor]="extraMenu" #extraTrigger="matMenuTrigger">
+			More
+			<fa-icon [icon]="caretIcon" [ngClass]="{'rotate': extraTrigger.menuOpen}" class="caret"></fa-icon>
+		</button>
+		<mat-menu #extraMenu="matMenu">
+			<button mat-menu-item type="button" *ngFor="let btn of moreMenuButtons" (click)="emitMoreButtonAction(btn.action)">{{btn.text}}</button>
+			<mat-divider></mat-divider>
+			<button mat-menu-item type="button" (click)="download()" title="save as CSV">Export Grid</button>
+			<button mat-menu-item type="button" (click)="gridAPI.sizeColumnsToFit()">Resize</button>
 		</mat-menu>
 	</div>
 </div>
diff --git a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.scss b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.scss
index b8d6f0ad52..b59d0c7e3e 100644
--- a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.scss
+++ b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.scss
@@ -13,13 +13,13 @@
 */
 
 ag-grid-angular {
-	width: 80vw;
+	width: 100%;
 	margin: auto;
 	height: 85vh;
 }
 
 .extra-actions {
-	width: 80vw;
+	width: 100%;
 	display: flex;
 	margin: auto;
 
@@ -44,8 +44,11 @@ ag-grid-angular {
 	}
 }
 
+.column-menu {
+	max-height: 40vh;
+}
+
 .toggle-columns {
-	// margin-left: auto;
 	position: relative;
 
 	menu {
@@ -89,6 +92,7 @@ ag-grid-angular {
 
 	button {
 		fa-icon.caret {
+			display: inline-block;
 			line-height: 30px;
 			transition: 0.5s;
 
diff --git a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
index 219d1f89d4..3b75bdfbdc 100644
--- a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
+++ b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
@@ -132,6 +132,28 @@ interface ContextMenuLink<T> {
 /** ContextMenuItems represent items in a context menu. They can be links or arbitrary actions. */
 export type ContextMenuItem<T> = ContextMenuAction<T> | ContextMenuLink<T>;
 
+/**
+ * Specifies what happens when a row in the grid is double-clicked.
+ */
+export interface DoubleClickLink<T> {
+	/**
+	 * If present, this method will be called to determine if the double click should be
+	 * ignored.
+	 *
+	 * @param data The selected data which can be used to make the
+	 * determination. This will be a single item if a single item is selected,
+	 * or an array if multiple are selected.
+	 * @param api A reference to the Grid's API - which must be checked for
+	 * initialization, unfortunately.
+	 */
+	disabled?: (selection: T | Array<T>) => boolean;
+	/**
+	 * href is inserted literally as the 'href' property of an anchor. Which means that if it's not relative it will be mangled for security
+	 * reasons.
+	 */
+	href: string | ((selectedRow: T) => string);
+}
+
 /** ContextMenuActionEvent is emitted by the GenericTableComponent when an action in its context menu was clicked. */
 export interface ContextMenuActionEvent<T> {
 	/** action is the 'action' property of the clicked action. */
@@ -278,7 +300,6 @@ export function setUpQueryParamFilter<T>(params: ParamMap, columns: ColDef<T>[],
 				break;
 		}
 		filter.setModel(filterModel);
-		// filter.applyModel();
 	}
 	api.onFilterChanged();
 }
@@ -305,10 +326,16 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 	@Input() public contextMenuItems: readonly ContextMenuItem<Readonly<T>>[] = [];
 	/** Optionally a set of additional table title buttons. */
 	@Input() public tableTitleButtons: Array<TableTitleButton> = [];
+	/** Optionally a set of additional more menu buttons. */
+	@Input() public moreMenuButtons: Array<TableTitleButton> = [];
+	/** Optionally a link that determines the action when double-clicking a grid row */
+	@Input() public doubleClickLink: DoubleClickLink<T> | undefined;
 	/** Emits when context menu actions are clicked. Type safety is the host's responsibility! */
 	@Output() public contextMenuAction = new EventEmitter<ContextMenuActionEvent<T>>();
 	/** Emits when title button actions are clicked. Type safety is the host's responsibility! */
 	@Output() public tableTitleButtonAction = new EventEmitter<string>();
+	/** Emits when more menu title button actions are clicked. Type safety is the host's responsibility! */
+	@Output() public moreMenuButtonAction = new EventEmitter<string>();
 
 	public isAction = isAction;
 
@@ -325,7 +352,6 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 	public clickOutside(e: MouseEvent): void {
 		e.stopPropagation();
 		this.showContextMenu = false;
-		this.menuClicked = false;
 	}
 
 	/** This holds a reference to the table's selected data, which is emitted on context menu action clicks. */
@@ -353,9 +379,6 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 	/** Used to handle the case that Angular loads faster than AG-Grid (as it usually does) */
 	private initialize = true;
 
-	/** Tracks whether the menu button has been clicked. */
-	private menuClicked = false;
-
 	/** Tells whether or not to show the cell context menu. */
 	public showContextMenu = false;
 
@@ -407,6 +430,19 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 			suppressContextMenu: true,
 			tooltipShowDelay: 500
 		};
+		this.gridOptions.onRowDoubleClicked = (e): void => {
+			if (this.doubleClickLink !== undefined) {
+				if (!this.doubleClickLink?.disabled) {
+					let href = "";
+					if (typeof (this.doubleClickLink.href) === "string") {
+						href = this.doubleClickLink.href;
+					} else {
+						href = this.doubleClickLink.href(e.data);
+					}
+					this.router.navigate([href]);
+				}
+			}
+		};
 	}
 
 	/**
@@ -671,12 +707,6 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 	public toggleMenu(e: Event): void {
 		e.stopPropagation();
 		this.showContextMenu = false;
-		this.menuClicked = !this.menuClicked;
-	}
-
-	/** This tracks whether the column visibility menu is/should be open. */
-	public get showMenu(): boolean {
-		return this.menuClicked && (this.columnAPI ? true : false);
 	}
 
 	/** This is the styling of the table's context menu. */
@@ -709,8 +739,6 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 			return;
 		}
 
-		this.menuClicked = false;
-
 		if (!this.contextmenu) {
 			console.warn("element reference to 'contextmenu' still null after view init");
 			return;
@@ -797,6 +825,15 @@ export class GenericTableComponent<T> implements OnInit, OnDestroy {
 		this.tableTitleButtonAction.emit(action);
 	}
 
+	/**
+	 * Handles when the user clicks on a more menu title button action item by emitting the proper data.
+	 *
+	 * @param action The action that was clicked.
+	 */
+	public emitMoreButtonAction(action: string): void {
+		this.moreMenuButtonAction.emit(action);
+	}
+
 	/**
 	 * Downloads the table data as a CSV file.
 	 */
diff --git a/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.scss b/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.scss
index ed43621983..80bd35ab84 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.scss
+++ b/experimental/traffic-portal/src/app/shared/navigation/tp-header/tp-header.component.scss
@@ -11,10 +11,11 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-mat-toolbar {
+
+.mat-toolbar {
 	display: inline-flex;
 	width: 100%;
-	height: 3em;
+	height: var(--toolbar-height);
 	padding: 5px;
 	position: sticky;
 	top: 0;
diff --git a/experimental/traffic-portal/src/app/shared/navigation/tp-sidebar/tp-sidebar.component.scss b/experimental/traffic-portal/src/app/shared/navigation/tp-sidebar/tp-sidebar.component.scss
index d1a6b76aa7..010c4c03dc 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/tp-sidebar/tp-sidebar.component.scss
+++ b/experimental/traffic-portal/src/app/shared/navigation/tp-sidebar/tp-sidebar.component.scss
@@ -11,11 +11,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-mat-sidenav-container {
-	height: calc(100% - 3.9em);
 
-	mat-sidenav {
+.mat-sidenav-container {
+	height: calc(100% - (var(--toolbar-height) + .9em));
+
+	.mat-sidenav {
 		min-width: 10em;
+		max-width: var(--sidebar-max-width);
 
 		.mat-icon {
 			margin-right: 0;
diff --git a/experimental/traffic-portal/src/styles.scss b/experimental/traffic-portal/src/styles.scss
index fdc0032522..ea5af5ebbe 100644
--- a/experimental/traffic-portal/src/styles.scss
+++ b/experimental/traffic-portal/src/styles.scss
@@ -32,15 +32,15 @@ $traffic-portal-warn: mat.define-palette(mat.$orange-palette, 500);
 $traffic-portal-success: mat.define-palette(mat.$teal-palette, 300);
 $traffic-portal-error: mat.define-palette(mat.$deep-orange-palette, 600);
 $traffic-portal-theme: mat.define-light-theme((
-  color: (
-    primary: $traffic-portal-primary,
-    accent: $traffic-portal-accent,
-    warn: $traffic-portal-warn,
-  )
+	color: (
+		primary: $traffic-portal-primary,
+		accent: $traffic-portal-accent,
+		warn: $traffic-portal-warn,
+	)
 ));
 
 $traffic-portal-theme: set-ag-grid($traffic-portal-theme, (
-	odd-row-background-color: map-get(mat.$gray-palette,100),
+	odd-row-background-color: map-get(mat.$gray-palette, 100),
 	row-border-color: #e2e2e2,
 	border-color: #e2e2e2
 ));
@@ -59,6 +59,7 @@ $traffic-portal-theme: add-extra-colors($traffic-portal-theme, (
 	font-display: swap;
 	src: url(./assets/Roboto.300.ttf) format('truetype');
 }
+
 @font-face {
 	font-family: 'Roboto';
 	font-style: normal;
@@ -66,6 +67,7 @@ $traffic-portal-theme: add-extra-colors($traffic-portal-theme, (
 	font-display: swap;
 	src: url(./assets/Roboto.400.ttf) format('truetype');
 }
+
 @font-face {
 	font-family: 'Roboto';
 	font-style: normal;
@@ -100,7 +102,98 @@ body {
 	overflow-x: hidden;
 }
 
-.mat-mdc-button-base {
+:root {
+	--toolbar-height: 3em;
+	--sidebar-max-width: 25vw;
+	--content-padding: 8vw;
+}
+
+.multi-line-tooltip {
+	white-space: pre-line;
+}
+
+.mat-mdc-hint > a {
+	text-decoration: none;
+}
+
+.page-content {
+	min-width: 640px;
+	width: calc(100% - var(--content-padding));
+	max-width: 1080px;
+	margin: 1em auto;
+
+	& > div.search-container {
+		width: 50%;
+		margin: auto;
+		padding-right: 10px;
+		position: sticky;
+		top: 0;
+		z-index: 2;
+
+		input[type="search"] {
+			width: 100%;
+			margin: 0 0 15px;
+		}
+
+		@media(max-width: 1230px) {
+			& {
+				width: 75%;
+			}
+		}
+
+	}
+}
+
+.single-column {
+	.container {
+		display: grid;
+		grid-template-columns: 1fr;
+		grid-row-gap: 2em;
+		width: 100%;
+		margin: 1em auto 2em;
+	}
+}
+
+.double-column-responsive {
+	.container {
+		display: grid;
+		grid-template-columns: 1fr 1fr;
+		grid-column-gap: 15px;
+		grid-row-gap: 1em;
+		width: 100%;
+		margin: 1em auto 2em;
+
+		@media(max-width: 840px) {
+			& {
+				grid-template-columns: 1fr;
+			}
+		}
+	}
+}
+
+.triple-column-responsive {
+	.container {
+		display: grid;
+		grid-template-columns: 1fr 1fr 1fr;
+		grid-column-gap: 15px;
+		grid-row-gap: 1em;
+		margin: 1em auto 2em;
+
+		@media(max-width: 1230px) {
+			& {
+				grid-template-columns: 1fr 1fr;
+			}
+		}
+
+		@media(max-width: 840px) {
+			& {
+				grid-template-columns: 1fr;
+			}
+		}
+	}
+}
+
+.mat-button-base {
 	// cursor: pointer;
 	text-transform: uppercase;
 }
@@ -119,8 +212,14 @@ h1, h2, h3, h4, h5, h6, label, legend {
 	cursor: pointer;
 }
 
-html, body { height: 100%; }
-body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
+html, body {
+	height: 100%;
+}
+
+body {
+	margin: 0;
+	font-family: Roboto, "Helvetica Neue", sans-serif;
+}
 
 button {
 	cursor: pointer;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceInvalidationJobs.ts b/experimental/traffic-portal/src/styles/vars.scss
similarity index 60%
copy from experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceInvalidationJobs.ts
copy to experimental/traffic-portal/src/styles/vars.scss
index e9e3f80812..fc290cbdcd 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/deliveryServices/deliveryServiceInvalidationJobs.ts
+++ b/experimental/traffic-portal/src/styles/vars.scss
@@ -1,4 +1,4 @@
-/*
+/*!
  * 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
@@ -12,21 +12,30 @@
  * limitations under the License.
  */
 
-import {
-	EnhancedPageObject
-} from "nightwatch";
+@mixin small-icon-button {
+	.small-icon-button {
+		width: 24px;
+		height: 24px;
+		padding: 0px;
+		align-items: center;
+		justify-content: center;
+		display: inline-flex;
 
-/**
- * Define the type for our PO
- */
-export type DeliveryServiceInvalidPageObject = EnhancedPageObject<{}, typeof deliveryServiceInvalidPageObject.elements>;
+		& > *[role=img] {
+			width: 16px;
+			height: 16px;
+			font-size: 16px;
+
+			svg {
+				width: 16px;
+				height: 16px;
+			}
+		}
 
-const deliveryServiceInvalidPageObject = {
-	elements: {
-		addButton: {
-			selector: "button#new"
+		.mat-mdc-button-touch-target {
+			width: 24px;
+			height: 24px;
 		}
 	}
-};
+}
 
-export default deliveryServiceInvalidPageObject;