Repository: apache/trafficcontrol
Pull request #7375 (2023-03-01 22:06:44)

Basic ISO generation page/form
Adds a page/view/form for generating ISO system images to TPv2

<hr/>

## Which Traffic Control components are affected by this PR?
- Traffic Portal (experimental v2)

## What is the best way to verify this PR?
Run the provided tests. You can't actually generate an ISO in any demo/testing/development environment we provide, so manual testing would be a pain (also why there's no e2e tests).

## PR submission checklist
- [x] This PR has tests
- [x] This PR has documentation
- [x] This PR has a CHANGELOG.md entry
- [x] This PR **DOES NOT FIX A SERIOUS SECURITY VULNERABILITY**


Patch:
diff --git a/dev/traffic_ops/Dockerfile b/dev/traffic_ops/Dockerfile
index 5e68bf2ef..50ffe7140 100644
--- a/dev/traffic_ops/Dockerfile
+++ b/dev/traffic_ops/Dockerfile
@@ -39,4 +39,9 @@ RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2
 COPY .pgpass /root/.pgpass
 RUN chmod 0600 /root/.pgpass
 
+# These versions don't actually do anything - because the dev env can't make ISO
+# system images - but having them defined anyway helps when testing related
+# functionality, to a point.
+RUN mkdir -p /var/www/files/ && echo '{"CentOS 7": "centos7", "Rocky Linux 8": "rocky8"}' > /var/www/files/osversions.json
+
 CMD $TC/dev/traffic_ops/run.sh
diff --git a/experimental/traffic-portal/.eslintrc.json b/experimental/traffic-portal/.eslintrc.json
index 65a76ad4f..4311f5802 100644
--- a/experimental/traffic-portal/.eslintrc.json
+++ b/experimental/traffic-portal/.eslintrc.json
@@ -314,6 +314,7 @@
 				"no-else-return": "error",
 				"no-empty": "error",
 				"no-extra-bind": "error",
+				"no-fallthrough": "off",
 				"no-invalid-this": "off",
 				"no-multiple-empty-lines": [
 					"error",
diff --git a/experimental/traffic-portal/angular.json b/experimental/traffic-portal/angular.json
index b0c039769..8986905c4 100644
--- a/experimental/traffic-portal/angular.json
+++ b/experimental/traffic-portal/angular.json
@@ -101,9 +101,6 @@
 					"builder": "@angular-devkit/build-angular:karma",
 					"options": {
 						"main": "src/test.ts",
-						"codeCoverageExclude": [
-							"src/app/api/**/*"
-						],
 						"polyfills": "src/polyfills.ts",
 						"tsConfig": "tsconfig.spec.json",
 						"karmaConfig": "karma.conf.js",
diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts
index 73b4df34a..75f22f4a9 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -16,6 +16,7 @@ import * as https from "https";
 
 import axios, { 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";
 import type { CacheGroupDetailPageObject } from "nightwatch/page_objects/cacheGroups/cacheGroupDetails";
 import type { CacheGroupsPageObject } from "nightwatch/page_objects/cacheGroups/cacheGroupsTable";
@@ -84,6 +85,7 @@ declare module "nightwatch" {
 			regionDetail: () => RegionDetailPageObject;
 			regionsTable: () => RegionsPageObject;
 			asnsTable: () => AsnsPageObject;
+			asnDetail: () => AsnDetailPageObject;
 		};
 		deliveryServices: {
 			deliveryServiceCard: () => DeliveryServiceCardPageObject;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/asnDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/asnDetail.ts
new file mode 100644
index 000000000..5ed7516fa
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/asnDetail.ts
@@ -0,0 +1,43 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EnhancedPageObject } from "nightwatch";
+
+/**
+ * Defines the PageObject for ASN Details.
+ */
+export type AsnDetailPageObject = EnhancedPageObject<{}, typeof asnDetailPageObject.elements>;
+
+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']"
+		}
+	},
+};
+
+export default asnDetailPageObject;
diff --git a/experimental/traffic-portal/package-lock.json b/experimental/traffic-portal/package-lock.json
index 257ff4fc5..44069b799 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.6",
+        "trafficops-types": "^4.0.7",
         "tslib": "^2.0.0",
         "zone.js": "~0.11.4"
       },
@@ -17179,9 +17179,9 @@
       }
     },
     "node_modules/trafficops-types": {
-      "version": "4.0.6",
-      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.6.tgz",
-      "integrity": "sha512-RuZX5FnQuPAF1UdF3sdte1AXgji5SyFt/wOqzMak68CjlzCj9Ld7QBgMmkGAqeE796qc9lmDMxbJdmSsr5cuiQ=="
+      "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=="
     },
     "node_modules/traverse": {
       "version": "0.6.7",
@@ -31143,9 +31143,9 @@
       }
     },
     "trafficops-types": {
-      "version": "4.0.6",
-      "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.6.tgz",
-      "integrity": "sha512-RuZX5FnQuPAF1UdF3sdte1AXgji5SyFt/wOqzMak68CjlzCj9Ld7QBgMmkGAqeE796qc9lmDMxbJdmSsr5cuiQ=="
+      "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=="
     },
     "traverse": {
       "version": "0.6.7",
diff --git a/experimental/traffic-portal/package.json b/experimental/traffic-portal/package.json
index aa8875d80..ed7563bef 100644
--- a/experimental/traffic-portal/package.json
+++ b/experimental/traffic-portal/package.json
@@ -71,7 +71,7 @@
     "chart.js": "^2.9.4",
     "express": "^4.15.2",
     "rxjs": "~6.6.0",
-    "trafficops-types": "^4.0.6",
+    "trafficops-types": "^4.0.7",
     "tslib": "^2.0.0",
     "zone.js": "~0.11.4"
   },
diff --git a/experimental/traffic-portal/src/app/api/base-api.service.spec.ts b/experimental/traffic-portal/src/app/api/base-api.service.spec.ts
new file mode 100644
index 000000000..9d0d0f74a
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/base-api.service.spec.ts
@@ -0,0 +1,26 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { hasAlerts } from "./base-api.service";
+
+describe("utilities provided to API services", () => {
+	it("correctly determines whether something is an alerts response", () => {
+		expect(hasAlerts({})).toBeFalse();
+		expect(hasAlerts({alerts: []})).toBeTrue();
+		expect(hasAlerts({alerts:[{}]})).toBeFalse();
+		expect(hasAlerts({alerts: [{level: "success", text: "succeeded"}]})).toBeTrue();
+		expect(hasAlerts({alerts: [{level: "success", text: "succeeded"}, null]})).toBeFalse();
+	});
+});
diff --git a/experimental/traffic-portal/src/app/api/base-api.service.ts b/experimental/traffic-portal/src/app/api/base-api.service.ts
index e0394a7d5..0edfe8380 100644
--- a/experimental/traffic-portal/src/app/api/base-api.service.ts
+++ b/experimental/traffic-portal/src/app/api/base-api.service.ts
@@ -15,9 +15,42 @@
 import { HttpClient, HttpHeaders } from "@angular/common/http";
 import type { Observable } from "rxjs";
 import { map } from "rxjs/operators";
+import type { Alert } from "trafficops-types";
 
 import { environment } from "src/environments/environment";
 
+import { hasProperty, isArray } from "../utils";
+
+/**
+ * Checks if something is an Alert.
+ *
+ * @param x The thing to check.
+ * @returns `true` if `x` is an Alert (or at least close enough), `false`
+ * otherwise.
+ */
+function isAlert(x: unknown): x is Alert {
+	if (typeof(x) !== "object" || !x) {
+		return false;
+	}
+
+	return hasProperty(x, "level", "string") && hasProperty(x, "text", "string");
+}
+
+/**
+ * Checks if an arbitrary object parsed from a response body is Alerts. This is
+ * useful for methods that typically return non-JSON data - except in the event
+ * of failures.
+ *
+ * @param x The object to check.
+ * @returns `true` if `x` has an `alerts` array, `false` otherwise.
+ */
+export function hasAlerts(x: object): x is ({alerts: Alert[]}) {
+	if (!hasProperty(x, "alerts")) {
+		return false;
+	}
+	return isArray(x.alerts, isAlert);
+}
+
 /**
  * This is the base class from which all other API classes inherit.
  */
@@ -128,8 +161,6 @@ export abstract class APIService {
 			params,
 			...this.defaultOptions
 		};
