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

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


The following commit(s) were added to refs/heads/master by this push:
     new 0b78dbf3f4 Basic ISO generation page/form (#7375)
0b78dbf3f4 is described below

commit 0b78dbf3f4b3a44ecf3392f03dec085a1e60d078
Author: ocket8888 <[email protected]>
AuthorDate: Thu Mar 23 15:38:53 2023 -0600

    Basic ISO generation page/form (#7375)
    
    * Move re-used form page styling to common location
    
    * Remove a TODO that has been TODOne
    
    * Add utilities for dealing with unknown types
    
    * Add an API service for miscellaneous APIs
    
    Also added test coverage for it, and removed the restriction that
    coverage for API services not be reported.
    
    * Consolidate input warning styling
    
    * Add dummy OS versions to dev env
    
    * Update trafficops-types for bugfix
    
    * Add an ArrayBufferView type guard
    
    * consolidate IP regular expressions to a single place
    
    * Add a service for providing downloads
    
    * Add rudimentary ISO generation form
    
    * Fix eslint disallowing fallthrough cases
    
    * Add routing
    
    * Fix incorrect component path
    
    * Replace custom injector with DOCUMENT usage
---
 dev/traffic_ops/Dockerfile                         |   5 +
 experimental/traffic-portal/.eslintrc.json         |   1 +
 experimental/traffic-portal/angular.json           |   3 -
 experimental/traffic-portal/package-lock.json      |  14 +-
 experimental/traffic-portal/package.json           |   2 +-
 .../src/app/api/base-api.service.spec.ts           |  26 ++
 .../traffic-portal/src/app/api/base-api.service.ts |  35 +-
 experimental/traffic-portal/src/app/api/index.ts   |   3 +
 .../src/app/api/misc-apis.service.spec.ts          | 135 ++++++++
 .../src/app/api/misc-apis.service.ts               |  86 +++++
 .../traffic-portal/src/app/api/testing/index.ts    |   6 +-
 .../src/app/api/testing/misc-apis.service.spec.ts  |  55 +++
 .../src/app/api/testing/misc-apis.service.ts       |  55 +++
 .../traffic-portal/src/app/app.ui.module.ts        |   4 +-
 .../cache-group-details.component.scss             |  26 +-
 .../cache-group-details.component.ts               |   2 +-
 .../detail/division-detail.component.scss          |  25 --
 .../divisions/detail/division-detail.component.ts  |   2 +-
 .../regions/detail/region-detail.component.scss    |  25 --
 .../regions/detail/region-detail.component.ts      |   2 +-
 .../traffic-portal/src/app/core/core.module.ts     |   3 +
 .../new-delivery-service.component.ts              |  16 +-
 .../isogeneration-form.component.html              | 121 +++++++
 .../isogeneration-form.component.scss              |  24 ++
 .../isogeneration-form.component.spec.ts           | 171 ++++++++++
 .../isogeneration-form.component.ts                | 155 +++++++++
 .../phys-loc/detail/phys-loc-detail.component.scss |  19 +-
 .../phys-loc/detail/phys-loc-detail.component.ts   |   2 +-
 .../server-details/server-details.component.html   |   2 +-
 .../form.page.scss}                                |   5 +-
 .../tenant-details/tenant-details.component.ts     |   2 +-
 .../users/user-details/user-details.component.scss |   6 -
 .../src/app/shared/file-utils.service.spec.ts      | 219 ++++++++++++
 .../src/app/shared/file-utils.service.ts           | 156 +++++++++
 .../app/shared/navigation/navigation.service.ts    |  14 +-
 .../traffic-portal/src/app/shared/shared.module.ts |   4 +-
 .../traffic-portal/src/app/utils/index.spec.ts     | 148 ++++++++
 experimental/traffic-portal/src/app/utils/index.ts | 371 +++++++++++++++++++++
 experimental/traffic-portal/src/app/utils/ip.ts    |  24 +-
 experimental/traffic-portal/src/styles.scss        |  10 +
 40 files changed, 1847 insertions(+), 137 deletions(-)

diff --git a/dev/traffic_ops/Dockerfile b/dev/traffic_ops/Dockerfile
index 5e68bf2ef3..50ffe71408 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 65a76ad4f1..4311f5802f 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 b0c039769b..8986905c45 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/package-lock.json 
b/experimental/traffic-portal/package-lock.json
index 257ff4fc55..44069b7995 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 aa8875d80c..ed7563bef6 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 0000000000..9d0d0f74ad
--- /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 e0394a7d54..0edfe83807 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/index.ts 
b/experimental/traffic-portal/src/app/api/index.ts
index b8c6e7edd3..82da8fd2df 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 0000000000..b3b190dbb8
--- /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 0000000000..3ff154e2fc
--- /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/api/testing/index.ts 
b/experimental/traffic-portal/src/app/api/testing/index.ts
index d73f26a05b..4048726732 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 0000000000..25bea362dc
--- /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 0000000000..3bde68a959
--- /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/experimental/traffic-portal/src/app/app.ui.module.ts 
b/experimental/traffic-portal/src/app/app.ui.module.ts
index 8294eae005..dfb6fd8463 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/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 7b4f84436e..2dc08de581 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 a6672ca50e..fb07813188 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/divisions/detail/division-detail.component.scss
 
b/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.scss
deleted file mode 100644
index f4746e2a22..0000000000
--- 
a/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.scss
+++ /dev/null
@@ -1,25 +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/cache-groups/divisions/detail/division-detail.component.ts
 
b/experimental/traffic-portal/src/app/core/cache-groups/divisions/detail/division-detail.component.ts
index 0519af7b88..d4ed1b127f 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.scss
 
b/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.scss
deleted file mode 100644
index f4746e2a22..0000000000
--- 
a/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.scss
+++ /dev/null
@@ -1,25 +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/cache-groups/regions/detail/region-detail.component.ts
 
b/experimental/traffic-portal/src/app/core/cache-groups/regions/detail/region-detail.component.ts
index c195bd4758..04a015ea31 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 a599834723..af02d05a7f 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -46,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";
@@ -86,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]}));
 
 /**
@@ -126,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 dd3a3cd04d..7e6a58144d 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]
 [...]
-/* 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 0000000000..3fae68ea3c
--- /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 0000000000..95568949cf
--- /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 0000000000..33c595ab4c
--- /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 0000000000..3834653bcb
--- /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 cc9be17c1f..20df428ced 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 b93e84ccbb..a5a57c12ef 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 2d09e79d2b..6528a7f7de 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/users/tenants/tenant-details/tenant-details.component.scss
 b/experimental/traffic-portal/src/app/core/styles/form.page.scss
similarity index 94%
rename from 
experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.scss
rename to experimental/traffic-portal/src/app/core/styles/form.page.scss
index 85b09c7c4c..b74018a3e8 100644
--- 
a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.scss
+++ b/experimental/traffic-portal/src/app/core/styles/form.page.scss
@@ -11,17 +11,16 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-
 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.ts
 
b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
index 556f5c6685..3b44b63ab1 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 833c8a11f8..15a39a57e8 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 0000000000..a244e0af37
--- /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 0000000000..3242f8c93f
--- /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 4fa73801fc..cfcf73eb64 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 3e7076a163..d12ab1823c 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 0000000000..a283b816bd
--- /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 3efe9a047c..605c9818c3 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 d7851a4221..bafd03f1ed 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
 [...]
-// 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-F
 [...]
 
-/** 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-
 [...]
-//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})|(
 [...]
 
 /** 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
 [...]
+/**
+ * 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}|(?:
 [...]
 /* eslint-enable max-len */
diff --git a/experimental/traffic-portal/src/styles.scss 
b/experimental/traffic-portal/src/styles.scss
index cf3064cd55..1b40eabc52 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;
+}

Reply via email to