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;
+}