-		// TODO pass alerts to the alert service
-		// (TODO create the alert service)
 		return this.http.request<{response: T}>(method, `/api/${this.apiVersion}/${path.replace(/^\/+/, "")}`, options).pipe(map(
 			r => {
 				if (!r.body) {
diff --git a/experimental/traffic-portal/src/app/api/cache-group.service.ts b/experimental/traffic-portal/src/app/api/cache-group.service.ts
index 632e58ae2..21d903f45 100644
--- a/experimental/traffic-portal/src/app/api/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/cache-group.service.ts
@@ -14,6 +14,7 @@
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
 import type {
+	RequestASN,
 	ResponseASN,
 	RequestDivision,
 	ResponseDivision,
@@ -428,14 +429,35 @@ export class CacheGroupService extends APIService {
 	}
 
 	/**
-	 * Deletes an existing asn.
+	 * Replaces the current definition of a ASN with the one given.
 	 *
-	 * @param asnOrId Id of the asn to delete.
-	 * @returns The deleted asn.
+	 * @param asn The new ASN.
+	 * @returns The updated ASN.
 	 */
-	public async deleteASN(asnOrId: number | ResponseASN): Promise<void> {
-		const id = typeof(asnOrId) === "number" ? asnOrId : asnOrId.id;
-		await this.delete("asns/", undefined, { id : String(id) }).toPromise();
+	public async updateASN(asn: ResponseASN): Promise<ResponseASN> {
+		const path = `asns/${asn.id}`;
+		return this.put<ResponseASN>(path, asn).toPromise();
+	}
+
+	/**
+	 * Creates a new ASN.
+	 *
+	 * @param asn The ASN to create.
+	 * @returns The created ASN.
+	 */
+	public async createASN(asn: RequestASN): Promise<ResponseASN> {
+		return this.post<ResponseASN>("asns", asn).toPromise();
+	}
+
+	/**
+	 * Deletes an existing ASN.
+	 *
+	 * @param asn The ASN to be deleted or ID of the ASN to delete.
+	 * @returns The deleted ASN.
+	 */
+	public async deleteASN(asn: ResponseASN | number): Promise<void> {
+		const id = typeof(asn) === "number" ? asn : asn.id;
+		return this.delete(`asns/${id}`).toPromise();
 	}
 
 	constructor(http: HttpClient) {
diff --git a/experimental/traffic-portal/src/app/api/index.ts b/experimental/traffic-portal/src/app/api/index.ts
index b8c6e7edd..82da8fd2d 100644
--- a/experimental/traffic-portal/src/app/api/index.ts
+++ b/experimental/traffic-portal/src/app/api/index.ts
@@ -22,6 +22,7 @@ import { CacheGroupService } from "./cache-group.service";
 import { CDNService } from "./cdn.service";
 import { DeliveryServiceService } from "./delivery-service.service";
 import { InvalidationJobService } from "./invalidation-job.service";
+import { MiscAPIsService } from "./misc-apis.service";
 import { PhysicalLocationService } from "./physical-location.service";
 import { ProfileService } from "./profile.service";
 import { ServerService } from "./server.service";
@@ -33,6 +34,7 @@ export * from "./cdn.service";
 export * from "./change-logs.service";
 export * from "./delivery-service.service";
 export * from "./invalidation-job.service";
+export * from "./misc-apis.service";
 export * from "./physical-location.service";
 export * from "./profile.service";
 export * from "./server.service";
@@ -53,6 +55,7 @@ export * from "./user.service";
 		ChangeLogsService,
 		DeliveryServiceService,
 		InvalidationJobService,
+		MiscAPIsService,
 		PhysicalLocationService,
 		ProfileService,
 		ServerService,
diff --git a/experimental/traffic-portal/src/app/api/misc-apis.service.spec.ts b/experimental/traffic-portal/src/app/api/misc-apis.service.spec.ts
new file mode 100644
index 000000000..b3b190dbb
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/misc-apis.service.spec.ts
@@ -0,0 +1,135 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { HttpClient } from "@angular/common/http";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { TestBed } from "@angular/core/testing";
+import { throwError } from "rxjs";
+import { type Alert, AlertLevel } from "trafficops-types";
+
+import { AlertService } from "../shared/alert/alert.service";
+
+import { MiscAPIsService } from "./misc-apis.service";
+
+const body = {
+	dhcp: "yes" as const,
+	disk: "sda",
+	domainName: "domain-name",
+	hostName: "host-name",
+	interfaceMtu: 0,
+	osVersionDir: "centos7",
+	rootPass: "",
+};
+
+describe("MiscAPIsService", () => {
+	let service: MiscAPIsService;
+	let httpTestingController: HttpTestingController;
+	let alert: Alert | null;
+
+	beforeEach(() => {
+		TestBed.configureTestingModule({
+			imports: [HttpClientTestingModule],
+			providers: [
+				MiscAPIsService,
+				{provide: AlertService, useValue: {
+					newAlert: (a: Alert): void => {
+						alert = a;
+					}
+				}}
+			]
+		});
+		service = TestBed.inject(MiscAPIsService);
+		httpTestingController = TestBed.inject(HttpTestingController);
+		alert = null;
+	});
+
+	it("should be created", () => {
+		expect(service).toBeTruthy();
+	});
+
+	it("sends requests for OS Versions", async () => {
+		const responseP = service.getISOOSVersions();
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/osversions`);
+		expect(req.request.method).toBe("GET");
+		const data = {
+			response: {
+				// eslint-disable-next-line @typescript-eslint/naming-convention
+				"CentOS 7": "centos7",
+				// eslint-disable-next-line @typescript-eslint/naming-convention
+				"Rocky Linux 8": "rocky8"
+			}
+		};
+		req.flush(data);
+		await expectAsync(responseP).toBeResolvedTo(data.response);
+	});
+
+	it("sends requests for ISO generation blobs", async () => {
+		const responseP = service.generateISO(body);
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/isos`);
+		expect(req.request.method).toBe("POST");
+		expect(req.request.body).toEqual(body);
+		const response = new Blob();
+		req.flush(response);
+		await expectAsync(responseP).toBeResolvedTo(response);
+	});
+
+	it("throws an error when TO gives back an empty ISO", async () => {
+		const responseP = service.generateISO(body);
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/isos`);
+		expect(req.request.method).toBe("POST");
+		expect(req.request.body).toEqual(body);
+		req.flush(null);
+		await expectAsync(responseP).toBeRejected();
+	});
+
+	it("parses JSON-encoded error alerts when TO responds with an error", async () => {
+		expect(alert).toBeNull();
+		const responseP = service.generateISO(body);
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/isos`);
+		expect(req.request.method).toBe("POST");
+		expect(req.request.body).toEqual(body);
+		const errAlert = {
+			level: AlertLevel.ERROR,
+			text: "something wicked happened"
+		};
+		req.flush(new Blob([JSON.stringify({alerts: [errAlert]})]), {status: 500, statusText: "Internal Server Error"});
+		await expectAsync(responseP).toBeRejectedWithError("POST isos failed with status 500 Internal Server Error");
+		expect(alert).toEqual(errAlert);
+	});
+
+	it("handles invalid JSON body error responses", async () => {
+		expect(alert).toBeNull();
+		const responseP = service.generateISO(body);
+		const req = httpTestingController.expectOne(`/api/${service.apiVersion}/isos`);
+		expect(req.request.method).toBe("POST");
+		expect(req.request.body).toEqual(body);
+		req.flush(new Blob(['{"this": "json is" invalid}']), {status: 500, statusText: "Internal Server Error"});
+		await expectAsync(responseP).toBeRejectedWithError("POST isos failed with status 500 Internal Server Error");
+		expect(alert).toBeNull();
+	});
+
+	it("handles non-HTTP errors", async () => {
+		const httpService = TestBed.inject(HttpClient);
+		const spy = spyOn(httpService, "request").and.returnValue(throwError(new Error("something wicked happened")));
+		expect(alert).toBeNull();
+		const responseP = service.generateISO(body);
+		await expectAsync(responseP).toBeRejectedWithError(/^POST isos failed: unknown error occurred:/);
+		expect(spy).toHaveBeenCalled();
+		expect(alert).toBeNull();
+	});
+
+	afterEach(() => {
+		httpTestingController.verify();
+	});
+});
diff --git a/experimental/traffic-portal/src/app/api/misc-apis.service.ts b/experimental/traffic-portal/src/app/api/misc-apis.service.ts
new file mode 100644
index 000000000..3ff154e2f
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/misc-apis.service.ts
@@ -0,0 +1,86 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { HttpClient, HttpErrorResponse } from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import type { ISORequest, OSVersions } from "trafficops-types";
+
+import { AlertService } from "../shared/alert/alert.service";
+
+import { APIService, hasAlerts } from "./base-api.service";
+
+/**
+ * This service implements APIs that aren't specific to any given ATC object.
+ * They can provide things like system information, access to the wacky external
+ * tooling provided by TO, basically anything that doesn't fit in a different
+ * API service.
+ */
+@Injectable()
+export class MiscAPIsService extends APIService{
+
+	constructor(http: HttpClient, private readonly alertsService: AlertService) {
+		super(http);
+	}
+
+	/**
+	 * Retrieves the operating system versions that can be used to generate
+	 * system images through the Traffic Ops API.
+	 *
+	 * @returns A mapping of human-friendly operating system names to
+	 * machine-readable OS IDs that can be used in subsequent requests to
+	 * {@link MiscAPIsService.generateISO}.
+	 */
+	public async getISOOSVersions(): Promise<OSVersions> {
+		return this.get<OSVersions>("osversions").toPromise();
+	}
+
+	/**
+	 * Generates a system image.
+	 *
+	 * @param spec The specifications used to define what kind of image Traffic
+	 * Ops will generate.
+	 * @returns The generated system image.
+	 */
+	public async generateISO(spec: ISORequest): Promise<Blob> {
+		const options = {
+			body: spec,
+			...this.defaultOptions,
+			responseType: "blob" as const
+		};
+		let response;
+		try {
+			response = await this.http.request("post", `/api/${this.apiVersion}/isos`, options).toPromise();
+		} catch (e) {
+			if (e instanceof HttpErrorResponse) {
+				try {
+					const body = JSON.parse(await e.error.text());
+					if (hasAlerts(body)) {
+						body.alerts.forEach(a => this.alertsService.newAlert(a));
+					}
+				} catch (innerError) {
+					console.error("during handling request failure, encountered an error trying to parse error-level alerts:", innerError);
+				}
+				throw new Error(`POST isos failed with status ${e.status} ${e.statusText}`);
+			}
+			throw new Error(`POST isos failed: unknown error occurred: ${e}`);
+		}
+		if (!response.body) {
+			throw new Error(`POST isos returned no response body - ${response.status} ${response.statusText}`);
+		}
+		if (response.body.type !== "application/octet-stream") {
+			console.warn("data returned by TO for ISO generation is of unrecognized MIME type", response.body.type);
+		}
+		return response.body;
+	}
+}
diff --git a/experimental/traffic-portal/src/app/app.ui.module.ts b/experimental/traffic-portal/src/app/app.ui.module.ts
index 8294eae00..dfb6fd846 100644
--- a/experimental/traffic-portal/src/app/app.ui.module.ts
+++ b/experimental/traffic-portal/src/app/app.ui.module.ts
@@ -32,6 +32,7 @@ import { MatMenuModule } from "@angular/material/menu";
 import { MatRadioModule } from "@angular/material/radio";
 import { MatSelectModule } from "@angular/material/select";
 import { MatSidenavModule } from "@angular/material/sidenav";
+import { MatSlideToggleModule } from "@angular/material/slide-toggle";
 import { MatSnackBarModule } from "@angular/material/snack-bar";
 import { MatStepperModule } from "@angular/material/stepper";
 import { MatToolbarModule } from "@angular/material/toolbar";
@@ -76,7 +77,8 @@ import { AgGridModule } from "ag-grid-angular";
 		MatStepperModule,
 		MatToolbarModule,
 		MatTooltipModule,
-		MatTreeModule
+		MatTreeModule,
+		MatSlideToggleModule,
 	]
 })
 export class AppUIModule {}
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.html b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.html
new file mode 100644
index 000000000..64565c879
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.html
@@ -0,0 +1,42 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<mat-card>
+	<tp-loading *ngIf="!asn"></tp-loading>
+	<form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="asn">
+		<mat-card-content>
+			<mat-form-field *ngIf="!new">
+				<mat-label>ID</mat-label>
+				<input matInput type="text" name="id" disabled readonly [defaultValue]="asn.id" />
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>ASN</mat-label>
+				<input matInput type="number" name="asn" required [(ngModel)]="asn.asn" />
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Cache Group</mat-label>
+				<mat-select name="cachegroup"[(ngModel)]="asn.cachegroupId" required>
+					<mat-option *ngFor="let cachegroup of cachegroups" [value]="cachegroup.id">{{cachegroup.name}}</mat-option>
+				</mat-select>
+			</mat-form-field>
+			<mat-form-field *ngIf="!new">
+				<mat-label>Last Updated</mat-label>
+				<input matInput type="text" name="lastUpdated" disabled readonly [defaultValue]="asn.lastUpdated" /> </mat-form-field>
+		</mat-card-content>
+		<mat-card-actions align="end">
+			<button mat-raised-button type="button" *ngIf="!new" color="warn" (click)="deleteAsn()">Delete</button>
+			<button mat-raised-button type="submit" color="primary">Save</button>
+		</mat-card-actions>
+	</form>
+</mat-card>
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.scss b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.scss
similarity index 97%
rename from experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.scss
rename to experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.scss
index f4746e2a2..fdfbde765 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.scss
+++ b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.scss
@@ -11,6 +11,7 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
+
 mat-card {
 	margin: 1em auto;
 	width: 80%;
@@ -19,7 +20,7 @@ mat-card {
 	mat-card-content {
 		display: grid;
 		grid-template-columns: 1fr;
-		grid-row-gap: 2em;
+		row-gap: 2em;
 		margin: 1em auto 50px;
 	}
 }
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.spec.ts b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.spec.ts
new file mode 100644
index 000000000..10f2a3492
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.spec.ts
@@ -0,0 +1,77 @@
+/*
+* 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 { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatDialogModule } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { ReplaySubject } from "rxjs";
+
+import { APITestingModule } from "src/app/api/testing";
+import { AsnDetailComponent } from "src/app/core/cache-groups/asns/detail/asn-detail.component";
+import { NavigationService } from "src/app/shared/navigation/navigation.service";
+
+describe("AsnDetailComponent", () => {
+	let component: AsnDetailComponent;
+	let fixture: ComponentFixture<AsnDetailComponent>;
+	let route: ActivatedRoute;
+	let paramMap: jasmine.Spy;
+
+	const headerSvc = jasmine.createSpyObj([],{headerHidden: new ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [ AsnDetailComponent ],
+			imports: [ APITestingModule, RouterTestingModule, MatDialogModule ],
+			providers: [ { provide: NavigationService, useValue: headerSvc } ]
+		})
+			.compileComponents();
+
+		route = TestBed.inject(ActivatedRoute);
+		paramMap = spyOn(route.snapshot.paramMap, "get");
+		paramMap.and.returnValue(null);
+		fixture = TestBed.createComponent(AsnDetailComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+	});
+
+	it("should create", () => {
+		expect(component).toBeTruthy();
+		expect(paramMap).toHaveBeenCalled();
+	});
+
+	it("new asn", async () => {
+		paramMap.and.returnValue("new");
+
+		fixture = TestBed.createComponent(AsnDetailComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		await fixture.whenStable();
+		expect(paramMap).toHaveBeenCalled();
+		expect(component.asn).not.toBeNull();
+		expect(component.asn.asn).toBe(1);
+		expect(component.new).toBeTrue();
+	});
+
+	it("existing asn", async () => {
+		paramMap.and.returnValue("1");
+
+		fixture = TestBed.createComponent(AsnDetailComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		await fixture.whenStable();
+		expect(paramMap).toHaveBeenCalled();
+		expect(component.asn).not.toBeNull();
+		expect(component.asn.asn).toBe(0);
+		expect(component.new).toBeFalse();
+	});
+});
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.ts
new file mode 100644
index 000000000..cb0643729
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/asns/detail/asn-detail.component.ts
@@ -0,0 +1,110 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { Location } from "@angular/common";
+import { Component, OnInit } from "@angular/core";
+import { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { ResponseASN, ResponseCacheGroup } from "trafficops-types";
+
+import { CacheGroupService } from "src/app/api";
+import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { NavigationService } from "src/app/shared/navigation/navigation.service";
+
+/**
+ * AsnDetailComponent is the controller for the ASN add/edit form.
+ */
+@Component({
+	selector: "tp-asn-detail",
+	styleUrls: ["./asn-detail.component.scss"],
+	templateUrl: "./asn-detail.component.html"
+})
+export class AsnDetailComponent implements OnInit {
+	public new = false;
+	public asn!: ResponseASN;
+	public cachegroups!: Array<ResponseCacheGroup>;
+	constructor(private readonly route: ActivatedRoute, private readonly cacheGroupService: CacheGroupService,
+		private readonly location: Location, private readonly dialog: MatDialog,
+		private readonly header: NavigationService) {
+	}
+
+	/**
+	 * Angular lifecycle hook where data is initialized.
+	 */
+	public async ngOnInit(): Promise<void> {
+		this.cachegroups = await this.cacheGroupService.getCacheGroups();
+		const ID = this.route.snapshot.paramMap.get("id");
+		if (ID === null) {
+			console.error("missing required route parameter 'id'");
+			return;
+		}
+
+		if (ID === "new") {
+			this.header.headerTitle.next("New ASN");
+			this.new = true;
+			this.asn = {
+				asn: 1,
+				cachegroup: "test",
+				cachegroupId: 1,
+				id: 1,
+				lastUpdated: new Date()
+			};
+			return;
+		}
+		const numID = parseInt(ID, 10);
+		if (Number.isNaN(numID)) {
+			console.error("route parameter 'id' was non-number:", ID);
+			return;
+		}
+
+		this.asn = await this.cacheGroupService.getASNs(numID);
+		this.header.headerTitle.next(`ASN: ${this.asn.asn}`);
+	}
+
+	/**
+	 * Deletes the current ASN.
+	 */
+	public async deleteAsn(): Promise<void> {
+		if (this.new) {
+			console.error("Unable to delete new ASN");
+			return;
+		}
+		const ref = this.dialog.open(DecisionDialogComponent, {
+			data: {message: `Are you sure you want to delete ASN ${this.asn.asn} with id ${this.asn.id}`,
+				title: "Confirm Delete"}
+		});
+		ref.afterClosed().subscribe(result => {
+			if(result) {
+				this.cacheGroupService.deleteASN(this.asn.id);
+				this.location.back();
+			}
+		});
+	}
+
+	/**
+	 * Submits new/updated ASN.
+	 *
+	 * @param e HTML form submission event.
+	 */
+	public async submit(e: Event): Promise<void> {
+		e.preventDefault();
+		e.stopPropagation();
+		if(this.new) {
+			this.asn = await this.cacheGroupService.createASN(this.asn);
+			this.new = false;
+		} else {
+			this.asn = await this.cacheGroupService.updateASN(this.asn);
+		}
+	}
+
+}
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss
index 7b4f84436..2dc08de58 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss
@@ -16,26 +16,12 @@ form {
 	max-width: calc(100% - 32px);
 }
 
-mat-card {
-	margin: 1em auto;
-	box-sizing: border-box;
-	width: 80%;
-	min-width: 350px;
-
-	mat-card-content {
-		display: grid;
-		grid-template-columns: 1fr;
-		row-gap: 2em;
-		margin: 1em auto 50px;
-
-		.pair {
-			display: grid;
-			grid-template-columns: 1fr 1fr;
-			column-gap: 48px;
-			align-items: center;
-			justify-content: space-between;
-		}
-	}
+mat-card mat-card-content .pair {
+	display: grid;
+	grid-template-columns: 1fr 1fr;
+	column-gap: 48px;
+	align-items: center;
+	justify-content: space-between;
 }
 
 /* Chosen more or less arbitrarily. */
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
index a6672ca50..fb0781318 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
@@ -28,7 +28,7 @@ import { NavigationService } from "src/app/shared/navigation/navigation.service"
  */
 @Component({
 	selector: "tp-cache-group-details",
-	styleUrls: ["./cache-group-details.component.scss"],
+	styleUrls: ["../../styles/form.page.scss", "./cache-group-details.component.scss"],
 	templateUrl: "./cache-group-details.component.html",
 })
 export class CacheGroupDetailsComponent implements OnInit {
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
index 3c91deffd..cadd191ca 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
@@ -15,7 +15,7 @@
 import { Component, type OnInit } from "@angular/core";
 import { FormControl } from "@angular/forms";
 import { MatDialog } from "@angular/material/dialog";
-import {ActivatedRoute, type Params } from "@angular/router";
+import { ActivatedRoute, type Params } from "@angular/router";
 import type { ColDef } from "ag-grid-community";
 import { BehaviorSubject } from "rxjs";
 import {
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts
index 0519af7b8..d4ed1b127 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts
@@ -26,7 +26,7 @@ import { NavigationService } from "src/app/shared/navigation/navigation.service"
  */
 @Component({
 	selector: "tp-divisions-detail",
-	styleUrls: ["./division-detail.component.scss"],
+	styleUrls: ["../../../styles/form.page.scss"],
 	templateUrl: "./division-detail.component.html"
 })
 export class DivisionDetailComponent implements OnInit {
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts
index c195bd475..04a015ea3 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts
@@ -26,7 +26,7 @@ import { NavigationService } from "src/app/shared/navigation/navigation.service"
  */
 @Component({
 	selector: "tp-regions-detail",
-	styleUrls: ["./region-detail.component.scss"],
+	styleUrls: ["../../../styles/form.page.scss"],
 	templateUrl: "./region-detail.component.html"
 })
 export class RegionDetailComponent implements OnInit {
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts
index 3a37cac3f..af02d05a7 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -24,6 +24,7 @@ import { AppUIModule } from "../app.ui.module";
 import { AuthenticatedGuard } from "../guards/authenticated-guard.service";
 import { SharedModule } from "../shared/shared.module";
 
+import { AsnDetailComponent } from "./cache-groups/asns/detail/asn-detail.component";
 import { AsnsTableComponent } from "./cache-groups/asns/table/asns-table.component";
 import { CacheGroupDetailsComponent } from "./cache-groups/cache-group-details/cache-group-details.component";
 import { CacheGroupTableComponent } from "./cache-groups/cache-group-table/cache-group-table.component";
@@ -45,6 +46,7 @@ import {
 	NewInvalidationJobDialogComponent
 } from "./deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component";
 import { NewDeliveryServiceComponent } from "./deliveryservice/new-delivery-service/new-delivery-service.component";
+import { ISOGenerationFormComponent } from "./misc/isogeneration-form/isogeneration-form.component";
 import { PhysLocDetailComponent } from "./servers/phys-loc/detail/phys-loc-detail.component";
 import { PhysLocTableComponent } from "./servers/phys-loc/table/phys-loc-table.component";
 import { ServerDetailsComponent } from "./servers/server-details/server-details.component";
@@ -60,6 +62,7 @@ import { UsersComponent } from "./users/users.component";
 
 export const ROUTES: Routes = [
 	{ component: DashboardComponent, path: "" },
+	{ component: AsnDetailComponent, path: "asns/:id"},
 	{ component: AsnsTableComponent, path: "asns" },
 	{ component: DivisionsTableComponent, path: "divisions" },
 	{ component: DivisionDetailComponent, path: "divisions/:id" },
@@ -84,6 +87,7 @@ export const ROUTES: Routes = [
 	{ component: CoordinatesTableComponent, path: "coordinates" },
 	{ component: TypesTableComponent, path: "types" },
 	{ component: TypeDetailComponent, path: "types/:id"},
+	{ component: ISOGenerationFormComponent, path: "iso-gen"},
 ].map(r => ({...r, canActivate: [AuthenticatedGuard]}));
 
 /**
@@ -114,6 +118,7 @@ export const ROUTES: Routes = [
 		PhysLocTableComponent,
 		PhysLocDetailComponent,
 		AsnsTableComponent,
+		AsnDetailComponent,
 		DivisionsTableComponent,
 		DivisionDetailComponent,
 		RegionsTableComponent,
@@ -123,6 +128,7 @@ export const ROUTES: Routes = [
 		CoordinateDetailComponent,
 		TypesTableComponent,
 		TypeDetailComponent,
+		ISOGenerationFormComponent,
 	],
 	exports: [],
 	imports: [
diff --git a/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.ts b/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.ts
index dd3a3cd04..7e6a58144 100644
--- a/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.ts
+++ b/experimental/traffic-portal/src/app/core/deliveryservice/new-delivery-service/new-delivery-service.component.ts
@@ -32,6 +32,7 @@ import {
 import { CDNService, DeliveryServiceService } from "src/app/api";
 import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
 import { NavigationService } from "src/app/shared/navigation/navigation.service";
+import { IPV4, IPV6 } from "src/app/utils";
 
 /**
  * A regular expression that matches character strings that are illegal in `xml_id`s
@@ -43,17 +44,6 @@ const XML_ID_SANITIZE = /[^a-z0-9\-]+/g;
  */
 const VALID_XML_ID = /^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$/;
 
-/* eslint-disable */
-/**
- * A regular expression that matches IPv4 addresses
- */
-const VALID_IPV4 = /^(1\d\d|2[0-4]\d|25[0-5]|\d\d?)(\.(1\d\d|2[0-4]\d|25[0-5]|\d\d?)){3}$/;
-/**
- * A regular expression that matches IPv6 addresses
- * This is huge and ugly, but there's no JS built-in for address parsing afaik.
- */
-const VALID_IPV6 = /^(?:(?:[a-fA-F\d]{1,4}:){7}(?:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,2}|:)|(?:[a-fA-F\d]{1,4}:){4}(?:(?::[a-fA-F\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,3}|:)|(?:[a-fA-F\d]{1,4}:){3}(?:(?::[a-fA-F\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,4}|:)|(?:[a-fA-F\d]{1,4}:){2}(?:(?::[a-fA-F\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,5}|:)|(?:[a-fA-F\d]{1,4}:){1}(?:(?::[a-fA-F\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?$/;
-/* eslint-enable */
 /**
  * A regular expression that matches a valid hostname
  */
@@ -394,9 +384,9 @@ export class NewDeliveryServiceComponent implements OnInit {
 	 * @throws {Error} if `v` is not a valid Bypass value
 	 */
 	public setDNSBypass(v: string): void {
-		if (VALID_IPV6.test(v)) {
+		if (IPV6.test(v)) {
 			this.deliveryService.dnsBypassIp6 = v;
-		} else if (VALID_IPV4.test(v)) {
+		} else if (IPV4.test(v)) {
 			this.deliveryService.dnsBypassIp = v;
 		} else if (VALID_HOSTNAME.test(v)) {
 			this.deliveryService.dnsBypassCname = v;
diff --git a/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.html b/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.html
new file mode 100644
index 000000000..3fae68ea3
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.html
@@ -0,0 +1,121 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<mat-card>
+	<form [formGroup]="form" (ngSubmit)="submit($event)">
+		<mat-card-header>
+			<mat-slide-toggle name="useDHCP" formControlName="useDHCP">Use DHCP?</mat-slide-toggle>
+		</mat-card-header>
+		<mat-card-content>
+			<mat-form-field>
+				<mat-label>Operating System</mat-label>
+				<mat-select name="osVersion" formControlName="osVersion" required>
+					<mat-option *ngFor="let version of osVersions" [value]="version[1]">{{version[0]}}</mat-option>
+				</mat-select>
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Disk for OS Installation</mat-label>
+				<input matInput type="text" name="disk" formControlName="disk" required placeholder="sda"/>
+				<mat-hint>This should be the name of the storage device relative to <code>/dev/</code>.</mat-hint>
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Fully Qualified Domain Name</mat-label>
+				<input matInput type="text" name="fqdn" required formControlName="fqdn"/>
+				<mat-error >Invalid <abbr title="Fully Qualified Domain Name">FQDN</abbr></mat-error>
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Network Interface Device Name</mat-label>
+				<input matInput type="text" name="interfaceName" placeholder="eth0" formControlName="interfaceName"/>
+				<mat-hint>This should be the name of the network device relative to <code>/dev/</code>.</mat-hint>
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Network Interface <abbr title="Maximum Transmission Unit">MTU</abbr></mat-label>
+				<input matInput type="number" name="interfaceMTU" min="1" max="9000" formControlName="mtu"/>
+				<mat-hint class="input-warning" [hidden]="hideMTUWarning()"><mat-icon color="warn">warning</mat-icon> Network interface <abbr title="Maximum Transmission Unit">MTU</abbr> values should almost always be either 1500 or 9000 - this value may not work correctly!</mat-hint>
+			</mat-form-field>
+			<mat-card *ngIf="!useDHCP">
+				<fieldset>
+					<legend mat-card-title>IPv4 Network Details</legend>
+					<mat-form-field>
+						<mat-label>IPv4 Address</mat-label>
+						<input matInput type="text" name="ipv4Address" required formControlName="ipv4Address"/>
+						<mat-error>Invalid IPv4 Address</mat-error>
+					</mat-form-field>
+					<mat-form-field>
+						<mat-label>IPv4 Gateway Address</mat-label>
+						<input matInput type="text" name="ipv4Gateway" required formControlName="ipv4Gateway"/>
+						<mat-error>Invalid IPv4 Address</mat-error>
+					</mat-form-field>
+					<mat-form-field>
+						<mat-label>IPv4 Netmask</mat-label>
+						<input matInput type="text" name="ipv4Netmask" required formControlName="ipv4Netmask"/>
+						<mat-hint>This can be deduced from a "CIDR" if your intended address has one; if you don't know how, ask a network administrator to help you fill this in.</mat-hint>
+						<mat-error>Invalid IPv4 Netmask</mat-error>
+					</mat-form-field>
+				</fieldset>
+			</mat-card>
+			<mat-card>
+				<fieldset>
+					<legend mat-card-title>IPv6 Network Details</legend>
+					<mat-form-field>
+						<mat-label>IPv6 Address</mat-label>
+						<input matInput type="text" name="ipv6Address" formControlName="ipv6Address"/>
+						<mat-error>Invalid IPv6 address</mat-error>
+					</mat-form-field>
+					<mat-form-field>
+						<mat-label>IPv6 Gateway Address</mat-label>
+						<input matInput type="text" name="ipv6Gateway" [required]="!!form.controls.ipv6Address.value" formControlName="ipv6Gateway"/>
+						<mat-error>Invalid IPv6 address</mat-error>
+					</mat-form-field>
+				</fieldset>
+			</mat-card>
+			<mat-card>
+				<fieldset>
+					<legend mat-card-title>Management Network Interface Details</legend>
+					<mat-form-field>
+						<mat-label>Management Interface Device Name</mat-label>
+						<input matInput type="text" formControlName="mgmtInterface" placeholder="eth0"/>
+						<mat-hint>This should be the name of the network device relative to <code>/dev/</code>.</mat-hint>
+					</mat-form-field>
+					<mat-form-field>
+						<mat-label>Management IPv4 Address</mat-label>
+						<input matInput type="text" formControlName="mgmtIpAddress"/>
+						<mat-error>Invalid IPv4 address</mat-error>
+					</mat-form-field>
+					<mat-form-field>
+						<mat-label>Management Gateway IPv4 Address</mat-label>
+						<input matInput type="text" formControlName="mgmtIpGateway"/>
+						<mat-error>Invalid IPv4 address</mat-error>
+					</mat-form-field>
+					<mat-form-field>
+						<mat-label>Management IPv4 Netmask</mat-label>
+						<input matInput type="text" formControlName="mgmtIpNetmask"/>
+						<mat-error>Invalid IPv4 Netmask</mat-error>
+					</mat-form-field>
+				</fieldset>
+			</mat-card>
+			<mat-form-field>
+				<mat-label>Password for Root User</mat-label>
+				<input type="password" matInput formControlName="rootPass" required />
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Confirm Root User Password</mat-label>
+				<input matInput type="password" formControlName="rootPassConfirm" required />
+				<mat-error>Does not match Root Password</mat-error>
+			</mat-form-field>
+		</mat-card-content>
+		<mat-card-actions align="end">
+			<button [disabled]="form.invalid" mat-raised-button type="submit" color="primary">Save</button>
+		</mat-card-actions>
+	</form>
+</mat-card>
diff --git a/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.scss b/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.scss
new file mode 100644
index 000000000..95568949c
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.scss
@@ -0,0 +1,24 @@
+/**
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+ form mat-card {
+	 width: 100%;
+	 min-width: none;
+	fieldset {
+		border: none;
+		display: grid;
+		grid-template-columns: 1fr;
+		row-gap: 2em;
+	}
+}
diff --git a/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.spec.ts b/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.spec.ts
new file mode 100644
index 000000000..33c595ab4
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.spec.ts
@@ -0,0 +1,171 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+
+import { MiscAPIsService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { SharedModule } from "src/app/shared/shared.module";
+
+import { ISOGenerationFormComponent } from "./isogeneration-form.component";
+
+describe("ISOGenerationFormComponent", () => {
+	let component: ISOGenerationFormComponent;
+	let fixture: ComponentFixture<ISOGenerationFormComponent>;
+	let form: typeof component.form.controls;
+	let spy: jasmine.Spy;
+
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [ ISOGenerationFormComponent ],
+			imports: [
+				APITestingModule,
+				SharedModule
+			],
+			providers: [{
+				provide: "Window",
+				useValue: {
+					open: (): void => {
+						// do nothing
+					}
+				}
+			}]
+		}).compileComponents();
+
+		fixture = TestBed.createComponent(ISOGenerationFormComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		form = component.form.controls;
+		const srv = TestBed.inject(MiscAPIsService);
+		spy = spyOn(srv, "generateISO").and.callThrough();
+	});
+
+	it("should create", () => {
+		expect(component).toBeTruthy();
+	});
+
+	it("hides the MTU warning when appropriate", () => {
+		// should be hidden by default
+		expect(component.hideMTUWarning()).toBeTrue();
+		form.mtu.setValue(1501);
+		expect(component.hideMTUWarning()).toBeFalse();
+		form.mtu.setValue(1500);
+		expect(component.hideMTUWarning()).toBeTrue();
+		form.mtu.setValue(9000);
+		expect(component.hideMTUWarning()).toBeTrue();
+	});
+
+	it("validates that the root password matches the confirm field", () => {
+		form.rootPass.setValue("testquest");
+		form.rootPassConfirm.setValue("testquest");
+		expect(form.rootPass.valid).toBeTrue();
+		expect(form.rootPassConfirm.valid).toBeTrue();
+
+		form.rootPassConfirm.setValue(`${form.rootPassConfirm.value} some more stuff`);
+		expect(form.rootPass.valid).toBeTrue();
+		expect(form.rootPassConfirm.invalid).toBeTrue();
+		if (!form.rootPassConfirm.errors) {
+			return fail("rootPassConfirm had null errors when it should be invalid");
+		}
+		expect(form.rootPassConfirm.errors.mismatch).toBeTrue();
+	});
+
+	it("doesn't submit requests when the form is invalid", async () => {
+		form.rootPass.setValue("something");
+		form.rootPassConfirm.setValue("something else");
+		await component.submit(new Event("submit"));
+		expect(spy).not.toHaveBeenCalled();
+	});
+
+	it("submits requests for DHCP ISOs", async () => {
+		const request = {
+			dhcp: "yes",
+			disk: "sda",
+			domainName: "domain-name",
+			hostName: "host-name",
+			interfaceMtu: 1501,
+			interfaceName: "eth0",
+			ip6Address: "f1d0::f00d",
+			ip6Gateway: "dead::beef",
+			mgmtInterface: "mgmt0",
+			mgmtIpAddress: "1.3.3.7",
+			mgmtIpGateway: "9.0.0.1",
+			mgmtIpNetmask: "0.4.2.0",
+			osVersionDir: "os version dir",
+			rootPass: "root password",
+		};
+		form.useDHCP.setValue(request.dhcp === "yes");
+		form.disk.setValue(request.disk);
+		form.fqdn.setValue(`${request.hostName}.${request.domainName}`);
+		form.interfaceName.setValue(request.interfaceName);
+		form.mtu.setValue(request.interfaceMtu);
+		form.ipv4Address.setValue("");
+		form.ipv4Gateway.setValue("");
+		form.ipv4Netmask.setValue("");
+		form.ipv6Address.setValue(request.ip6Address);
+		form.ipv6Gateway.setValue(request.ip6Gateway);
+		form.mgmtInterface.setValue(request.mgmtInterface);
+		form.mgmtIpAddress.setValue(request.mgmtIpAddress);
+		form.mgmtIpGateway.setValue(request.mgmtIpGateway);
+		form.mgmtIpNetmask.setValue(request.mgmtIpNetmask);
+		form.osVersion.setValue(request.osVersionDir);
+		form.rootPass.setValue(request.rootPass);
+		form.rootPassConfirm.setValue(request.rootPass);
+
+		await component.submit(new Event("submit"));
+		expect(spy).toHaveBeenCalledOnceWith(request);
+	});
+
+	it("submits requests for non-DHCP ISOs", async () => {
+		const request = {
+			dhcp: "no",
+			disk: "sda",
+			domainName: "domain-name",
+			hostName: "host-name",
+			interfaceMtu: 1501,
+			interfaceName: "eth0",
+			ip6Address: "f1d0::f00d",
+			ip6Gateway: "dead::beef",
+			ipAddress: "1.2.3.4",
+			ipGateway: "4.3.2.1",
+			ipNetmask: "0.1.10.100",
+			mgmtInterface: "mgmt0",
+			mgmtIpAddress: "1.3.3.7",
+			mgmtIpGateway: "9.0.0.1",
+			mgmtIpNetmask: "0.4.2.0",
+			osVersionDir: "os version dir",
+			rootPass: "root password",
+		};
+		form.useDHCP.setValue(request.dhcp === "yes");
+		form.disk.setValue(request.disk);
+		form.fqdn.setValue(`${request.hostName}.${request.domainName}`);
+		form.interfaceName.setValue(request.interfaceName);
+		form.mtu.setValue(request.interfaceMtu);
+		form.ipv4Address.setValue(request.ipAddress);
+		form.ipv4Gateway.setValue(request.ipGateway);
+		form.ipv4Netmask.setValue(request.ipNetmask);
+		form.ipv6Address.setValue(request.ip6Address);
+		form.ipv6Gateway.setValue(request.ip6Gateway);
+		form.mgmtInterface.setValue(request.mgmtInterface);
+		form.mgmtIpAddress.setValue(request.mgmtIpAddress);
+		form.mgmtIpGateway.setValue(request.mgmtIpGateway);
+		form.mgmtIpNetmask.setValue(request.mgmtIpNetmask);
+		form.osVersion.setValue(request.osVersionDir);
+		form.rootPass.setValue(request.rootPass);
+		form.rootPassConfirm.setValue(request.rootPass);
+
+		await component.submit(new Event("submit"));
+		expect(spy).toHaveBeenCalledOnceWith(request);
+	});
+});
diff --git a/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.ts b/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.ts
new file mode 100644
index 000000000..3834653bc
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/misc/isogeneration-form/isogeneration-form.component.ts
@@ -0,0 +1,155 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Component, type OnInit } from "@angular/core";
+import { FormControl, FormGroup, ValidationErrors, Validators } from "@angular/forms";
+import type { ISORequest } from "trafficops-types";
+
+import { MiscAPIsService } from "src/app/api";
+import { FileUtilsService } from "src/app/shared/file-utils.service";
+import { IPV4, IPV6, IPV6_WITH_CIDR } from "src/app/utils";
+
+/**
+ * The controller for a form that can be used to generate ISOs.
+ *
+ * Ideally this will be removed in the future, but it is not yet deprecated.
+ */
+@Component({
+	selector: "tp-isogeneration-form",
+	styleUrls: ["../../styles/form.page.scss", "./isogeneration-form.component.scss"],
+	templateUrl: "./isogeneration-form.component.html",
+})
+export class ISOGenerationFormComponent implements OnInit {
+	public osVersions: [string, string][] = [];
+
+	/**
+	 * This is the reactive form controller for the page. Due to some complex
+	 * validation, as well as spotty browser support for the absolutely MASSIVE
+	 * regular expressions some the pattern validators use, this can't reliably
+	 * done in a template-driven form.
+	 */
+	public form = new FormGroup({
+		disk: new FormControl("", {nonNullable: true}),
+		fqdn: new FormControl("", {
+			nonNullable: true,
+			validators: Validators.pattern(/^[a-zA-Z0-9][a-zA-Z0-9-]+\.([a-zA-Z0-9][a-zA-Z0-9-]+)$/)
+		}),
+		interfaceName: new FormControl(""),
+		ipv4Address: new FormControl("", {nonNullable: true, validators: Validators.pattern(IPV4)}),
+		ipv4Gateway: new FormControl("", {nonNullable: true, validators: Validators.pattern(IPV4)}),
+		ipv4Netmask: new FormControl("", {nonNullable: true, validators: Validators.pattern(IPV4)}),
+		ipv6Address: new FormControl("", Validators.pattern(IPV6_WITH_CIDR)),
+		ipv6Gateway: new FormControl("", Validators.pattern(IPV6)),
+		mgmtInterface: new FormControl(""),
+		mgmtIpAddress: new FormControl("", Validators.pattern(IPV4)),
+		mgmtIpGateway: new FormControl("", Validators.pattern(IPV4)),
+		mgmtIpNetmask: new FormControl("", Validators.pattern(IPV4)),
+		mtu: new FormControl(1500, {nonNullable: true}),
+		osVersion: new FormControl("", {nonNullable: true}),
+		rootPass: new FormControl("", {nonNullable: true}),
+		rootPassConfirm: new FormControl("", {nonNullable: true}),
+		useDHCP: new FormControl(true, {nonNullable: true}),
+	});
+
+	/** `true` if IPv4 will be dynamic, `false` otherwise. */
+	public get useDHCP(): boolean {
+		return this.form.controls.useDHCP.value;
+	}
+
+	constructor(private readonly api: MiscAPIsService, private readonly fileService: FileUtilsService) {
+		this.form.controls.rootPassConfirm.addValidators((ctrl): ValidationErrors | null => {
+			if (this.form.controls.rootPass.value !== ctrl.value) {
+				return {
+					mismatch: true
+				};
+			}
+			return null;
+		});
+	}
+
+	/** Angular lifecycle hook. */
+	public async ngOnInit(): Promise<void> {
+		const osVersions = await this.api.getISOOSVersions();
+		this.osVersions = Object.entries(osVersions);
+	}
+
+	/**
+	 * Checks if the warning about an unusual MTU should be hidden.
+	 *
+	 * @returns `true` if the MTU warning should be hidden, `false` otherwise.
+	 */
+	public hideMTUWarning(): boolean {
+		return this.form.controls.mtu.value === 1500 || this.form.controls.mtu.value === 9000;
+	}
+
+	/**
+	 * Handles form submission.
+	 *
+	 * @param event The DOM form submission event.
+	 */
+	public async submit(event: Event): Promise<void> {
+		event.preventDefault();
+		event.stopPropagation();
+		if (this.form.invalid) {
+			return;
+		}
+
+		const fqdn = this.form.controls.fqdn.value;
+		const [hostName, domainName] = fqdn.split(".", 2);
+
+		let req: ISORequest;
+		if (this.useDHCP) {
+			req = {
+				dhcp: "yes",
+				disk: this.form.controls.disk.value,
+				domainName,
+				hostName,
+				interfaceMtu: this.form.controls.mtu.value,
+				interfaceName: this.form.controls.interfaceName.value,
+				ip6Address: this.form.controls.ipv6Address.value,
+				ip6Gateway: this.form.controls.ipv6Gateway.value,
+				mgmtInterface: this.form.controls.mgmtInterface.value,
+				mgmtIpAddress: this.form.controls.mgmtIpAddress.value,
+				mgmtIpGateway: this.form.controls.mgmtIpGateway.value,
+				mgmtIpNetmask: this.form.controls.mgmtIpNetmask.value,
+				osVersionDir: this.form.controls.osVersion.value,
+				rootPass: this.form.controls.rootPass.value
+			};
+		} else {
+			req = {
+				dhcp: "no",
+				disk: this.form.controls.disk.value,
+				domainName,
+				hostName,
+				interfaceMtu: this.form.controls.mtu.value,
+				interfaceName: this.form.controls.interfaceName.value,
+				ip6Address: this.form.controls.ipv6Address.value,
+				ip6Gateway: this.form.controls.ipv6Gateway.value,
+				ipAddress: this.form.controls.ipv4Address.value,
+				ipGateway: this.form.controls.ipv4Gateway.value,
+				ipNetmask: this.form.controls.ipv4Netmask.value,
+				mgmtInterface: this.form.controls.mgmtInterface.value,
+				mgmtIpAddress: this.form.controls.mgmtIpAddress.value,
+				mgmtIpGateway: this.form.controls.mgmtIpGateway.value,
+				mgmtIpNetmask: this.form.controls.mgmtIpNetmask.value,
+				osVersionDir: this.form.controls.osVersion.value,
+				rootPass: this.form.controls.rootPass.value
+			};
+		}
+
+		const response = await this.api.generateISO(req);
+		this.fileService.download(response, `${fqdn}-${this.form.controls.osVersion.value}.iso`);
+	}
+
+}
diff --git a/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.scss b/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.scss
index cc9be17c1..20df428ce 100644
--- a/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.scss
+++ b/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.scss
@@ -11,20 +11,11 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-mat-card {
-	margin: 1em auto;
-	width: 80%;
-	min-width: 350px;
+mat-card mat-card-content {
+	grid-template-columns: 1fr 1fr;
+	column-gap: 2em;
 
-	mat-card-content {
-		display: grid;
-		grid-template-columns: 1fr 1fr;
-		grid-column-gap: 2em;
-		grid-row-gap: 2em;
-		margin: 1em auto 50px;
-
-		.doubleColSpan {
-			grid-column: span 2;
-		}
+	.doubleColSpan {
+		grid-column: span 2;
 	}
 }
diff --git a/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.ts b/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.ts
index b93e84ccb..a5a57c12e 100644
--- a/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/phys-loc/detail/phys-loc-detail.component.ts
@@ -26,7 +26,7 @@ import { NavigationService } from "src/app/shared/navigation/navigation.service"
  */
 @Component({
 	selector: "tp-phys-loc-detail",
-	styleUrls: ["./phys-loc-detail.component.scss"],
+	styleUrls: ["../../../styles/form.page.scss", "./phys-loc-detail.component.scss"],
 	templateUrl: "./phys-loc-detail.component.html"
 })
 export class PhysLocDetailComponent implements OnInit {
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 2d09e79d2..6528a7f7d 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
@@ -120,7 +120,7 @@ limitations under the License.
 						<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-error class="input-warning" *ngIf="inf.maxBandwidth !== 0">Setting Max Bandwidth to zero will cause cache servers to always be unavailable</mat-error>
+							<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> -->
 					</div>
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.scss b/experimental/traffic-portal/src/app/core/styles/form.page.scss
similarity index 94%
rename from experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.scss
rename to experimental/traffic-portal/src/app/core/styles/form.page.scss
index f4746e2a2..b74018a3e 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.scss
+++ b/experimental/traffic-portal/src/app/core/styles/form.page.scss
@@ -13,13 +13,14 @@
 */
 mat-card {
 	margin: 1em auto;
+	box-sizing: border-box;
 	width: 80%;
 	min-width: 350px;
 
 	mat-card-content {
 		display: grid;
 		grid-template-columns: 1fr;
-		grid-row-gap: 2em;
+		row-gap: 2em;
 		margin: 1em auto 50px;
 	}
 }
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.scss b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.scss
deleted file mode 100644
index 85b09c7c4..000000000
--- a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.scss
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*     http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
-*/
-
-mat-card {
-	margin: 1em auto;
-	width: 80%;
-	min-width: 350px;
-
-	mat-card-content {
-		display: grid;
-		grid-template-columns: 1fr;
-		grid-row-gap: 2em;
-		margin: 1em auto 50px;
-	}
-}
-
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
index 556f5c668..3b44b63ab 100644
--- a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
@@ -24,7 +24,7 @@ import { TreeData } from "src/app/models";
  */
 @Component({
 	selector: "tp-tenant-details",
-	styleUrls: ["./tenant-details.component.scss"],
+	styleUrls: ["../../../styles/form.page.scss"],
 	templateUrl: "./tenant-details.component.html"
 })
 export class TenantDetailsComponent implements OnInit {
diff --git a/experimental/traffic-portal/src/app/core/users/user-details/user-details.component.scss b/experimental/traffic-portal/src/app/core/users/user-details/user-details.component.scss
index 833c8a11f..15a39a57e 100644
--- a/experimental/traffic-portal/src/app/core/users/user-details/user-details.component.scss
+++ b/experimental/traffic-portal/src/app/core/users/user-details/user-details.component.scss
@@ -26,12 +26,6 @@ mat-card-content {
 	grid-row-gap: 1em;
 	width: 85vw;
 	margin: 1em auto 50px;
-
-	.input-warning {
-		color: #ff9800;
-		font-weight: bold;
-		font-size: x-small;
-	}
 }
 
 #ssh-container {
diff --git a/experimental/traffic-portal/src/app/shared/file-utils.service.spec.ts b/experimental/traffic-portal/src/app/shared/file-utils.service.spec.ts
new file mode 100644
index 000000000..a244e0af3
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/file-utils.service.spec.ts
@@ -0,0 +1,219 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { DOCUMENT } from "@angular/common";
+import { TestBed } from "@angular/core/testing";
+
+import { FileUtilsService } from "./file-utils.service";
+
+describe("FileUtilsService", () => {
+	let service: FileUtilsService;
+	let spy: jasmine.Spy<typeof globalThis.open>;
+	let urlSpy: jasmine.Spy<typeof URL.createObjectURL>;
+
+	beforeEach(() => {
+		const window = {open: (): void => {
+			// do nothing
+		}};
+		spy = spyOn(window, "open").and.callThrough();
+		urlSpy = spyOn(URL, "createObjectURL").and.callThrough();
+		TestBed.configureTestingModule({
+			providers: [
+				FileUtilsService,
+				{provide: DOCUMENT, useValue: {defaultView: window}}
+			]
+		});
+		expect(spy).not.toHaveBeenCalled();
+		service = TestBed.inject(FileUtilsService);
+	});
+
+	it("should be created", () => {
+		expect(service).toBeTruthy();
+	});
+
+	describe("downloading string-data files", () => {
+		const data = "data";
+		it("downloads files when everything is specified", () => {
+			const f = new File([data], "myfilename", {type: "mytype"});
+			service.download(data, f.name, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only filename is specified", () => {
+			const f = new File([data], "myfilename", {type: FileUtilsService.TEXT_CONTENT_TYPE});
+			service.download(data, f.name);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only content type is specified", () => {
+			const f = new File([data], `${FileUtilsService.DEFAULT_FILE_NAME}.txt`, {type: "mytype"});
+			service.download(data, undefined, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files without any details", () => {
+			const f = new File([data], `${FileUtilsService.DEFAULT_FILE_NAME}.txt`, {type: FileUtilsService.TEXT_CONTENT_TYPE});
+			service.download(data);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+	});
+
+	describe("downloading ArrayBuffer-data files", () => {
+		const data = new ArrayBuffer(27);
+		it("downloads files when everything is specified", () => {
+			const f = new File([data], "myfilename", {type: "mytype"});
+			service.download(data, f.name, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only filename is specified", () => {
+			const f = new File([data], "myfilename", {type: FileUtilsService.BINARY_DATA_CONTENT_TYPE});
+			service.download(data, f.name);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only content type is specified", () => {
+			const f = new File([data], `${FileUtilsService.DEFAULT_FILE_NAME}.bin`, {type: "mytype"});
+			service.download(data, undefined, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files without any details", () => {
+			const f = new File([data], `${FileUtilsService.DEFAULT_FILE_NAME}.bin`, {type: FileUtilsService.BINARY_DATA_CONTENT_TYPE});
+			service.download(data);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+	});
+
+	describe("downloading ArrayBufferView-data files", () => {
+		const data = new Uint8Array();
+		it("downloads files when everything is specified", () => {
+			const f = new File([data], "myfilename", {type: "mytype"});
+			service.download(data, f.name, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only filename is specified", () => {
+			const f = new File([data], "myfilename", {type: FileUtilsService.BINARY_DATA_CONTENT_TYPE});
+			service.download(data, f.name);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only content type is specified", () => {
+			const f = new File([data], `${FileUtilsService.DEFAULT_FILE_NAME}.bin`, {type: "mytype"});
+			service.download(data, undefined, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files without any details", () => {
+			const f = new File([data], `${FileUtilsService.DEFAULT_FILE_NAME}.bin`, {type: FileUtilsService.BINARY_DATA_CONTENT_TYPE});
+			service.download(data);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+	});
+
+	describe("downloading arbitrary object-data files", () => {
+		const data = {data: "object"};
+		it("downloads files when everything is specified", () => {
+			const f = new File([JSON.stringify(data)], "myfilename.bin", {type: "mytype"});
+			service.download(data, f.name, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only filename is specified", () => {
+			const f = new File([JSON.stringify(data)], "myfilename", {type: FileUtilsService.JSON_DATA_CONTENT_TYPE});
+			service.download(data, f.name);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only content type is specified", () => {
+			const f = new File([JSON.stringify(data)], `${FileUtilsService.DEFAULT_FILE_NAME}.json`, {type: "mytype"});
+			service.download(data, undefined, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files without any details", () => {
+			const f = new File(
+				[JSON.stringify(data)],
+				`${FileUtilsService.DEFAULT_FILE_NAME}.json`,
+				{type: FileUtilsService.JSON_DATA_CONTENT_TYPE}
+			);
+			service.download(data);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+	});
+
+	describe("downloading blobs as files", () => {
+		it("downloads files when everything is specified", () => {
+			const data = new Blob(["data"], {type: "myType"});
+			const f = new File([data], "myfilename.bin", {type: data.type});
+			service.download(data, f.name, f.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only filename is specified", () => {
+			const data = new Blob(["data"]);
+			const f = new File([JSON.stringify(data)], "myfilename.bin");
+			service.download(data, f.name);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only content type is specified", () => {
+			const data = new Blob(["data"], {type: "myType"});
+			const f = new File([data], `${FileUtilsService.DEFAULT_FILE_NAME}.bin`, {type: "mytype"});
+			service.download(data, undefined, data.type);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files without any details", () => {
+			const data = new Blob(["data"]);
+			const f = new File([data], `${FileUtilsService.DEFAULT_FILE_NAME}.bin`);
+			service.download(data);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+	});
+
+	describe("downloading pre-constructed files", () => {
+		it("downloads files when everything is specified", () => {
+			const f = new File(["data"], "myfile", {type: "mytype"});
+			service.download(f);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only filename is specified", () => {
+			const f = new File(["data"], "myfile");
+			service.download(f);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files when only content type is specified", () => {
+			const f = new File(["data"], "", {type: "mytype"});
+			service.download(f);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+		it("downloads files without any details", () => {
+			const f = new File(["data"], "");
+			service.download(f);
+			expect(spy).toHaveBeenCalledTimes(1);
+			expect(urlSpy).toHaveBeenCalledOnceWith(f);
+		});
+	});
+
+});
diff --git a/experimental/traffic-portal/src/app/shared/file-utils.service.ts b/experimental/traffic-portal/src/app/shared/file-utils.service.ts
new file mode 100644
index 000000000..3242f8c93
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/file-utils.service.ts
@@ -0,0 +1,156 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { DOCUMENT } from "@angular/common";
+import { Inject, Injectable } from "@angular/core";
+
+import { isArrayBufferView } from "../utils";
+
+/**
+ * FileUtilsService provides utilities dealing with files, uploads, downloads,
+ * etc.
+ */
+@Injectable()
+export class FileUtilsService {
+
+	/** The default MIME-Type for string data downloads. */
+	public static readonly TEXT_CONTENT_TYPE = "text/plain;charset=UTF-8";
+	/** The default MIME-Type for raw binary data downloads. */
+	public static readonly BINARY_DATA_CONTENT_TYPE = "application/octet-stream";
+	/** The default MIME-Type for arbitrary object downloads. */
+	public static readonly JSON_DATA_CONTENT_TYPE = "application/json";
+
+	/**
+	 * The file name that will be used for downloads, if one is not provided.
+	 */
+	public static readonly DEFAULT_FILE_NAME = "download";
+
+	/** A pre-compiled expression that matches a '.' at the start of a string */
+	private static readonly EXT_PATTERN = /^\./;
+
+	private readonly window: Window;
+
+	constructor(@Inject(DOCUMENT) document: Document) {
+		const {defaultView} = document;
+		if (!defaultView) {
+			throw new Error("global root document has no default view; cannot access required functionality");
+		}
+		this.window = defaultView;
+	}
+
+	/**
+	 * Builds a file name for {@link FileUtilsService.download}.
+	 *
+	 * @param passed The file name as passed to the service.
+	 * @param defaultExt The default file extension, to be used if the passed
+	 * file name doesn't have one - or if no file name was passed.
+	 * @returns The constructed file name.
+	 */
+	private static constructFileName(passed: string | undefined, defaultExt: string): string {
+		const basename = passed || this.DEFAULT_FILE_NAME;
+		if (passed && basename.includes(".")) {
+			return basename;
+		}
+		return `${basename}.${defaultExt.replace(this.EXT_PATTERN, "")}`;
+	}
+
+	/**
+	 * Initiates a download of a file created from the passed data.
+	 *
+	 * @param data The data to be contained in the downloaded file. If this is
+	 * a string or a buffer of binary data, it's treated as the literal contents
+	 * of the file. If it's some arbitrary, unrecognized object, it'll be
+	 * encoded using `JSON.stringify`.
+	 * @param fileName The suggested name of the file in the browser's "Save"
+	 * dialog. If this isn't provided (or is an empty string),
+	 * {@link FileUtilsService.DEFAULT_FILE_NAME} will be used.
+	 * @param contentType The MIME content type of the data stored in the file,
+	 * which is used by some clients to determine how to handle the data (for
+	 * example most browsers display PDFs in-browser instead of initiating a
+	 * download). If this isn't provided,
+	 * {@link FileUtilsService.BINARY_DATA_CONTENT_TYPE} will be assumed for
+	 * ArrayBuffer and ArrayBufferView data,
+	 * {@link FileUtilsService.TEXT_CONTENT_TYPE} will be assumed for string
+	 * data, and {@link FileUtilsService.JSON_DATA_CONTENT_TYPE} will be used
+	 * when the passed data is encoded as JSON.
+	 */
+	public download(data: ArrayBuffer | ArrayBufferView | string | object, fileName?: string, contentType?: string): void;
+	/**
+	 * Initiates a download of a file created from the passed content.
+	 *
+	 * @param content The data to be contained in the downloaded file. If the
+	 * blob has a `type`, it will be used as the file's MIME content type -
+	 * otherwise, {@link FileUtilsService.BINARY_DATA_CONTENT_TYPE} will be
+	 * used.
+	 * @param fileName The suggested name of the file in the browser's "Save"
+	 * dialog. If this isn't provided (or is an empty string),
+	 * {@link FileUtilsService.DEFAULT_FILE_NAME} will be used.
+	 */
+	public download(content: Blob, fileName?: string): void;
+	/**
+	 * Initiates a download of a file.
+	 *
+	 * @param file The file to be downloaded.
+	 */
+	public download(file: File): void;
+	/**
+	 * Initiates a download of a file created from the passed data, or passed
+	 * directly as a pre-constructed content blob or file object.
+	 *
+	 * @param file The data to be contained in the downloaded file. If this is
+	 * a string or a buffer of binary data, it's treated as the literal contents
+	 * of the file. If it's some arbitrary, unrecognized object, it'll be
+	 * encoded using `JSON.stringify`. If it's a blob, then it is treated as the
+	 * data to be contained in the downloaded file. If the blob has a `type`, it
+	 * will be used as the file's MIME content type - otherwise,
+	 * {@link FileUtilsService.BINARY_DATA_CONTENT_TYPE} will be used (the
+	 * `contentType` parameter of this service method is ignored in that case).
+	 * Lastly, if this is a pre-constructed `File`, then it is assumed to be
+	 * fully ready for download; `fileName` and `contentType` are both ignored.
+	 * @param fileName The suggested name of the file in the browser's "Save"
+	 * dialog. If this isn't provided (or is an empty string),
+	 * {@link FileUtilsService.DEFAULT_FILE_NAME} will be used - unless `file`
+	 * was a `File`, in which case it is totally ignored whether passed or not.
+	 * @param contentType The MIME content type of the data stored in the file,
+	 * which is used by some clients to determine how to handle the data (for
+	 * example most browsers display PDFs in-browser instead of initiating a
+	 * download). This is ignored if the data passed in `file` is a `Blob` or
+	 * `File`, and their respective `type` is used instead (if a `Blob` is
+	 * missing a type, then it will be set to
+	 * {@link FileUtilsService.BINARY_DATA_CONTENT_TYPE}).
+	 */
+	public download(file: File | Blob | ArrayBuffer | ArrayBufferView | string | object, fileName?: string, contentType?: string): void {
+		let f: File;
+		if (typeof(file) === "string") {
+			const fname = FileUtilsService.constructFileName(fileName, ".txt");
+			f = new File([file], fname, {type: contentType || FileUtilsService.TEXT_CONTENT_TYPE});
+		} else if (file instanceof(ArrayBuffer) || isArrayBufferView(file)) {
+			const fname = FileUtilsService.constructFileName(fileName, ".bin");
+			f = new File([file], fname, {type: contentType || FileUtilsService.BINARY_DATA_CONTENT_TYPE});
+		} else if (file instanceof File) {
+			f = file;
+		} else if (file instanceof Blob) {
+			const fname = FileUtilsService.constructFileName(fileName, ".bin");
+			f = new File([file], fname, {type: file.type || FileUtilsService.BINARY_DATA_CONTENT_TYPE});
+		} else {
+			const content = JSON.stringify(file);
+			const fname = FileUtilsService.constructFileName(fileName, ".json");
+			f = new File([content], fname, {type: contentType || FileUtilsService.JSON_DATA_CONTENT_TYPE});
+		}
+
+		const url = URL.createObjectURL(f);
+		this.window.open(url, "_blank");
+		URL.revokeObjectURL(url);
+	}
+}
diff --git a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
index 4fa73801f..cfcf73eb6 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -150,10 +150,16 @@ export class NavigationService {
 			}],
 			name: "Users"
 		}, {
-			children: [{
-				href: "/core/change-logs",
-				name: "Change Logs"
-			}],
+			children: [
+				{
+					href: "/core/change-logs",
+					name: "Change Logs"
+				},
+				{
+					href: "/core/iso-gen",
+					name: "Generate System ISO"
+				}
+			],
 			name: "Other"
 		}, {
 			children: [{
diff --git a/experimental/traffic-portal/src/app/shared/shared.module.ts b/experimental/traffic-portal/src/app/shared/shared.module.ts
index 3e7076a16..d12ab1823 100644
--- a/experimental/traffic-portal/src/app/shared/shared.module.ts
+++ b/experimental/traffic-portal/src/app/shared/shared.module.ts
@@ -26,6 +26,7 @@ import { LinechartDirective } from "./charts/linechart.directive";
 import { CollectionChoiceDialogComponent } from "./dialogs/collection-choice-dialog/collection-choice-dialog.component";
 import { DecisionDialogComponent } from "./dialogs/decision-dialog/decision-dialog.component";
 import { TextDialogComponent } from "./dialogs/text-dialog/text-dialog.component";
+import { FileUtilsService } from "./file-utils.service";
 import { GenericTableComponent } from "./generic-table/generic-table.component";
 import { AlertInterceptor } from "./interceptor/alerts.interceptor";
 import { DateReviverInterceptor } from "./interceptor/date-reviver.interceptor";
@@ -85,7 +86,8 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
 	providers: [
 		{ multi: true, provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor },
 		{ multi: true, provide: HTTP_INTERCEPTORS, useClass: AlertInterceptor },
-		{ multi: true, provide: HTTP_INTERCEPTORS, useClass: DateReviverInterceptor}
+		{ multi: true, provide: HTTP_INTERCEPTORS, useClass: DateReviverInterceptor },
+		FileUtilsService,
 	]
 })
 export class SharedModule { }
diff --git a/experimental/traffic-portal/src/app/utils/index.spec.ts b/experimental/traffic-portal/src/app/utils/index.spec.ts
new file mode 100644
index 000000000..a283b816b
--- /dev/null
+++ b/experimental/traffic-portal/src/app/utils/index.spec.ts
@@ -0,0 +1,148 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TestBed } from "@angular/core/testing";
+
+import { hasProperty, isArray, isArrayBufferView, isBoolean, isNumber, isNumberArray, isRecord, isString, isStringArray } from ".";
+
+describe("Typing utility functions", () => {
+	beforeEach(() => TestBed.configureTestingModule({}));
+
+	it("should check for property existence correctly", () => {
+		let test = {};
+		expect(hasProperty(test, "anything")).toBeFalse();
+		expect(hasProperty(test, 0)).toBeFalse();
+
+		test = {anything: "something"};
+		expect(hasProperty(test, "anything")).toBeTrue();
+		expect(hasProperty(test, 0)).toBeFalse();
+
+		// eslint-disable-next-line @typescript-eslint/naming-convention
+		test = {0: "something"};
+		expect(hasProperty(test, "anything")).toBeFalse();
+		expect(hasProperty(test, 0)).toBeTrue();
+	});
+
+	it("should check property types correctly", () => {
+		const wrong = {wrong: "type"};
+		const isNum = (x: unknown): x is number => typeof x === "number";
+		expect(hasProperty(wrong, "wrong", isNum)).toBeFalse();
+		const right = {right: 0};
+		expect(hasProperty(right, "right", isNum)).toBeTrue();
+	});
+
+	it("should check for string types correctly", () => {
+		let test: string | number = "";
+		expect(isString(test)).toBeTrue();
+		test = 5;
+		expect(isString(test)).toBeFalse();
+	});
+
+	it("should check for numeric types correctly", () => {
+		let test: string | number = "0";
+		expect(isNumber(test)).toBeFalse();
+		test = 5;
+		expect(isNumber(test)).toBeTrue();
+	});
+
+	it("should check for boolean types correctly", () => {
+		let test: string | boolean = "true";
+		expect(isBoolean(test)).toBeFalse();
+		test = true;
+		expect(isBoolean(test)).toBeTrue();
+	});
+
+	it("should check ambiguous record types correctly", () => {
+		expect(isRecord(null)).toBeFalse();
+		expect(isRecord({})).toBeTrue();
+	});
+
+	it("should check homogenous record types correctly", () => {
+		const passes = {
+			all: "properties",
+			are: "strings"
+		};
+		expect(isRecord(passes, "string")).toBeTrue();
+		const numbers = {
+			numeric: 1,
+			only: 5.23e7,
+			properties: 0x2e,
+		};
+		expect(isRecord(numbers, "number")).toBeTrue();
+		const fails = {
+			not: "all",
+			properties: "are",
+			strings: 0
+		};
+		expect(isRecord(fails, "string")).toBeFalse();
+		const isZero = (x: unknown): x is 0 => x === 0;
+		expect(isRecord(fails, isZero)).toBeFalse();
+		const customPasses = {
+			everything: 0,
+			is: 0,
+			zero: 0
+		};
+		expect(isRecord(customPasses, isZero)).toBeTrue();
+	});
+
+	it("should check homogenous array types correctly", () => {
+		const a = {};
+		expect(isArray(a)).toBeFalse();
+		const b = new Array();
+		expect(isArray(b)).toBeTrue();
+		b.push(5);
+		expect(isArray(b)).toBeTrue();
+		expect(isArray(b, "number")).toBeTrue();
+		expect(isArray(b, "string")).toBeFalse();
+		b.push("test");
+		expect(isArray(b)).toBeTrue();
+		expect(isArray(b, "number")).toBeFalse();
+		expect(isArray(b, "string")).toBeFalse();
+		expect(isArray(b, (x): x is Array<number | string> => typeof x === "number" || typeof x === "string")).toBeTrue();
+	});
+
+	it("should verify that only objects can be records", ()=>{
+		expect(isRecord(0, (_): _ is unknown => true)).toBeFalse();
+	});
+
+	it("should be able to verify existence and type of array properties", () => {
+		const a = {
+			test: new Array<number|string>()
+		};
+		expect(hasProperty(a, "test", isArray)).toBeTrue();
+		a.test.push("test");
+		expect(hasProperty(a, "test", isStringArray)).toBeTrue();
+		expect(hasProperty(a, "test", isNumberArray)).toBeFalse();
+		a.test.push(5);
+		expect(hasProperty(a, "test", isArray)).toBeTrue();
+		expect(hasProperty(a, "test", isStringArray)).toBeFalse();
+		expect(hasProperty(a, "test", isNumberArray)).toBeFalse();
+	});
+
+	it("knows if an object is an ArrayBufferView", () => {
+		expect(isArrayBufferView(undefined)).toBeFalse();
+		expect(isArrayBufferView(undefined)).toBeFalse();
+		expect(isArrayBufferView(undefined)).toBeFalse();
+		expect(isArrayBufferView(new Int8Array())).toBeTrue();
+		expect(isArrayBufferView(new Uint8Array())).toBeTrue();
+		expect(isArrayBufferView(new Uint8ClampedArray())).toBeTrue();
+		expect(isArrayBufferView(new Int16Array())).toBeTrue();
+		expect(isArrayBufferView(new Uint16Array())).toBeTrue();
+		expect(isArrayBufferView(new Int32Array())).toBeTrue();
+		expect(isArrayBufferView(new Uint32Array())).toBeTrue();
+		expect(isArrayBufferView(new DataView(new ArrayBuffer(0)))).toBeTrue();
+		expect(isArrayBufferView([1, 2, 3])).toBeFalse();
+		expect(isArrayBufferView([0x01n, 2n, 3n])).toBeFalse();
+	});
+});
diff --git a/experimental/traffic-portal/src/app/utils/index.ts b/experimental/traffic-portal/src/app/utils/index.ts
index 3efe9a047..605c9818c 100644
--- a/experimental/traffic-portal/src/app/utils/index.ts
+++ b/experimental/traffic-portal/src/app/utils/index.ts
@@ -283,3 +283,374 @@ export const enum AutocompleteValue {
 	/** A nickname or handle. */
 	NICKNAME = "nickname",
 }
+
+/**
+ * Checks if an object is a string. Useful for passing as a type guard into
+ * generic functions. In general, you should just use `typeof` instead.
+ *
+ * @param x The object to check.
+ * @returns `true` if `x` is a string, `false` otherwise.
+ */
+export function isString(x: unknown): x is string {
+	return typeof(x) === "string";
+}
+
+/**
+ * Checks if an object is a number. Useful for passing as a type guard into
+ * generic functions. In general, you should just use `typeof` instead.
+ *
+ * @param x The object to check.
+ * @returns `true` if `x` is a number, `false` otherwise.
+ */
+export function isNumber(x: unknown): x is number {
+	return typeof(x) === "number";
+}
+
+/**
+ * Checks if an object is a boolean. Useful for passing as a type guard into
+ * generic functions. In general, you should just use `typeof` instead.
+ *
+ * @param x The object to check.
+ * @returns `true` if `x` is a boolean, `false` otherwise.
+ */
+export function isBoolean(x: unknown): x is boolean {
+	return typeof(x) === "boolean";
+}
+
+/**
+ * isRecord checks that the passed object is a Record (object).
+ *
+ * @param x The object to check.
+ * @returns `true` if `x` is a Record, `false` otherwise.
+ */
+export function isRecord(x: unknown): x is Record<PropertyKey, unknown>;
+/**
+ * isRecord checks that the passed object is a Record with string property
+ * values.
+ *
+ * @param x The object to check.
+ * @param type Indicates we are checking for string properties.
+ * @returns `true` if `x` is a Record of properties with string values, `false`
+ * otherwise.
+ */
+export function isRecord(x: unknown, type: "string"): x is Record<PropertyKey, string>;
+/**
+ * isRecord checks that the passed object is a Record with numeric property
+ * values.
+ *
+ * @param x The object to check.
+ * @param type Indicates we are checking for number properties.
+ * @returns `true` if `x` is a Record of properties with numeric values, `false`
+ * otherwise.
+ */
+export function isRecord(x: unknown, type: "number"): x is Record<PropertyKey, number>;
+/**
+ * isRecord checks that the passed object is a Record with a certain, homogenous
+ * type.
+ *
+ * @param x The object to check.
+ * @param checker A type guard that will be used to ensure each property is of
+ * the type for which it checks.
+ * @returns `true` if `x` is a Record of properties with values that satisfy
+ * `checker`, `false` otherwise.
+ */
+export function isRecord<T>(x: unknown, checker: (p: unknown) => p is T): x is Record<PropertyKey, T>;
+/**
+ * isRecord checks that the passed object is a Record, optionally of a certain,
+ * homogenous type.
+ *
+ * @param x The object to check.
+ * @param checker Either the name of a primitive type to check, or a custom
+ * type checker function to use to check the type of each property value. If not
+ * given, the values of properties is not verified.
+ * @returns `true` if `x` is a Record of properties with values optionally given
+ * by `checker`.
+ */
+export function isRecord<T>(x: unknown, checker?: "string" | "number" | ((p: unknown) => p is T)): x is Record<PropertyKey, T> {
+	if (typeof x !== "object" || x === null) {
+		return false;
+	}
+	if (!checker) {
+		return true;
+	}
+	let chk;
+	switch (checker) {
+		case "string":
+			chk = isString;
+			break;
+		case "number":
+			chk = isNumber;
+			break;
+		default:
+			chk = checker;
+	}
+	return Object.values(x).every(chk);
+}
+
+/**
+ * isArray checks if something is an Array. This call signature is provided for
+ * generic completeness - it should basically never be used. Instead, use the
+ * built-in `Array.isArray` method.
+ *
+ * @param a The possible array to check.
+ * @returns `true` if `a` is any kind of Array, `false` otherwise.
+ */
+export function isArray(a: unknown): a is Array<unknown>;
+/**
+ * isArray checks if some object is an array of strings. This is exactly
+ * equivalent to using {@link isStringArray}.
+ *
+ * @param a The possible array to check.
+ * @param type Indicates we are checking for an array of strings.
+ * @returns `true` if `a` is an array containing only strings, `false`
+ * otherwise.
+ */
+export function isArray(a: unknown, type: "string"): a is Array<string>;
+/**
+ * isArray checks if some object is an array of numbers. This is exactly
+ * equivalent to using {@link isNumberArray}.
+ *
+ * @param a The possible array to check.
+ * @param type Indicates we are checking for an array of numbers.
+ * @returns `true` if `a` is an array containing only numbers, `false`
+ * otherwise.
+ */
+export function isArray(a: unknown, type: "number"): a is Array<number>;
+/**
+ * isArray checks if some object is a homogenous array of some specific type.
+ *
+ * @param a The possible array to check.
+ * @param checker A type guard that will be used to verify that `a` - if it
+ * indeed be an array - contains only types of data that satisfy the guard.
+ * @returns `true` if `a` is an array containing only values of type `T`,
+ * `false` otherwise.
+ */
+export function isArray<T>(a: unknown, checker: (x: unknown) => x is T): a is Array<T>;
+/**
+ * isArray checks if something is an Array, optionally with some homogenous
+ * value.
+ *
+ * @param a The possible array to check.
+ * @param checker If given, this enforces that `a` is a homogenous array. If
+ * this is the name of a primitive, it automatically checks for that primitive.
+ * More complex types require that this be a type guard in its own right, to
+ * match against each element of `a`.
+ * @returns `true` if `a` is an array, satisfying a homogeneity checker if one
+ * is provided, `false` otherwise.
+ */
+export function isArray<T>(a: unknown, checker?: "string" | "number" | ((x: unknown) => x is T)): a is Array<T> {
+	if (!Array.isArray(a)) {
+		return false;
+	}
+	if (!checker) {
+		return true;
+	}
+
+	let chk: (x: unknown) => x is T | string | number;
+	switch (checker) {
+		case "number":
+			chk = isNumber;
+			break;
+		case "string":
+			chk = isString;
+			break;
+		default:
+			chk = checker;
+	}
+
+	return a.every(chk);
+}
+
+/**
+ * isStringArray checks if the passed object is a homogeneous array of strings.
+ *
+ * @param sa The potential string array.
+ * @returns `true` if `sa` is an array and contains only strings, `false`
+ * otherwise.
+ */
+export const isStringArray = (sa: unknown): sa is Array<string> => isArray(sa, "string");
+/**
+ * isNumberArray checks if the passed object is a homogeneous array of numbers.
+ *
+ * @param na The potential numeric array.
+ * @returns `true` if `na` is an array and contains only numbers, `false`
+ * otherwise.
+ */
+export const isNumberArray = (na: unknown): na is Array<number> => isArray(na, "number");
+
+/**
+ * hasProperty checks, generically, whether some variable passed as `o` has the
+ * property `k`, where `k` is of some specific type.
+ *
+ * @note This only works for 'object' sub-types. Other basic types have easier
+ * checks.
+ *
+ * @example
+ * hasProperty({}, "id", isNumber); // returns `false`.
+ *
+ * @example
+ * const isNum: (x: unknown) => x is number = x => typeof x === number;
+ * // returns `false`
+ * hasProperty({wrong: "type"}, "wrong", isNum, "number");
+ *
+ * @param o The object to check.
+ * @param k The key for which to check in the object.
+ * @param s This type guard will be used to narrow the *type* of the property,
+ * as well as its existence.
+ * @returns `true` if o has a property `k` such that the value of `o.k`
+ * satisfies `s`, `false` otherwise.
+ * @throws {TypeError} when called improperly.
+ */
+export function hasProperty<T extends object, K extends PropertyKey, S>
+(o: T, k: K, s: (x: unknown) => x is S): o is T & Record<K, S>;
+/**
+ * hasProperty checks, generically, whether some variable passed as `o` has the
+ * property `k` such that `o.k` is a string.
+ *
+ * @note This only works for 'object' sub-types. Other basic types have easier
+ * checks.
+ *
+ * @example
+ * hasProperty({}, "id", "string"); // returns `false`.
+ *
+ * @example
+ * hasProperty({wrongType: 5}, "wrongType", "string"); // returns `false`
+ *
+ * @param o The object to check.
+ * @param k The key for which to check in the object.
+ * @param s indicates that we are checking for a string value of `o.k`.
+ * @returns `true` if o has a property `k` such that the value of `o.k` is a
+ * string, `false` otherwise.
+ * @throws {TypeError} when called improperly.
+ */
+export function hasProperty<T extends object, K extends PropertyKey>
+(o: T, k: K, s: "string"): o is T & Record<K, string>;
+/**
+ * hasProperty checks, generically, whether some variable passed as `o` has the
+ * property `k` such that `o.k` is a number.
+ *
+ * @note This only works for 'object' sub-types. Other basic types have easier
+ * checks.
+ *
+ * @example
+ * hasProperty({}, "id", "number"); // returns `false`.
+ *
+ * @example
+ * hasProperty({wrong: "type"}, "wrong", "number"); // returns `false`
+ *
+ * @param o The object to check.
+ * @param k The key for which to check in the object.
+ * @param s indicates that we are checking for a numeric value of `o.k`.
+ * @returns `true` if o has a property `k` such that the value of `o.k` is a
+ * number, `false` otherwise.
+ * @throws {TypeError} when called improperly.
+ */
+export function hasProperty<T extends object, K extends PropertyKey>
+(o: T, k: K, s: "number"): o is T & Record<K, number>;
+/**
+ * hasProperty checks, generically, whether some variable passed as `o` has the
+ * property `k` such that `o.k` is a boolean.
+ *
+ * @note This only works for 'object' sub-types. Other basic types have easier
+ * checks.
+ *
+ * @example
+ * hasProperty({}, "id", "boolean"); // returns `false`.
+ *
+ * @example
+ * hasProperty({wrong: "type"}, "wrong", "boolean"); // returns `false`
+ *
+ * @param o The object to check.
+ * @param k The key for which to check in the object.
+ * @param s indicates that we are checking for a boolean value of `o.k`.
+ * @returns `true` if o has a property `k` such that the value of `o.k` is a
+ * boolean, `false` otherwise.
+ * @throws {TypeError} when called improperly.
+ */
+export function hasProperty<T extends object, K extends PropertyKey>
+(o: T, k: K, s: "boolean"): o is T & Record<K, boolean>;
+/**
+ * hasProperty checks, generically, whether some variable passed as `o` has the
+ * property `k`.
+ *
+ * @note This only works for 'object' sub-types. Other basic types have easier
+ * checks.
+ *
+ * @example
+ * hasProperty({}, "id"); // returns `false`.
+ *
+ * @param o The object to check.
+ * @param k The key for which to check in the object.
+ * @returns `true` if o has a property `k`, `false` otherwise.
+ * @throws {TypeError} when called improperly.
+ */
+export function hasProperty<T extends object, K extends PropertyKey>
+(o: T, k: K): o is T & Record<K, unknown>;
+/**
+ * hasProperty checks, generically, whether some variable passed as `o` has the
+ * property `k`. It can also optionally narrow the type of `o.k`.
+ *
+ * @note This only works for 'object' sub-types. Other basic types have easier
+ * checks.
+ *
+ * @example
+ * hasProperty({}, "id"); // returns `false`
+ *
+ * @example
+ * const isNum: (x: unknown) => x is number = x => typeof x === number;
+ * // returns `false`.
+ * hasProperty({wrong: "type"}, "wrong", isNum);
+ *
+ * @param o The object to check.
+ * @param k The key for which to check in the object.
+ * @param s If provided, this type guard can be used to narrow the *type* of
+ * the property, as well as its existence.
+ * @returns `true` if `o` has a property `k` such that any provided `s` is
+ * satisfied by `o.k`.
+ * @throws {Error} when the type check fails.
+ */
+export function hasProperty<T extends object, K extends string | number, S = unknown>(
+	o: T,
+	k: K,
+	s?: "string" | "number" | "boolean" | ((x: unknown) => x is S)
+): o is T & Record<K, S> {
+	if (o === null || !Object.prototype.hasOwnProperty.call(o, k)) {
+		return false;
+	}
+	if (s) {
+		const val = (o as Record<K, unknown>)[k];
+		switch (s) {
+			case "string":
+			case "number":
+			case "boolean":
+				return typeof(val) === s;
+		}
+		return s(val);
+	}
+	return true;
+}
+
+/**
+ * Checks if the input implements the ArrayBufferView interface. NodeJS has a
+ * built-in for this, but that won't be available in the browser.
+ *
+ * @param x The object to check.
+ * @returns `true` if `x` is a typed array or a DataView, `false` otherwise.
+ */
+export function isArrayBufferView(x: unknown): x is ArrayBufferView {
+	if (!x || typeof(x) !== "object") {
+		return false;
+	}
+	switch(x.constructor) {
+		case Int8Array:
+		case Uint8Array:
+		case Uint8ClampedArray:
+		case Int16Array:
+		case Uint16Array:
+		case Int32Array:
+		case Uint32Array:
+		case DataView:
+			return true;
+	}
+	return false;
+}
diff --git a/experimental/traffic-portal/src/app/utils/ip.ts b/experimental/traffic-portal/src/app/utils/ip.ts
index d7851a422..bafd03f1e 100644
--- a/experimental/traffic-portal/src/app/utils/ip.ts
+++ b/experimental/traffic-portal/src/app/utils/ip.ts
@@ -24,17 +24,31 @@
 // wouldn't be any easier to read, I think.
 /* eslint-disable max-len*/
 /**
- * This RegExp matches any valid IPv4 or IPv6 address - CIDRs allowed on IPv6 addresses only
+ * This RegExp matches any valid IPv4 or IPv6 address - CIDRs allowed on IPv6
+ * addresses only.
  * Source: Rahul Tripathy @ https://stackoverflow.com/questions/32324614/how-to-validate-ipv6-address-in-angularjs#answer-32324868
  * May Neptune have mercy on my soul...
  */
 export const IP = "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]):){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^\\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?(\\/(12[0-8]|1[0-1][0-9]|[0-9][0-9]?))?$|^((25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)";
-// export const IP = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]):){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?(\/(12[0-8]|1[0-1][0-9]|[0-9][0-9]?))?$|^((25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)$/;
 
-/** This RegExp matches IP addresses and allows IPv4 addresses to have optional CIDR-notation subnets */
+/**
+ * This RegExp matches IP addresses just like {@link IP}, but allows IPv4
+ * addresses to have optional CIDR-notation subnets.
+ */
 export const IP_WITH_CIDR = "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]):){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^\\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?(\\/(12[0-8]|1[0-1][0-9]|[0-9][0-9]?))?$|^((25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)(\\/(3[0-2]|[0-2]?[0-9]))?";
-//export const IP_WITH_CIDR = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]):){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?(\/(12[0-8]|1[0-1][0-9]|[0-9][0-9]?))?$|^((25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)(\/(3[0-2]|[0-2]?[0-9]))?$/;
 
 /** This RegExp matches valid IPv4 addresses (or netmasks). */
-export const IPV4 = /^((25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)$/;
+export const IPV4 = /^(1\d\d|2[0-4]\d|25[0-5]|\d\d?)(\.(1\d\d|2[0-4]\d|25[0-5]|\d\d?)){3}$/;
+
+/**
+ * A regular expression that matches IPv6 addresses - **without** a CIDR.
+ *
+ * This is huge and ugly, but there's no JS built-in for address parsing afaik.
+ */
+export const IPV6 = /^(?:(?:[a-fA-F\d]{1,4}:){7}(?:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,2}|:)|(?:[a-fA-F\d]{1,4}:){4}(?:(?::[a-fA-F\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,3}|:)|(?:[a-fA-F\d]{1,4}:){3}(?:(?::[a-fA-F\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,4}|:)|(?:[a-fA-F\d]{1,4}:){2}(?:(?::[a-fA-F\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,5}|:)|(?:[a-fA-F\d]{1,4}:){1}(?:(?::[a-fA-F\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?$/;
+/**
+ * This matches IPv6 addresses just like {@link IPV6}, but allows an optional
+ * CIDR-notation subnet suffix.
+ */
+export const IPV6_WITH_CIDR = /^(?:(?:[a-fA-F\d]{1,4}:){7}(?:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,2}|:)|(?:[a-fA-F\d]{1,4}:){4}(?:(?::[a-fA-F\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,3}|:)|(?:[a-fA-F\d]{1,4}:){3}(?:(?::[a-fA-F\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,4}|:)|(?:[a-fA-F\d]{1,4}:){2}(?:(?::[a-fA-F\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,5}|:)|(?:[a-fA-F\d]{1,4}:){1}(?:(?::[a-fA-F\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?(?:\/(?:[01]{1,2}\d|12[0-8]))?$/;
 /* eslint-enable max-len */
diff --git a/experimental/traffic-portal/src/styles.scss b/experimental/traffic-portal/src/styles.scss
index cf3064cd5..1b40eabc5 100644
--- a/experimental/traffic-portal/src/styles.scss
+++ b/experimental/traffic-portal/src/styles.scss
@@ -152,3 +152,13 @@ button {
 	bottom: 16px;
 	right: 16px;
 }
+
+abbr[title] {
+	cursor: help;
+}
+
+form mat-hint.mat-hint.input-warning {
+	color: #ff9800;
+	font-weight: bold;
+	font-size: x-small;
+}
diff --git a/traffic_ops/traffic_ops_golang/server/servers_assignment.go b/traffic_ops/traffic_ops_golang/server/servers_assignment.go
index fe11af3f6..27fa04caf 100644
--- a/traffic_ops/traffic_ops_golang/server/servers_assignment.go
+++ b/traffic_ops/traffic_ops_golang/server/servers_assignment.go
@@ -310,7 +310,7 @@ func ValidateDSCapabilities(dsIDs []int, serverName string, tx *sql.Tx) (error,
 		}
 	}
 
-	return nil, nil, 0
+	return nil, nil, http.StatusOK
 }
 
 func assignDeliveryServicesToServer(server int, dses []int, replace bool, tx *sql.Tx) ([]int, error) {


Test patch:
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/asns/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/asns/detail.spec.ts
new file mode 100644
index 000000000..aa1f6a20b
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/asns/detail.spec.ts
@@ -0,0 +1,44 @@
+/*
+ * 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("Asn Detail Spec", () => {
+	it("Test Asn", () => {
+		const page = browser.page.cacheGroups.asnDetail();
+		browser.url(`${page.api.launchUrl}/core/asns/${browser.globals.testData.asn.id}`, res => {
+			browser.assert.ok(res.status === 0);
+			page.waitForElementVisible("mat-card")
+				.assert.enabled("@asn")
+				.assert.enabled("@cachegroup")
+				.assert.enabled("@saveBtn")
+				.assert.not.enabled("@id")
+				.assert.not.enabled("@lastUpdated")
+				.assert.valueEquals("@asn", String(browser.globals.testData.asn.asn))
+				.assert.valueEquals("@id", String(browser.globals.testData.asn.id));
+		});
+	});
+
+	it("New asn", () => {
+		const page = browser.page.cacheGroups.asnDetail();
+		browser.url(`${page.api.launchUrl}/core/asns/new`, res => {
+			browser.assert.ok(res.status === 0);
+			page.waitForElementVisible("mat-card")
+				.assert.enabled("@asn")
+				.assert.enabled("@cachegroup")
+				.assert.enabled("@saveBtn")
+				.assert.not.elementPresent("@id")
+				.assert.not.elementPresent("@lastUpdated")
+				.assert.valueEquals("@asn", "1");
+		});
+	});
+});
diff --git a/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts b/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
index a87c0a06a..fbe62ee51 100644
--- a/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
@@ -16,13 +16,14 @@ import type {
 	CacheGroupQueueRequest,
 	CacheGroupQueueResponse,
 	CDN,
+	RequestASN,
+	ResponseASN,
 	RequestCacheGroup,
+	ResponseCacheGroup,
 	RequestCoordinate,
+	ResponseCoordinate,
 	RequestDivision,
 	RequestRegion,
-	ResponseASN,
-	ResponseCacheGroup,
-	ResponseCoordinate,
 	ResponseDivision,
 	ResponseRegion,
 } from "trafficops-types";
@@ -698,13 +699,46 @@ export class CacheGroupService {
 		return this.asns;
 	}
 
+	/**
+	 * Replaces the current definition of a ASN with the one given.
+	 *
+	 * @param asn The new ASN.
+	 * @returns The updated ASN.
+	 */
+	public async updateASN(asn: ResponseASN): Promise<ResponseASN> {
+		const id = this.asns.findIndex(a => a.id === asn.id);
+		if (id === -1) {
+			throw new Error(`no such ASN: ${asn.id}`);
+		}
+		this.asns[id] = asn;
+		return asn;
+	}
+
+	/**
+	 * Creates a new ASN.
+	 *
+	 * @param asn The ASN to create.
+	 * @returns The created ASN.
+	 */
+	public async createASN(asn: RequestASN): Promise<ResponseASN> {
+		const sn = {
+			...asn,
+			cachegroup: this.cacheGroups.find(cg => cg.id === asn.cachegroupId)?.name ?? "",
+			cachegroupId: asn.cachegroupId,
+			id: ++this.lastID,
+			lastUpdated: new Date()
+		};
+		this.asns.push(sn);
+		return sn;
+	}
+
 	/**
 	 * Deletes an existing asn.
 	 *
-	 * @param asn Id of the asn to delete.
+	 * @param asn The ASN to be deleted or ID of the ASN to delete..
 	 * @returns The deleted asn.
 	 */
-	public async deleteASN(asn: number | ResponseASN): Promise<ResponseASN> {
+	public async deleteASN(asn: ResponseASN | number): Promise<ResponseASN> {
 		const index = this.asns.findIndex(a => a.asn === asn);
 		if (index === -1) {
 			throw new Error(`no such asn: ${asn}`);
diff --git a/experimental/traffic-portal/src/app/api/testing/index.ts b/experimental/traffic-portal/src/app/api/testing/index.ts
index d73f26a05..404872673 100644
--- a/experimental/traffic-portal/src/app/api/testing/index.ts
+++ b/experimental/traffic-portal/src/app/api/testing/index.ts
@@ -21,6 +21,7 @@ import {
 	ChangeLogsService,
 	DeliveryServiceService,
 	InvalidationJobService,
+	MiscAPIsService,
 	PhysicalLocationService,
 	ProfileService,
 	ServerService,
@@ -33,6 +34,7 @@ import { CDNService as TestingCDNService } from "./cdn.service";
 import { ChangeLogsService as TestingChangeLogsService} from "./change-logs.service";
 import { DeliveryServiceService as TestingDeliveryServiceService } from "./delivery-service.service";
 import { InvalidationJobService as TestingInvalidationJobService } from "./invalidation-job.service";
+import { MiscAPIsService as TestingMiscAPIsService } from "./misc-apis.service";
 import { PhysicalLocationService as TestingPhysicalLocationService } from "./physical-location.service";
 import { ProfileService as TestingProfileService } from "./profile.service";
 import { ServerService as TestingServerService } from "./server.service";
@@ -54,12 +56,14 @@ import { UserService as TestingUserService } from "./user.service";
 		{provide: CDNService, useClass: TestingCDNService},
 		{provide: DeliveryServiceService, useClass: TestingDeliveryServiceService},
 		{provide: InvalidationJobService, useClass: TestingInvalidationJobService},
+		{provide: MiscAPIsService, useClass: TestingMiscAPIsService},
 		{provide: PhysicalLocationService, useClass: TestingPhysicalLocationService},
 		{provide: ProfileService, useClass: TestingProfileService},
 		{provide: ServerService, useClass: TestingServerService},
 		{provide: TypeService, useClass: TestingTypeService},
 		{provide: UserService, useClass: TestingUserService},
-		TestingServerService
+		TestingServerService,
+		TestingMiscAPIsService
 	]
 })
 export class APITestingModule { }
diff --git a/experimental/traffic-portal/src/app/api/testing/misc-apis.service.spec.ts b/experimental/traffic-portal/src/app/api/testing/misc-apis.service.spec.ts
new file mode 100644
index 000000000..25bea362d
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/testing/misc-apis.service.spec.ts
@@ -0,0 +1,55 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TestBed } from "@angular/core/testing";
+
+import type { MiscAPIsService } from "../misc-apis.service";
+
+import { MiscAPIsService as TestingMiscAPIsService } from "./misc-apis.service";
+
+import { APITestingModule } from ".";
+
+describe("TestingMiscAPIsService", () => {
+	let service: TestingMiscAPIsService;
+
+	beforeEach(() => {
+		TestBed.configureTestingModule({
+			imports: [APITestingModule]
+		});
+		service = TestBed.inject(TestingMiscAPIsService);
+	});
+
+	it("should be created", () => {
+		expect(service).toBeTruthy();
+	});
+
+	it("returns a static set of mock os versions", async () => {
+		expect(await service.getISOOSVersions()).toEqual(service.osVersions);
+	});
+
+	it("gives back an empty blob when requesting an ISO be generated, no matter what you give it", async () => {
+		let blob = await service.generateISO();
+		expect(blob.size).toBe(0);
+		blob = await (service as unknown as MiscAPIsService).generateISO({
+			dhcp: "yes",
+			disk: "sda",
+			domainName: "domain-name",
+			hostName: "host-name",
+			interfaceMtu: 1500,
+			osVersionDir: "a version that doesn't even exist",
+			rootPass: ""
+		});
+		expect(blob.size).toBe(0);
+	});
+});
diff --git a/experimental/traffic-portal/src/app/api/testing/misc-apis.service.ts b/experimental/traffic-portal/src/app/api/testing/misc-apis.service.ts
new file mode 100644
index 000000000..3bde68a95
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/testing/misc-apis.service.ts
@@ -0,0 +1,55 @@
+/**
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Injectable } from "@angular/core";
+import type { OSVersions } from "trafficops-types";
+
+/**
+ * This service implements APIs that aren't specific to any given ATC object.
+ * They can provide things like system information, access to the wacky external
+ * tooling provided by TO, basically anything that doesn't fit in a different
+ * API service.
+ */
+@Injectable()
+export class MiscAPIsService {
+	/** Some static mock OS versions. */
+	public readonly osVersions = {
+		// eslint-disable-next-line @typescript-eslint/naming-convention
+		"CentOS 7": "centos7",
+		// eslint-disable-next-line @typescript-eslint/naming-convention
+		"Rocky Linux 8": "rocky8"
+	};
+
+	/**
+	 * Retrieves the operating system versions that can be used to generate
+	 * system images through the Traffic Ops API.
+	 *
+	 * @returns A mapping of human-friendly operating system names to
+	 * machine-readable OS IDs that can be used in subsequent requests to
+	 * {@link MiscAPIsService.generateISO}.
+	 */
+	public async getISOOSVersions(): Promise<OSVersions> {
+		return this.osVersions;
+	}
+
+	/**
+	 * A mock call for generating system images.
+	 *
+	 * @returns In tests, this returns an empty data blob no matter what you
+	 * pass it.
+	 */
+	public async generateISO(): Promise<Blob> {
+		return new Blob();
+	}
+}
diff --git a/traffic_ops/testing/api_contract/README.md b/traffic_ops/testing/api_contract/README.md
new file mode 100644
index 000000000..ce08da215
--- /dev/null
+++ b/traffic_ops/testing/api_contract/README.md
@@ -0,0 +1,52 @@
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+-->
+
+# Traffic Ops API contract tests
+
+The Traffic Ops API contract tests are used to validate the Traffic Ops API's.
+
+## Setup
+
+In order to run the tests you will need a running instance of Traffic Ops and Traffic Ops DB:
+
+1. Follow the instructions for building and running Traffic Ops from docs.
+
+2. Install the requirements for testing API contract tests.
+
+    ```console
+    pip install -r /path/to/requirements.txt
+    ``` 
+
+## Running the API contract tests
+
+The API contract tests are run using `pytest` from the ``traffic_ops/testing/api_contract/v4`` directory.
+
+Example commands to run the tests:
+
+Only test a specific endpoint with arguments.
+> Note: For particular environments (Step 1 is not mandatory)
+```console
+pytest --to-user Username --to-password Password --to-url URL test_cdns.py
+```
+
+Only test a specific endpoint with local Traffic Ops instance.
+> Note: For local environment (Step 1 is mandatory)
+```console
+pytest -rA test_cdns.py
+```
diff --git a/traffic_ops/testing/api_contract/v4/conftest.py b/traffic_ops/testing/api_contract/v4/conftest.py
new file mode 100644
index 000000000..684089b6b
--- /dev/null
+++ b/traffic_ops/testing/api_contract/v4/conftest.py
@@ -0,0 +1,143 @@
+#
+# 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.
+#
+
+"""This module is used to create a Traffic Ops session 
+and to store prerequisite data for endpoints."""
+import json
+import logging
+import sys
+import os
+from random import randint
+from typing import NamedTuple, Optional
+from urllib.parse import urlparse
+import pytest
+import requests
+from trafficops.tosession import TOSession
+from trafficops.restapi import OperationError
+
+
+# Create and configure logger
+logger = logging.getLogger()
+
+
+class ArgsType(NamedTuple):
+    """Represents the arguments needed to create Traffic Ops session.
+
+    Attributes:
+        user (str): The username used for authentication.
+        password (str): The password used for authentication.
+        url (str): The URL of the environment.
+        port (int): The port number to use for session.
+        api_version (float): The version number of the API to use.
+    """
+    user: str
+    password: str
+    url: str
+    port: int
+    api_version: float
+
+
+def pytest_addoption(parser: pytest.Parser) -> None:
+    """Passing in Traffic Ops arguments [Username, Password, Url and Hostname] from command line.
+    :param parser: Parser to parse command line arguments
+    """
+    parser.addoption(
+        "--to-user", action="store", help="User name for Traffic Ops Session."
+    )
+    parser.addoption(
+        "--to-password", action="store", help="Password for Traffic Ops Session."
+    )
+    parser.addoption(
+        "--to-url", action="store", help="Traffic Ops URL."
+    )
+
+
+@pytest.fixture(name="to_args")
+def to_data(pytestconfig: pytest.Config) -> ArgsType:
+    """PyTest fixture to store Traffic ops arguments passed from command line.
+    :param pytestconfig: Session-scoped fixture that returns the session's pytest.Config object
+    :returns args: Return Traffic Ops arguments
+    """
+    with open(os.path.join(os.path.dirname(__file__), "to_data.json"),
+            encoding="utf-8", mode="r") as session_file:
+        session_data = json.load(session_file)
+    to_user = pytestconfig.getoption("--to-user")
+    to_password = pytestconfig.getoption("--to-password")
+    to_url = pytestconfig.getoption("--to-url")
+    api_version = urlparse(
+        session_data.get("url")).path.strip("/").split("/")[1]
+
+    if not all([to_user, to_password, to_url]):
+        logger.info(
+            "Traffic Ops session data were not passed from Command line Args.")
+        args = ArgsType(user=session_data.get("user"), password=session_data.get(
+            "password"), url=session_data.get("url"), port=session_data.get("port"),
+            api_version=api_version)
+
+    else:
+        args = ArgsType(user=to_user, password=to_password, url=to_url,
+                        port=session_data.get("port"), api_version=api_version)
+        logger.info("Parsed Traffic ops session data from args %s", args)
+    return args
+
+
+@pytest.fixture(name="to_session")
+def to_login(to_args: ArgsType) -> TOSession:
+    """PyTest Fixture to create a Traffic Ops session from Traffic Ops Arguments
+    passed as command line arguments in to_args fixture in conftest.
+    :param to_args: Fixture to get Traffic ops session arguments
+    :returns to_session: Return Traffic ops session
+    """
+    # Create a Traffic Ops V4 session and login
+    to_url = urlparse(to_args.url)
+    to_host = to_url.hostname
+    try:
+        to_session = TOSession(host_ip=to_host, host_port=to_args.port,
+                               api_version=to_args.api_version, ssl=True, verify_cert=False)
+        logger.info("Established Traffic Ops Session.")
+    except OperationError as error:
+        logger.debug("%s", error, exc_info=True, stack_info=True)
+        logger.error(
+            "Failure in Traffic Ops session creation. Reason: %s", error)
+        sys.exit(-1)
+
+    # Login To TO_API
+    to_session.login(to_args.user, to_args.password)
+    logger.info("Successfully logged into Traffic Ops.")
+    return to_session
+
+
+@pytest.fixture()
+def cdn_post_data(to_session: TOSession, cdn_prereq_data:
+                  object) -> Optional[list[dict[str, str] | requests.Response]]:
+    """PyTest Fixture to create POST data for cdns endpoint.
+    :param to_session: Fixture to get Traffic ops session 
+    :param get_cdn_data: Fixture to get cdn data from a prereq file
+    :returns prerequisite_data: Returns sample Post data and actual api response
+    """
+
+    # Return new post data and post response from cdns POST request
+    cdn_prereq_data["name"] = cdn_prereq_data["name"][:4]+str(randint(0, 1000))
+    cdn_prereq_data["domainName"] = cdn_prereq_data["domainName"][:5] + \
+        str(randint(0, 1000))
+    logger.info("New cdn data to hit POST method %s", cdn_prereq_data)
+    # Hitting cdns POST methed
+    response = to_session.create_cdn(data=cdn_prereq_data)
+    prerequisite_data = None
+    try:
+        cdn_response = response[0]
+        prerequisite_data = [cdn_prereq_data, cdn_response]
+    except IndexError:
+        logger.error("No CDN response data from cdns POST request.")
+    return prerequisite_data
diff --git a/traffic_ops/testing/api_contract/v4/prerequisite_data.json b/traffic_ops/testing/api_contract/v4/prerequisite_data.json
new file mode 100644
index 000000000..4939bbe28
--- /dev/null
+++ b/traffic_ops/testing/api_contract/v4/prerequisite_data.json
@@ -0,0 +1,9 @@
+{
+    "cdns": {
+        "name": "test",
+        "domainName": "quest",
+        "dnssecEnabled": false,
+        "id": null,
+        "lastUpdated": null
+    }
+}
diff --git a/traffic_ops/testing/api_contract/v4/pytest.ini b/traffic_ops/testing/api_contract/v4/pytest.ini
new file mode 100644
index 000000000..4fac4d37a
--- /dev/null
+++ b/traffic_ops/testing/api_contract/v4/pytest.ini
@@ -0,0 +1,20 @@
+#
+# 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.
+#
+
+[pytest]
+
+log_cli = 1
+log_cli_level = INFO
+log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)
+log_cli_date_format=%Y-%m-%dT%H:%M:%S
diff --git a/traffic_ops/testing/api_contract/v4/requirements.txt b/traffic_ops/testing/api_contract/v4/requirements.txt
new file mode 100644
index 000000000..81b925726
--- /dev/null
+++ b/traffic_ops/testing/api_contract/v4/requirements.txt
@@ -0,0 +1,16 @@
+#
+# 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.
+#
+
+Apache-TrafficControl==3.1.0
+pytest==7.2.1
diff --git a/traffic_ops/testing/api_contract/v4/test_cdns.py b/traffic_ops/testing/api_contract/v4/test_cdns.py
new file mode 100644
index 000000000..9b7e4d249
--- /dev/null
+++ b/traffic_ops/testing/api_contract/v4/test_cdns.py
@@ -0,0 +1,79 @@
+#
+# 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.
+#
+
+"""Api Contract Test Case for cdns endpoint."""
+import json
+import logging
+import os
+import pytest
+import requests
+from trafficops.tosession import TOSession
+
+# Create and configure logger
+logger = logging.getLogger()
+
+
+@pytest.fixture(name="cdn_prereq_data")
+def get_cdn_prereq_data() -> object:
+    """PyTest Fixture to store prereq data for cdns endpoint.
+    :returns cdn_data: Returns prerequisite data for cdns endpoint
+    """
+    # Response keys for cdns endpoint
+    with open(os.path.join(os.path.dirname(__file__), "prerequisite_data.json"),
+              encoding="utf-8", mode="r") as prereq_file:
+        data = json.load(prereq_file)
+    cdn_data = data["cdns"]
+    return cdn_data
+
+
+def test_cdn_contract(to_session: TOSession, cdn_prereq_data: object,
+                      cdn_post_data: list[dict[str, str] | requests.Response]) -> None:
+    """Test step to validate keys, values and data types from cdns endpoint response.
+    :param to_session: Fixture to get Traffic ops session 
+    :param get_cdn_data: Fixture to get cdn data from a prereq file
+    :param cdn_prereq: Fixture to get sample cdn data and actual cdn response
+    """
+    # validate CDN keys from cdns get response
+    logger.info("Accessing Cdn endpoint through Traffic ops session.")
+    cdn_name = cdn_post_data[0]["name"]
+    cdn_get_response = to_session.get_cdns(
+        query_params={"name": cdn_name})
+    try:
+        cdn_data = cdn_get_response[0]
+        cdn_keys = list(cdn_data[0].keys())
+        logger.info(
+            "CDN Keys from cdns endpoint response %s", cdn_keys)
+        # validate cdn values from prereq data in cdns get response.
+        prereq_values = [cdn_post_data[0]["name"], cdn_post_data[0]
+                         ["domainName"], cdn_post_data[0]["dnssecEnabled"]]
+        get_values = [cdn_data[0]["name"], cdn_data[0]
+                      ["domainName"], cdn_data[0]["dnssecEnabled"]]
+        # validate data types for values from cdn get json response.
+        for (prereq_value, get_value) in zip(prereq_values, get_values):
+            assert isinstance(prereq_value, type(get_value))
+        assert cdn_keys.sort() == list(cdn_prereq_data.keys()).sort()
+        assert get_values == prereq_values
+    except IndexError:
+        logger.error("No CDN data from cdns get request")
+        pytest.fail("Response from get request is empty, Failing test_get_cdn")
+    finally:
+        # Delete CDN after test execution to avoid redundancy.
+        try:
+            cdn_response = cdn_post_data[1]
+            cdn_id = cdn_response["id"]
+            to_session.delete_cdn_by_id(cdn_id=cdn_id)
+        except IndexError:
+            logger.error("CDN wasn't created")
+            pytest.fail(
+                "Response from delete request is empty, Failing test_get_cdn")
diff --git a/traffic_ops/testing/api_contract/v4/to_data.json b/traffic_ops/testing/api_contract/v4/to_data.json
new file mode 100644
index 000000000..903a0434a
--- /dev/null
+++ b/traffic_ops/testing/api_contract/v4/to_data.json
@@ -0,0 +1,6 @@
+{
+    "user": "admin",
+    "password": "twelve12",
+    "url": "https://localhost/api/4.0",
+    "port": 443
+}
\ No newline at end of file
diff --git a/traffic_ops/traffic_ops_golang/server/put_status_test.go b/traffic_ops/traffic_ops_golang/server/put_status_test.go
index 82b81e72a..def35a57b 100644
--- a/traffic_ops/traffic_ops_golang/server/put_status_test.go
+++ b/traffic_ops/traffic_ops_golang/server/put_status_test.go
@@ -29,6 +29,26 @@ import (
 	"gopkg.in/DATA-DOG/go-sqlmock.v1"
 )
 
+func TestInvalidStatusForDeliveryServicesAlertText(t *testing.T) {
+	type testStruct struct {
+		dsId     []int
+		expected string
+	}
+
+	var testData = []testStruct{
+		{[]int{0}, " #0 with no 'ONLINE' or 'REPORTED' EDGE servers"},
+		{[]int{0, 1}, "s #0 and #1 with no 'ONLINE' or 'REPORTED' EDGE servers"},
+		{[]int{0, 1, 2}, "s #0, #1, and #2 with no 'ONLINE' or 'REPORTED' EDGE servers"},
+	}
+
+	for i, _ := range testData {
+		desc := InvalidStatusForDeliveryServicesAlertText("", "EDGE", testData[i].dsId)
+		if testData[i].expected != desc {
+			t.Errorf("strings don't't match, got:%s; expected:%s", desc, testData[i].expected)
+		}
+	}
+}
+
 func TestCheckExistingStatusInfo(t *testing.T) {
 	mockDB, mock, err := sqlmock.New()
 	if err != nil {
diff --git a/traffic_ops/traffic_ops_golang/server/servers_assignment_test.go b/traffic_ops/traffic_ops_golang/server/servers_assignment_test.go
index 09027be53..5c387479d 100644
--- a/traffic_ops/traffic_ops_golang/server/servers_assignment_test.go
+++ b/traffic_ops/traffic_ops_golang/server/servers_assignment_test.go
@@ -183,3 +183,36 @@ func TestCheckTenancyAndCDN(t *testing.T) {
 		t.Errorf("tenancy and cdn check for a given user failed with status code:%d", code)
 	}
 }
+
+func TestValidateDSCapabilities(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%v' was not expected when opening a stub database connection", err)
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	mock.ExpectBegin()
+	rows := sqlmock.NewRows([]string{"server_capability"})
+	rows.AddRow([]byte("{eas}"))
+	mock.ExpectQuery("SELECT").WithArgs("eas").WillReturnRows(rows)
+
+	dsIDs := []int64{1}
+	rows1 := sqlmock.NewRows([]string{"id", "required_capabilities"})
+	rows1.AddRow(1, []byte("{eas}"))
+	mock.ExpectQuery("SELECT ").WithArgs(pq.Array(dsIDs)).WillReturnRows(rows1)
+	mock.ExpectCommit()
+
+	usrErr, sysErr, code := ValidateDSCapabilities([]int{1}, "eas", db.MustBegin().Tx)
+	if usrErr != nil {
+		t.Errorf("unable to validate DS capability, most likely cache doesn't have the DS capability, error: %v", usrErr)
+	}
+	if sysErr != nil {
+		t.Errorf("unable to validate DS Capability, system error: %v", sysErr)
+	}
+	if code != http.StatusOK {
+		t.Errorf("DS validation failed with status code:%d", code)
+	}
+}
diff --git a/traffic_ops/traffic_ops_golang/server/servers_test.go b/traffic_ops/traffic_ops_golang/server/servers_test.go
index 25e4f5dda..bc15429d9 100644
--- a/traffic_ops/traffic_ops_golang/server/servers_test.go
+++ b/traffic_ops/traffic_ops_golang/server/servers_test.go
@@ -28,10 +28,11 @@ import (
 
 	"github.com/apache/trafficcontrol/lib/go-tc"
 	"github.com/apache/trafficcontrol/lib/go-util"
-
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+
 	"github.com/jmoiron/sqlx"
+	"github.com/lib/pq"
 	"gopkg.in/DATA-DOG/go-sqlmock.v1"
 )
 
@@ -119,7 +120,7 @@ func getTestServers() []ServerAndInterfaces {
 }
 
 // Test to make sure that updating the "cdn" of a server already assigned to a DS fails
-func TestUpdateServer(t *testing.T) {
+func TestCheckTypeChangeSafety(t *testing.T) {
 	mockDB, mock, err := sqlmock.New()
 	if err != nil {
 		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
@@ -722,3 +723,387 @@ func (s SortableServers) Swap(i, j int) {
 func (s SortableServers) Less(i, j int) bool {
 	return s[i].HostName < s[j].HostName
 }
+
+func TestUpdateStatusLastUpdatedTime(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%v' was not expected when opening a stub database connection", err)
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	lastUpdated := time.Now()
+	mock.ExpectBegin()
+	mock.ExpectExec("UPDATE").WithArgs(lastUpdated, 1).WillReturnResult(sqlmock.NewResult(1, 1))
+	mock.ExpectCommit()
+
+	sysErr, _, code := updateStatusLastUpdatedTime(1, &lastUpdated, db.MustBegin().Tx)
+	if sysErr != nil {
+		t.Errorf("unable to update time, system error: %v", sysErr)
+	}
+	if code != http.StatusOK {
+		t.Errorf("updated time failed with status code:%d", code)
+	}
+}
+
+func TestCreateInterfaces(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%v' was not expected when opening a stub database connection", err)
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	testInterface := getTestServers()[0].Interface
+	var iface []tc.ServerInterfaceInfoV40
+	iface = append(iface, testInterface)
+
+	mock.ExpectBegin()
+	mock.ExpectExec("INSERT INTO interface").
+		WithArgs(iface[0].MaxBandwidth, iface[0].Monitor, iface[0].MTU, iface[0].Name, 1, iface[0].RouterHostName, iface[0].RouterPortName).
+		WillReturnResult(sqlmock.NewResult(1, 1))
+
+	mock.ExpectExec("INSERT INTO ip_address").
+		WithArgs(iface[0].IPAddresses[0].Address, iface[0].IPAddresses[0].Gateway, iface[0].Name, 1, iface[0].IPAddresses[0].ServiceAddress).
+		WillReturnResult(sqlmock.NewResult(1, 1))
+	mock.ExpectCommit()
+
+	usrErr, sysErr, code := createInterfaces(1, iface, db.MustBegin().Tx)
+	if usrErr != nil {
+		t.Errorf("unable to create interface, user error: %v", usrErr)
+	}
+	if sysErr != nil {
+		t.Errorf("unable to create interface, system error: %v", sysErr)
+	}
+	if code != http.StatusOK {
+		t.Errorf("unable to create interface, failed with status code:%d", code)
+	}
+}
+
+func TestDeleteInterfaces(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%v' was not expected when opening a stub database connection", err)
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	mock.ExpectBegin()
+	mock.ExpectExec("DELETE FROM ip_address").WithArgs(1).WillReturnResult(sqlmock.NewResult(1, 1))
+
+	mock.ExpectExec("DELETE FROM interface").WithArgs(1).WillReturnResult(sqlmock.NewResult(1, 1))
+	mock.ExpectCommit()
+
+	usrErr, sysErr, code := deleteInterfaces(1, db.MustBegin().Tx)
+	if usrErr != nil {
+		t.Errorf("unable to delete interface, user error: %v", usrErr)
+	}
+	if sysErr != nil {
+		t.Errorf("unable to delete interface, system error: %v", sysErr)
+	}
+	if code != http.StatusOK {
+		t.Errorf("unable to delete interface, failed with status code:%d", code)
+	}
+}
+
+func TestInsertServerProfile(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%v' was not expected when opening a stub database connection", err)
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	profileName := []string{"traffic_ops", "global"}
+	priority := []int{0, 1}
+	mock.ExpectBegin()
+	mock.ExpectExec("INSERT INTO").WithArgs(1, pq.Array(profileName), pq.Array(priority)).WillReturnResult(sqlmock.NewResult(2, 2))
+	mock.ExpectCommit()
+
+	usrErr, sysErr, code := insertServerProfile(1, profileName, db.MustBegin().Tx)
+	if usrErr != nil {
+		t.Errorf("unable to insert profile, user error: %v", usrErr)
+	}
+	if sysErr != nil {
+		t.Errorf("unable to insert profile, system error: %v", sysErr)
+	}
+	if code != http.StatusOK {
+		t.Errorf("unable to insert profile, failed with status code:%d", code)
+	}
+}
+
+func TestCreateServerV4(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%v' was not expected when opening a stub database connection", err)
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	s4 := getTestServers()[0].Server
+
+	mock.ExpectBegin()
+	rows0 := sqlmock.NewRows([]string{"id"})
+	rows0.AddRow(1)
+	mock.ExpectQuery("SELECT").WithArgs(s4.ProfileNames[0]).WillReturnRows(rows0)
+
+	rows := sqlmock.NewRows([]string{
+		"cachegroup",
+		"cachegroup_id",
+		"cdn_id",
+		"cdn_name",
+		"domain_name",
+		"guid",
+		"host_name",
+		"https_port",
+		"id",
+		"ilo_ip_address",
+		"ilo_ip_gateway",
+		"ilo_ip_netmask",
+		"ilo_password",
+		"ilo_username",
+		"last_updated",
+		"mgmt_ip_address",
+		"mgmt_ip_gateway",
+		"mgmt_ip_netmask",
+		"offline_reason",
+		"phys_location",
+		"phys_location_id",
+		"profile_name",
+		"rack",
+		"status",
+		"status_id",
+		"tcp_port",
+		"server_type",
+		"server_type_id",
+	})
+	rows.AddRow(
+		*s4.Cachegroup,
+		*s4.CachegroupID,
+		*s4.CDNID,
+		*s4.CDNName,
+		*s4.DomainName,
+		*s4.GUID,
+		*s4.HostName,
+		*s4.HTTPSPort,
+		*s4.ID,
+		*s4.ILOIPAddress,
+		*s4.ILOIPGateway,
+		*s4.ILOIPNetmask,
+		*s4.ILOPassword,
+		*s4.ILOUsername,
+		*s4.LastUpdated,
+		*s4.MgmtIPAddress,
+		*s4.MgmtIPGateway,
+		*s4.MgmtIPNetmask,
+		*s4.OfflineReason,
+		*s4.PhysLocation,
+		*s4.PhysLocationID,
+		fmt.Sprintf("{%s}", strings.Join(s4.ProfileNames, ",")),
+		*s4.Rack,
+		*s4.Status,
+		*s4.StatusID,
+		*s4.TCPPort,
+		s4.Type,
+		*s4.TypeID,
+	)
+	mock.ExpectQuery("INSERT INTO server").
+		WithArgs(*s4.CachegroupID, *s4.CDNID, *s4.DomainName, *s4.HostName, *s4.HTTPSPort, *s4.ILOIPAddress,
+			*s4.ILOIPNetmask, *s4.ILOIPGateway, *s4.ILOUsername, *s4.ILOPassword, *s4.MgmtIPAddress,
+			*s4.MgmtIPNetmask, *s4.MgmtIPGateway, *s4.OfflineReason, *s4.PhysLocationID, 1, *s4.Rack,
+			*s4.StatusID, *s4.TCPPort, *s4.TypeID, *s4.XMPPID, *s4.XMPPPasswd).
+		WillReturnRows(rows)
+	mock.ExpectCommit()
+
+	sid, err := createServerV4(db.MustBegin(), s4)
+	if err != nil {
+		t.Errorf("unable to create v4 server, error: %v", err)
+	}
+	if sid != int64(*s4.ID) {
+		t.Errorf("mismatched server ID, expected: %d, got: %d", *s4.ID, sid)
+	}
+}
+
+func TestCreateServerV3(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%v' was not expected when opening a stub database connection", err)
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	s3 := tc.ServerV30{
+		CommonServerProperties: tc.CommonServerProperties{
+			Cachegroup:       util.Ptr("mb"),
+			CachegroupID:     util.Ptr(1),
+			CDNID:            util.Ptr(1),
+			CDNName:          util.Ptr("ALL"),
+			DeliveryServices: nil,
+			DomainName:       util.Ptr(""),
+			FQDN:             nil,
+			FqdnTime:         time.Time{},
+			GUID:             util.Ptr(""),
+			HostName:         util.Ptr("test"),
+			HTTPSPort:        util.Ptr(8443),
+			ID:               util.Ptr(1),
+			ILOIPAddress:     util.Ptr(""),
+			ILOIPGateway:     util.Ptr(""),
+			ILOIPNetmask:     util.Ptr(""),
+			ILOPassword:      util.Ptr(""),
+			ILOUsername:      util.Ptr(""),
+			LastUpdated:      util.Ptr(tc.TimeNoMod{}),
+			MgmtIPAddress:    util.Ptr(""),
+			MgmtIPGateway:    util.Ptr(""),
+			MgmtIPNetmask:    util.Ptr(""),
+			OfflineReason:    util.Ptr(""),
+			PhysLocation:     util.Ptr("boulder"),
+			PhysLocationID:   util.Ptr(1),
+			Profile:          util.Ptr("GLOBAL"),
+			ProfileDesc:      nil,
+			ProfileID:        util.Ptr(1),
+			Rack:             util.Ptr(""),
+			RevalPending:     util.Ptr(false),
+			Status:           util.Ptr("ACTIVE"),
+			StatusID:         util.Ptr(1),
+			TCPPort:          util.Ptr(8080),
+			Type:             "EDGE",
+			TypeID:           util.Ptr(2),
+			UpdPending:       util.Ptr(false),
+			XMPPID:           util.Ptr(""),
+			XMPPPasswd:       util.Ptr(""),
+		},
+		RouterHostName:    util.Ptr(""),
+		RouterPortName:    util.Ptr(""),
+		Interfaces:        []tc.ServerInterfaceInfo{},
+		StatusLastUpdated: util.Ptr(time.Time{}),
+	}
+
+	mock.ExpectBegin()
+	rows := sqlmock.NewRows([]string{"id"})
+	rows.AddRow(1)
+	mock.ExpectQuery("INSERT INTO server").
+		WithArgs(*s3.CachegroupID, *s3.CDNID, *s3.DomainName, *s3.HostName, *s3.HTTPSPort, *s3.ILOIPAddress,
+			*s3.ILOIPNetmask, *s3.ILOIPGateway, *s3.ILOUsername, *s3.ILOPassword, *s3.MgmtIPAddress,
+			*s3.MgmtIPNetmask, *s3.MgmtIPGateway, *s3.OfflineReason, *s3.PhysLocationID, *s3.ProfileID,
+			*s3.Rack, *s3.StatusID, *s3.TCPPort, *s3.TypeID, *s3.XMPPID, *s3.XMPPPasswd, *s3.StatusLastUpdated).
+		WillReturnRows(rows)
+	mock.ExpectCommit()
+
+	sid, err := createServerV3(db.MustBegin(), s3)
+	if err != nil {
+		t.Errorf("unable to create v3 server, error: %v", err)
+	}
+	if sid != int64(*s3.ID) {
+		t.Errorf("mismatched server ID, expected: %d, got: %d", *s3.ID, sid)
+	}
+}
+
+func TestUpdateServer(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%v' was not expected when opening a stub database connection", err)
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	s4 := getTestServers()[0].Server
+
+	mock.ExpectBegin()
+	rows := sqlmock.NewRows([]string{
+		"cachegroup",
+		"cachegroup_id",
+		"cdn_id",
+		"cdn_name",
+		"domain_name",
+		"guid",
+		"host_name",
+		"https_port",
+		"id",
+		"ilo_ip_address",
+		"ilo_ip_gateway",
+		"ilo_ip_netmask",
+		"ilo_password",
+		"ilo_username",
+		"last_updated",
+		"mgmt_ip_address",
+		"mgmt_ip_gateway",
+		"mgmt_ip_netmask",
+		"offline_reason",
+		"phys_location",
+		"phys_location_id",
+		"profile_name",
+		"rack",
+		"status",
+		"status_id",
+		"tcp_port",
+		"server_type",
+		"server_type_id",
+		"status_last_updated",
+	})
+	rows.AddRow(
+		*s4.Cachegroup,
+		*s4.CachegroupID,
+		*s4.CDNID,
+		*s4.CDNName,
+		*s4.DomainName,
+		*s4.GUID,
+		*s4.HostName,
+		*s4.HTTPSPort,
+		*s4.ID,
+		*s4.ILOIPAddress,
+		*s4.ILOIPGateway,
+		*s4.ILOIPNetmask,
+		*s4.ILOPassword,
+		*s4.ILOUsername,
+		*s4.LastUpdated,
+		*s4.MgmtIPAddress,
+		*s4.MgmtIPGateway,
+		*s4.MgmtIPNetmask,
+		*s4.OfflineReason,
+		*s4.PhysLocation,
+		*s4.PhysLocationID,
+		fmt.Sprintf("{%s}", strings.Join(s4.ProfileNames, ",")),
+		*s4.Rack,
+		*s4.Status,
+		*s4.StatusID,
+		*s4.TCPPort,
+		s4.Type,
+		*s4.TypeID,
+		*s4.StatusLastUpdated,
+	)
+	mock.ExpectQuery("UPDATE server SET").
+		WithArgs(*s4.CachegroupID, *s4.CDNID, *s4.DomainName, *s4.HostName, *s4.HTTPSPort, *s4.ILOIPAddress,
+			*s4.ILOIPNetmask, *s4.ILOIPGateway, *s4.ILOUsername, *s4.ILOPassword, *s4.MgmtIPAddress,
+			*s4.MgmtIPNetmask, *s4.MgmtIPGateway, *s4.OfflineReason, *s4.PhysLocationID, 1, *s4.Rack,
+			*s4.StatusID, *s4.TCPPort, *s4.TypeID, *s4.XMPPPasswd, *s4.StatusLastUpdated, *s4.ID).
+		WillReturnRows(rows)
+	mock.ExpectCommit()
+
+	sid, code, usrErr, sysErr := updateServer(db.MustBegin(), s4)
+	if usrErr != nil {
+		t.Errorf("unable to update v4 server, user error: %v", usrErr)
+	}
+	if sysErr != nil {
+		t.Errorf("unable to update v4 server, system error: %v", sysErr)
+	}
+	if sid != int64(*s4.ID) {
+		t.Errorf("updated incorrect server, expected:%d, got:%d", *s4.ID, sid)
+	}
+	if code != http.StatusOK {
+		t.Errorf("failed to update server with id:%d, expected: %d, got: %d", *s4.ID, http.StatusOK, code)
+	}
+}