This is an automated email from the ASF dual-hosted git repository.
ocket8888 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git
The following commit(s) were added to refs/heads/master by this push:
new b7f5774a97 Add Origins page Traffic Portal v2 (#7881)
b7f5774a97 is described below
commit b7f5774a979b8adbf247389195546cb85deb29cf
Author: The Anh Nguyen <[email protected]>
AuthorDate: Wed Feb 7 22:08:14 2024 +0700
Add Origins page Traffic Portal v2 (#7881)
* [WIP] Add Origins
* add origin, coordinate service; update origin list/detail
* fix unit tests
* add missing @returns annotations
* add test get multiple coordinates/origins
* add test case delete existing origins
* fix test delete origin
---
.../src/app/api/coordinate.service.spec.ts | 117 ++++++++++++
.../src/app/api/coordinate.service.ts | 85 +++++++++
experimental/traffic-portal/src/app/api/index.ts | 6 +
.../src/app/api/origin.service.spec.ts | 206 +++++++++++++++++++++
.../traffic-portal/src/app/api/origin.service.ts | 122 ++++++++++++
.../src/app/api/testing/coordinate.service.ts | 68 +++++++
.../traffic-portal/src/app/api/testing/index.ts | 6 +
.../src/app/api/testing/origin.service.ts | 182 ++++++++++++++++++
.../traffic-portal/src/app/core/core.module.ts | 6 +
.../origins/detail/origin-detail.component.html | 133 +++++++++++++
.../origins/detail/origin-detail.component.scss | 13 ++
.../origins/detail/origin-detail.component.spec.ts | 125 +++++++++++++
.../core/origins/detail/origin-detail.component.ts | 198 ++++++++++++++++++++
.../origins/table/origins-table.component.html | 33 ++++
.../origins/table/origins-table.component.scss | 14 ++
.../origins/table/origins-table.component.spec.ts | 199 ++++++++++++++++++++
.../core/origins/table/origins-table.component.ts | 193 +++++++++++++++++++
.../app/shared/navigation/navigation.service.ts | 2 +-
18 files changed, 1707 insertions(+), 1 deletion(-)
diff --git a/experimental/traffic-portal/src/app/api/coordinate.service.spec.ts
b/experimental/traffic-portal/src/app/api/coordinate.service.spec.ts
new file mode 100644
index 0000000000..b0811ddb85
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/coordinate.service.spec.ts
@@ -0,0 +1,117 @@
+/**
+ * @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 {
+ HttpClientTestingModule,
+ HttpTestingController,
+} from "@angular/common/http/testing";
+import { TestBed } from "@angular/core/testing";
+
+import { CoordinateService } from "./coordinate.service";
+
+describe("CoordinateService", () => {
+ let service: CoordinateService;
+ let httpTestingController: HttpTestingController;
+ const coordinate = {
+ id: 1,
+ lastUpdated: new Date(),
+ latitude: 1.0,
+ longitude: -1.0,
+ name: "test_coordinate",
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [CoordinateService],
+ });
+ service = TestBed.inject(CoordinateService);
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ it("should be created", () => {
+ expect(service).toBeTruthy();
+ });
+
+ it("gets multiple Coordinates", async () => {
+ const responseP = service.getCoordinates();
+ const req = httpTestingController.expectOne(
+ `/api/${service.apiVersion}/coordinates`
+ );
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.keys().length).toBe(0);
+ req.flush({ response: [coordinate] });
+ await expectAsync(responseP).toBeResolvedTo([coordinate]);
+ });
+
+ it("gets a single Coordinate by ID", async () => {
+ const responseP = service.getCoordinates(coordinate.id);
+ const req = httpTestingController.expectOne(
+ (r) => r.url ===
`/api/${service.apiVersion}/coordinates`
+ );
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.keys().length).toBe(1);
+
expect(req.request.params.get("id")).toBe(String(coordinate.id));
+ req.flush({ response: [coordinate] });
+ await expectAsync(responseP).toBeResolvedTo(coordinate);
+ });
+
+ it("gets a single Coordinate by name", async () => {
+ const responseP = service.getCoordinates(coordinate.name);
+ const req = httpTestingController.expectOne(
+ (r) => r.url ===
`/api/${service.apiVersion}/coordinates`
+ );
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.keys().length).toBe(1);
+ expect(req.request.params.get("name")).toBe(coordinate.name);
+ req.flush({ response: [coordinate] });
+ await expectAsync(responseP).toBeResolvedTo(coordinate);
+ });
+
+ it("sends requests for multiple coordinates by ID", async () => {
+ const responseParams = service.getCoordinates(coordinate.id);
+ const req = httpTestingController.expectOne(
+ (r) => r.url ===
`/api/${service.apiVersion}/coordinates`
+ );
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.keys().length).toBe(1);
+
expect(req.request.params.get("id")).toBe(String(coordinate.id));
+ const data = {
+ response: [
+ {
+ id: 1,
+ lastUpdated: new Date(),
+ latitude: 1.0,
+ longitude: -1.0,
+ name: "test_coordinate1",
+ },
+ {
+ id: 1,
+ lastUpdated: new Date(),
+ latitude: 1.0,
+ longitude: -1.0,
+ name: "test_coordinate2",
+ },
+ ],
+ };
+ req.flush(data);
+ await expectAsync(responseParams).toBeRejectedWithError(
+ `Traffic Ops responded with 2 Coordinates by identifier
${coordinate.id}`
+ );
+ });
+
+ afterEach(() => {
+ httpTestingController.verify();
+ });
+});
diff --git a/experimental/traffic-portal/src/app/api/coordinate.service.ts
b/experimental/traffic-portal/src/app/api/coordinate.service.ts
new file mode 100644
index 0000000000..6d9d89ddf7
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/coordinate.service.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { Injectable } from "@angular/core";
+import type { ResponseCoordinate } from "trafficops-types";
+
+import { APIService } from "./base-api.service";
+
+/**
+ * CoordinateService exposes API functionality relating to Coordinates.
+ */
+@Injectable()
+export class CoordinateService extends APIService {
+ /**
+ * Gets a specific Coordinate from Traffic Ops.
+ *
+ * @param idOrName Either the integral, unique identifier (number) or
name
+ * (string) of the Coordinate to be returned.
+ * @returns The requested Coordinate.
+ */
+ public async getCoordinates(
+ idOrName: number | string
+ ): Promise<ResponseCoordinate>;
+ /**
+ * Gets Coordinates from Traffic Ops.
+ *
+ * @returns An Array of all Coordinates from Traffic Ops.
+ */
+ public async getCoordinates(): Promise<Array<ResponseCoordinate>>;
+
+ /**
+ * Gets one or all Coordinates from Traffic Ops.
+ *
+ * @param idOrName Optionally the integral, unique identifier (number)
or
+ * name (string) of a single Coordinate to be returned.
+ * @returns The requested Coordinate(s).
+ */
+ public async getCoordinates(
+ idOrName?: number | string
+ ): Promise<ResponseCoordinate | Array<ResponseCoordinate>> {
+ const path = "coordinates";
+ if (idOrName !== undefined) {
+ let params;
+ switch (typeof idOrName) {
+ case "string":
+ params = { name: idOrName };
+ break;
+ case "number":
+ params = { id: idOrName };
+ }
+ const r = await this.get<[ResponseCoordinate]>(
+ path,
+ undefined,
+ params
+ ).toPromise();
+ if (r.length !== 1) {
+ throw new Error(
+ `Traffic Ops responded with ${r.length}
Coordinates by identifier ${idOrName}`
+ );
+ }
+ return r[0];
+ }
+ return this.get<Array<ResponseCoordinate>>(path).toPromise();
+ }
+
+ /**
+ * Injects the Angular HTTP client service into the parent constructor.
+ *
+ * @param http The Angular HTTP client service.
+ */
+ constructor(http: HttpClient) {
+ super(http);
+ }
+}
diff --git a/experimental/traffic-portal/src/app/api/index.ts
b/experimental/traffic-portal/src/app/api/index.ts
index 039b8abe1e..7de4187ab3 100644
--- a/experimental/traffic-portal/src/app/api/index.ts
+++ b/experimental/traffic-portal/src/app/api/index.ts
@@ -20,9 +20,11 @@ import { ChangeLogsService } from
"src/app/api/change-logs.service";
import { CacheGroupService } from "./cache-group.service";
import { CDNService } from "./cdn.service";
+import { CoordinateService } from "./coordinate.service";
import { DeliveryServiceService } from "./delivery-service.service";
import { InvalidationJobService } from "./invalidation-job.service";
import { MiscAPIsService } from "./misc-apis.service";
+import { OriginService } from "./origin.service";
import { PhysicalLocationService } from "./physical-location.service";
import { ProfileService } from "./profile.service";
import { ServerService } from "./server.service";
@@ -42,6 +44,8 @@ export * from "./server.service";
export * from "./topology.service";
export * from "./type.service";
export * from "./user.service";
+export * from "./origin.service";
+export * from "./coordinate.service";
/**
* The API Module contains all logic used to access the Traffic Ops API.
@@ -64,6 +68,8 @@ export * from "./user.service";
TopologyService,
TypeService,
UserService,
+ OriginService,
+ CoordinateService,
]
})
export class APIModule { }
diff --git a/experimental/traffic-portal/src/app/api/origin.service.spec.ts
b/experimental/traffic-portal/src/app/api/origin.service.spec.ts
new file mode 100644
index 0000000000..2ff474e235
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/origin.service.spec.ts
@@ -0,0 +1,206 @@
+/**
+ * @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 {
+ HttpClientTestingModule,
+ HttpTestingController,
+} from "@angular/common/http/testing";
+import { TestBed } from "@angular/core/testing";
+
+import { OriginService } from "./origin.service";
+
+describe("OriginService", () => {
+ let service: OriginService;
+ let httpTestingController: HttpTestingController;
+ const origin = {
+ cachegroup: null,
+ cachegroupId: null,
+ coordinate: null,
+ coordinateId: null,
+ deliveryService: "test",
+ deliveryServiceId: 1,
+ fqdn: "origin.infra.ciab.test",
+ id: 1,
+ ip6Address: null,
+ ipAddress: null,
+ isPrimary: false,
+ lastUpdated: new Date(),
+ name: "test",
+ port: null,
+ profile: null,
+ profileId: null,
+ protocol: "http" as never,
+ tenant: "root",
+ tenantId: 1,
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [OriginService],
+ });
+ service = TestBed.inject(OriginService);
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ it("should be created", () => {
+ expect(service).toBeTruthy();
+ });
+
+ it("gets multiple Origins", async () => {
+ const responseP = service.getOrigins();
+ const req = httpTestingController.expectOne(
+ `/api/${service.apiVersion}/origins`
+ );
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.keys().length).toBe(0);
+ req.flush({ response: [origin] });
+ await expectAsync(responseP).toBeResolvedTo([origin]);
+ });
+
+ it("gets a single Origin by ID", async () => {
+ const responseP = service.getOrigins(origin.id);
+ const req = httpTestingController.expectOne(
+ (r) => r.url === `/api/${service.apiVersion}/origins`
+ );
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.keys().length).toBe(1);
+ expect(req.request.params.get("id")).toBe(String(origin.id));
+ req.flush({ response: [origin] });
+ await expectAsync(responseP).toBeResolvedTo(origin);
+ });
+
+ it("gets a single Origin by name", async () => {
+ const responseP = service.getOrigins(origin.name);
+ const req = httpTestingController.expectOne(
+ (r) => r.url === `/api/${service.apiVersion}/origins`
+ );
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.keys().length).toBe(1);
+ expect(req.request.params.get("name")).toBe(origin.name);
+ req.flush({ response: [origin] });
+ await expectAsync(responseP).toBeResolvedTo(origin);
+ });
+
+ it("submits requests to create new Origins", async () => {
+ const responseP = service.createOrigin({
+ ...origin,
+ tenantID: origin.tenantId,
+ });
+ const req = httpTestingController.expectOne(
+ `/api/${service.apiVersion}/origins`
+ );
+ expect(req.request.method).toBe("POST");
+ expect(req.request.params.keys().length).toBe(0);
+ expect(req.request.body.name).toEqual(origin.name);
+ req.flush({ response: origin });
+ await expectAsync(responseP).toBeResolvedTo(origin);
+ });
+
+ it("submits requests to update existing Origins", async () => {
+ const responseP = service.updateOrigin(origin);
+ const req = httpTestingController.expectOne(
+ `/api/${service.apiVersion}/origins?id=${origin.id}`
+ );
+ expect(req.request.method).toBe("PUT");
+ expect(req.request.params.keys().length).toBe(0);
+ expect(req.request.body).toEqual(origin);
+ req.flush({ response: origin });
+ await expectAsync(responseP).toBeResolvedTo(origin);
+ });
+
+ it("submits requests to delete Origins", async () => {
+ let responseP = service.deleteOrigin(origin);
+ let req = httpTestingController.expectOne(
+ `/api/${service.apiVersion}/origins?id=${origin.id}`
+ );
+ expect(req.request.method).toBe("DELETE");
+ expect(req.request.params.keys().length).toBe(0);
+ req.flush({ alerts: [] });
+ await expectAsync(responseP).toBeResolved();
+
+ responseP = service.deleteOrigin(origin.id);
+ req = httpTestingController.expectOne(
+ `/api/${service.apiVersion}/origins?id=${origin.id}`
+ );
+ expect(req.request.method).toBe("DELETE");
+ expect(req.request.params.keys().length).toBe(0);
+ req.flush({ alerts: [] });
+ await expectAsync(responseP).toBeResolved();
+ });
+
+ it("sends requests for multiple origins by ID", async () => {
+ const responseParams = service.getOrigins(origin.id);
+ const req = httpTestingController.expectOne(
+ (r) => r.url === `/api/${service.apiVersion}/origins`
+ );
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.keys().length).toBe(1);
+ expect(req.request.params.get("id")).toBe(String(origin.id));
+ const data = {
+ response: [
+ {
+ cachegroup: null,
+ cachegroupId: null,
+ coordinate: null,
+ coordinateId: null,
+ deliveryService: "test",
+ deliveryServiceId: 1,
+ fqdn: "origin.infra.ciab.test",
+ id: 1,
+ ip6Address: null,
+ ipAddress: null,
+ isPrimary: false,
+ lastUpdated: new Date(),
+ name: "test",
+ port: null,
+ profile: null,
+ profileId: null,
+ protocol: "http" as never,
+ tenant: "root",
+ tenantId: 1,
+ },
+ {
+ cachegroup: null,
+ cachegroupId: null,
+ coordinate: null,
+ coordinateId: null,
+ deliveryService: "test",
+ deliveryServiceId: 1,
+ fqdn: "origin.infra.ciab.test",
+ id: 1,
+ ip6Address: null,
+ ipAddress: null,
+ isPrimary: false,
+ lastUpdated: new Date(),
+ name: "test2",
+ port: null,
+ profile: null,
+ profileId: null,
+ protocol: "http" as never,
+ tenant: "root",
+ tenantId: 1,
+ },
+ ],
+ };
+ req.flush(data);
+ await expectAsync(responseParams).toBeRejectedWithError(
+ `Traffic Ops responded with 2 Origins by identifier
${origin.id}`
+ );
+ });
+
+ afterEach(() => {
+ httpTestingController.verify();
+ });
+});
diff --git a/experimental/traffic-portal/src/app/api/origin.service.ts
b/experimental/traffic-portal/src/app/api/origin.service.ts
new file mode 100644
index 0000000000..50b74a9284
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/origin.service.ts
@@ -0,0 +1,122 @@
+/*
+ * 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 { Injectable } from "@angular/core";
+import type { RequestOrigin, RequestOriginResponse } from "trafficops-types";
+
+import { APIService } from "./base-api.service";
+
+/**
+ * OriginService exposes API functionality relating to Origins.
+ */
+@Injectable()
+export class OriginService extends APIService {
+ /**
+ * Gets a specific Origin from Traffic Ops.
+ *
+ * @param idOrName Either the integral, unique identifier (number) or
name
+ * (string) of the Origin to be returned.
+ * @returns The requested Origin.
+ */
+ public async getOrigins(
+ idOrName: number | string
+ ): Promise<RequestOriginResponse>;
+ /**
+ * Gets Origins from Traffic Ops.
+ *
+ * @returns An Array of all Origins from Traffic Ops.
+ */
+ public async getOrigins(): Promise<Array<RequestOriginResponse>>;
+ /**
+ * Gets one or all Origins from Traffic Ops.
+ *
+ * @param idOrName Optionally the integral, unique identifier (number)
or
+ * name (string) of a single Origin to be returned.
+ * @returns The requested Origin(s).
+ */
+ public async getOrigins(
+ idOrName?: number | string
+ ): Promise<RequestOriginResponse | Array<RequestOriginResponse>> {
+ const path = "origins";
+ if (idOrName !== undefined) {
+ let params;
+ switch (typeof idOrName) {
+ case "string":
+ params = { name: idOrName };
+ break;
+ case "number":
+ params = { id: idOrName };
+ }
+ const r = await this.get<[RequestOriginResponse]>(
+ path,
+ undefined,
+ params
+ ).toPromise();
+ if (r.length !== 1) {
+ throw new Error(
+ `Traffic Ops responded with ${r.length}
Origins by identifier ${idOrName}`
+ );
+ }
+ return r[0];
+ }
+ return this.get<Array<RequestOriginResponse>>(path).toPromise();
+ }
+
+ /**
+ * Deletes an existing origin.
+ *
+ * @param originOrId Id of the origin to delete.
+ * @returns The deleted origin.
+ */
+ public async deleteOrigin(
+ originOrId: number | RequestOriginResponse
+ ): Promise<RequestOriginResponse> {
+ const id = typeof originOrId === "number" ? originOrId :
originOrId.id;
+ return
this.delete<RequestOriginResponse>(`origins?id=${id}`).toPromise();
+ }
+
+ /**
+ * Creates a new origin.
+ *
+ * @param origin The origin to create.
+ * @returns The created origin.
+ */
+ public async createOrigin(
+ origin: RequestOrigin
+ ): Promise<RequestOriginResponse> {
+ return this.post<RequestOriginResponse>("origins",
origin).toPromise();
+ }
+
+ /**
+ * Replaces the current definition of an origin with the one given.
+ *
+ * @param origin The new origin.
+ * @returns The updated origin.
+ */
+ public async updateOrigin(
+ origin: RequestOriginResponse
+ ): Promise<RequestOriginResponse> {
+ const path = `origins?id=${origin.id}`;
+ return this.put<RequestOriginResponse>(path,
origin).toPromise();
+ }
+
+ /**
+ * Injects the Angular HTTP client service into the parent constructor.
+ *
+ * @param http The Angular HTTP client service.
+ */
+ constructor(http: HttpClient) {
+ super(http);
+ }
+}
diff --git
a/experimental/traffic-portal/src/app/api/testing/coordinate.service.ts
b/experimental/traffic-portal/src/app/api/testing/coordinate.service.ts
new file mode 100644
index 0000000000..a4a0f49759
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/testing/coordinate.service.ts
@@ -0,0 +1,68 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Injectable } from "@angular/core";
+import type { ResponseCoordinate } from "trafficops-types";
+
+/**
+ * CoordinateService exposes API functionality relating to Coordinates.
+ */
+@Injectable()
+export class CoordinateService {
+ private readonly coordinates: Array<ResponseCoordinate> = [
+ {
+ id: 1,
+ lastUpdated: new Date(),
+ latitude: 1.0,
+ longitude: -1.0,
+ name: "test_coordinate",
+ },
+ ];
+
+ public async getCoordinates(): Promise<Array<ResponseCoordinate>>;
+ public async getCoordinates(
+ nameOrID: string | number
+ ): Promise<ResponseCoordinate>;
+
+ /**
+ * Gets one or all Coordinates from Traffic Ops.
+ *
+ * @param nameOrID If given, returns only the ResponseCoordinate with
the given name
+ * (string) or ID (number).
+ * @returns An Array of ResponseCoordinate objects - or a single
ResponseCoordinate object if 'nameOrID'
+ * was given.
+ */
+ public async getCoordinates(
+ nameOrID?: string | number
+ ): Promise<Array<ResponseCoordinate> | ResponseCoordinate> {
+ if (nameOrID) {
+ let coordinate;
+ switch (typeof nameOrID) {
+ case "string":
+ coordinate = this.coordinates.find(
+ (d) => d.name === nameOrID
+ );
+ break;
+ case "number":
+ coordinate = this.coordinates.find(
+ (d) => d.id === nameOrID
+ );
+ }
+ if (!coordinate) {
+ throw new Error(`no such Coordinate:
${nameOrID}`);
+ }
+ return coordinate;
+ }
+ return this.coordinates;
+ }
+}
diff --git a/experimental/traffic-portal/src/app/api/testing/index.ts
b/experimental/traffic-portal/src/app/api/testing/index.ts
index b03a1471e1..f71fd42819 100644
--- a/experimental/traffic-portal/src/app/api/testing/index.ts
+++ b/experimental/traffic-portal/src/app/api/testing/index.ts
@@ -19,9 +19,11 @@ import {
CacheGroupService,
CDNService,
ChangeLogsService,
+ CoordinateService,
DeliveryServiceService,
InvalidationJobService,
MiscAPIsService,
+ OriginService,
PhysicalLocationService,
ProfileService,
ServerService,
@@ -33,9 +35,11 @@ import {
import { CacheGroupService as TestingCacheGroupService } from
"./cache-group.service";
import { CDNService as TestingCDNService } from "./cdn.service";
import { ChangeLogsService as TestingChangeLogsService} from
"./change-logs.service";
+import { CoordinateService as TestingCoordinateService } from
"./coordinate.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 { OriginService as TestingOriginService } from "./origin.service";
import { PhysicalLocationService as TestingPhysicalLocationService } from
"./physical-location.service";
import { ProfileService as TestingProfileService } from "./profile.service";
import { ServerService as TestingServerService } from "./server.service";
@@ -56,9 +60,11 @@ import { UserService as TestingUserService } from
"./user.service";
{provide: CacheGroupService, useClass:
TestingCacheGroupService},
{provide: ChangeLogsService, useClass:
TestingChangeLogsService},
{provide: CDNService, useClass: TestingCDNService},
+ {provide: CoordinateService, useClass:
TestingCoordinateService},
{provide: DeliveryServiceService, useClass:
TestingDeliveryServiceService},
{provide: InvalidationJobService, useClass:
TestingInvalidationJobService},
{provide: MiscAPIsService, useClass: TestingMiscAPIsService},
+ {provide: OriginService, useClass: TestingOriginService},
{provide: PhysicalLocationService, useClass:
TestingPhysicalLocationService},
{provide: ProfileService, useClass: TestingProfileService},
{provide: ServerService, useClass: TestingServerService},
diff --git a/experimental/traffic-portal/src/app/api/testing/origin.service.ts
b/experimental/traffic-portal/src/app/api/testing/origin.service.ts
new file mode 100644
index 0000000000..e85ec3f256
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/testing/origin.service.ts
@@ -0,0 +1,182 @@
+/*
+ * 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 { RequestOrigin, RequestOriginResponse } from "trafficops-types";
+
+import {
+ CacheGroupService,
+ CoordinateService,
+ DeliveryServiceService,
+ ProfileService,
+ UserService,
+} from "..";
+
+/**
+ * CoordinateService exposes API functionality relating to Coordinates.
+ */
+@Injectable()
+export class OriginService {
+ private readonly origins: Array<RequestOriginResponse> = [
+ {
+ cachegroup: null,
+ cachegroupId: null,
+ coordinate: null,
+ coordinateId: null,
+ deliveryService: "test",
+ deliveryServiceId: 1,
+ fqdn: "origin.infra.ciab.test",
+ id: 1,
+ ip6Address: null,
+ ipAddress: null,
+ isPrimary: false,
+ lastUpdated: new Date(),
+ name: "test",
+ port: null,
+ profile: null,
+ profileId: null,
+ protocol: "http",
+ tenant: "root",
+ tenantId: 1,
+ },
+ ];
+
+ constructor(
+ private readonly userService: UserService,
+ private readonly coordinateService: CoordinateService,
+ private readonly cacheGroupService: CacheGroupService,
+ private readonly profileService: ProfileService,
+ private readonly dsService: DeliveryServiceService
+ ) {}
+
+ public async getOrigins(): Promise<Array<RequestOriginResponse>>;
+ public async getOrigins(
+ nameOrID: string | number
+ ): Promise<RequestOriginResponse>;
+
+ /**
+ * Gets one or all Coordinates from Traffic Ops.
+ *
+ * @param nameOrID If given, returns only the ResponseCoordinate with
the given name
+ * (string) or ID (number).
+ * @returns An Array of ResponseCoordinate objects - or a single
ResponseCoordinate object if 'nameOrID'
+ * was given.
+ */
+ public async getOrigins(
+ nameOrID?: string | number
+ ): Promise<Array<RequestOriginResponse> | RequestOriginResponse> {
+ if (nameOrID) {
+ let origin;
+ switch (typeof nameOrID) {
+ case "string":
+ origin = this.origins.find((d) =>
d.name === nameOrID);
+ break;
+ case "number":
+ origin = this.origins.find((d) => d.id
=== nameOrID);
+ }
+ if (!origin) {
+ throw new Error(`no such Origin: ${nameOrID}`);
+ }
+ return origin;
+ }
+ return this.origins;
+ }
+
+ /**
+ * Replaces the current definition of a Origin with the one given.
+ *
+ * @param origin The new Origin.
+ * @returns The updated Origin.
+ */
+ public async updateOrigin(
+ origin: RequestOriginResponse
+ ): Promise<RequestOriginResponse> {
+ const id = this.origins.findIndex((d) => d.id === origin.id);
+ if (id === -1) {
+ throw new Error(`no such Origin: ${origin.id}`);
+ }
+ this.origins[id] = origin;
+ return origin;
+ }
+
+ /**
+ * Creates a new Origin.
+ *
+ * @param origin The Origin to create.
+ * @returns The created Origin.
+ */
+ public async createOrigin(
+ origin: RequestOrigin
+ ): Promise<RequestOriginResponse> {
+ const tenant = await
this.userService.getTenants(origin.tenantID);
+ const ds = await this.dsService.getDeliveryServices(
+ origin.deliveryServiceId
+ );
+ let profile = null;
+ if (!!origin?.profileId) {
+ profile = await
this.profileService.getProfiles(origin.profileId);
+ }
+ let coordinate = null;
+ if (!!origin?.coordinateId) {
+ coordinate = await
this.coordinateService.getCoordinates(
+ origin.coordinateId
+ );
+ }
+ let cacheGroup = null;
+ if (!!origin?.cachegroupId) {
+ cacheGroup = await
this.cacheGroupService.getCacheGroups(
+ origin.cachegroupId
+ );
+ }
+
+ const created = {
+ cachegroup: cacheGroup?.name ?? null,
+ cachegroupId: cacheGroup?.id ?? null,
+ coordinate: coordinate?.name ?? null,
+ coordinateId: coordinate?.id ?? null,
+ deliveryService: ds.displayName ?? null,
+ deliveryServiceId: ds.id ?? null,
+ fqdn: "",
+ id: 1,
+ ip6Address: null,
+ ipAddress: null,
+ isPrimary: null,
+ lastUpdated: new Date(),
+ name: "",
+ port: null,
+ profile: profile?.name ?? null,
+ profileId: profile?.id ?? null,
+ protocol: "https" as never,
+ tenant: tenant.name ?? null,
+ tenantId: tenant.id ?? null,
+ };
+ this.origins.push(created);
+ return created;
+ }
+
+ /**
+ * Deletes an existing Origin.
+ *
+ * @param origin The Origin to be deleted (or its ID)
+ */
+ public async deleteOrigin(
+ origin: RequestOriginResponse | number
+ ): Promise<void> {
+ const id = typeof origin === "number" ? origin : origin.id;
+ const index = this.origins.findIndex((d) => d.id === id);
+ if (index === -1) {
+ throw new Error(`no such Origin: ${id}`);
+ }
+ this.origins.splice(index, 1);
+ }
+}
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts
b/experimental/traffic-portal/src/app/core/core.module.ts
index 4a5f1a96ad..4a09a645f1 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -51,6 +51,8 @@ import {
} 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 { OriginDetailComponent } from
"./origins/detail/origin-detail.component";
+import { OriginsTableComponent } from
"./origins/table/origins-table.component";
import { ParameterDetailComponent } from
"./parameters/detail/parameter-detail.component";
import { ParametersTableComponent } from
"./parameters/table/parameters-table.component";
import { ProfileDetailComponent } from
"./profiles/profile-detail/profile-detail.component";
@@ -118,6 +120,8 @@ export const ROUTES: Routes = [
{ component: CoordinatesTableComponent, path: "coordinates" },
{ component: TypesTableComponent, path: "types" },
{ component: TypeDetailComponent, path: "types/:id"},
+ { component: OriginsTableComponent, path: "origins" },
+ { component: OriginDetailComponent, path: "origins/:id"},
{ component: ParametersTableComponent, path: "parameters" },
{ component: ParameterDetailComponent, path: "parameters/:id" },
{ component: StatusesTableComponent, path: "statuses" },
@@ -175,6 +179,8 @@ export const ROUTES: Routes = [
TopologyDetailsComponent,
TypeDetailComponent,
TypesTableComponent,
+ OriginDetailComponent,
+ OriginsTableComponent,
UpdatePasswordDialogComponent,
UpdateStatusComponent,
UserDetailsComponent,
diff --git
a/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.html
b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.html
new file mode 100644
index 0000000000..94c14be8fe
--- /dev/null
+++
b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.html
@@ -0,0 +1,133 @@
+<!--
+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 appearance="outlined" class="page-content triple-column-responsive">
+ <tp-loading *ngIf="!origin"></tp-loading>
+ <form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="origin">
+ <mat-card-content class="container">
+ <mat-form-field *ngIf="!new">
+ <mat-label>ID</mat-label>
+ <input
+ matInput
+ type="text"
+ name="id"
+ disabled
+ readonly
+ [defaultValue]="origin.id"
+ />
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Name</mat-label>
+ <input
+ matInput
+ type="text"
+ name="name"
+ required
+ [(ngModel)]="origin.name"
+ />
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Tenant</mat-label>
+ <mat-select [(ngModel)]="origin.tenantId"
required name="tenantId">
+ <mat-option *ngFor="let t of tenants"
[value]="t.id">{{ t.name }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>FQDN</mat-label>
+ <input
+ matInput
+ type="text"
+ name="fqdn"
+ required
+ [(ngModel)]="origin.fqdn"
+ />
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>IPv4 Address</mat-label>
+ <input
+ matInput
+ type="text"
+ name="ipAddress"
+ [(ngModel)]="origin.ipAddress"
+ />
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>IPv6 Address</mat-label>
+ <input
+ matInput
+ type="text"
+ name="ip6Address"
+ [(ngModel)]="origin.ip6Address"
+ />
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Protocol</mat-label>
+ <mat-select [(ngModel)]="origin.protocol"
required name="protocol">
+ <mat-option *ngFor="let p of protocols"
[value]="p">{{ p }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>TCP Port</mat-label>
+ <input matInput type="text" name="port"
[(ngModel)]="origin.port"/>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Delivery Service</mat-label>
+ <mat-select
[(ngModel)]="origin.deliveryServiceId" required name="deliveryServiceId">
+ <mat-option *ngFor="let ds of
deliveryServices" [value]="ds.id">{{ ds.displayName }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Coordinate</mat-label>
+ <mat-select [(ngModel)]="origin.coordinateId"
name="coordinateId">
+ <mat-option *ngFor="let c of
coordinates" [value]="c.id">{{ c.name }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Cache Group</mat-label>
+ <mat-select [(ngModel)]="origin.cachegroupId"
name="cachegroupId">
+ <mat-option *ngFor="let c of
cacheGroups" [value]="c.id">{{ c.name }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Profile</mat-label>
+ <mat-select [(ngModel)]="origin.profileId"
name="profileId">
+ <mat-option *ngFor="let c of profiles"
[value]="c.id">{{ c.name }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ <mat-form-field *ngIf="!new">
+ <mat-label>Last Updated</mat-label>
+ <input
+ matInput
+ type="text"
+ name="lastUpdated"
+ disabled
+ readonly
+ [defaultValue]="origin.lastUpdated"
+ />
+ </mat-form-field>
+ </mat-card-content>
+ <mat-card-actions align="end" class="actions-container">
+ <button
+ mat-raised-button
+ type="button"
+ *ngIf="!new"
+ color="warn"
+ (click)="deleteOrigin()"
+ >
+ Delete
+ </button>
+ <button mat-raised-button type="submit"
color="primary">Save</button>
+ </mat-card-actions>
+ </form>
+</mat-card>
diff --git
a/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.scss
b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.scss
new file mode 100644
index 0000000000..ebe77042d3
--- /dev/null
+++
b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.scss
@@ -0,0 +1,13 @@
+/*
+* 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.
+*/
diff --git
a/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.spec.ts
b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.spec.ts
new file mode 100644
index 0000000000..62501ed52b
--- /dev/null
+++
b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.spec.ts
@@ -0,0 +1,125 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { HarnessLoader } from "@angular/cdk/testing";
+import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatButtonHarness } from "@angular/material/button/testing";
+import { MatDialogModule } from "@angular/material/dialog";
+import { MatDialogHarness } from "@angular/material/dialog/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { ReplaySubject } from "rxjs";
+
+import { OriginService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { NavigationService } from
"src/app/shared/navigation/navigation.service";
+
+import { OriginDetailComponent } from "./origin-detail.component";
+
+describe("OriginDetailComponent", () => {
+ let component: OriginDetailComponent;
+ let fixture: ComponentFixture<OriginDetailComponent>;
+ let route: ActivatedRoute;
+ let paramMap: jasmine.Spy;
+ let loader: HarnessLoader;
+ let service: OriginService;
+
+ const navSvc = jasmine.createSpyObj([], {
+ headerHidden: new ReplaySubject<boolean>(),
+ headerTitle: new ReplaySubject<string>(),
+ });
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [OriginDetailComponent],
+ imports: [APITestingModule, RouterTestingModule,
MatDialogModule, NoopAnimationsModule],
+ providers: [{provide: NavigationService, useValue:
navSvc}],
+ }).compileComponents();
+
+ route = TestBed.inject(ActivatedRoute);
+ paramMap = spyOn(route.snapshot.paramMap, "get");
+ paramMap.and.returnValue(null);
+ fixture = TestBed.createComponent(OriginDetailComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ service = TestBed.inject(OriginService);
+ loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
+ await fixture.whenStable();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("new origin", async () => {
+ paramMap.and.returnValue("new");
+
+ fixture = TestBed.createComponent(OriginDetailComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(paramMap).toHaveBeenCalled();
+ expect(component.origin).not.toBeNull();
+ expect(component.origin.name).toBe("");
+ expect(component.new).toBeTrue();
+ });
+
+ it("existing origin", async () => {
+ const id = 1;
+ paramMap.and.returnValue(id);
+ const origin = await service.getOrigins(id);
+ fixture = TestBed.createComponent(OriginDetailComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(paramMap).toHaveBeenCalled();
+ expect(component.origin).not.toBeNull();
+ expect(component.origin.name).toBe(origin.name);
+ expect(component.new).toBeFalse();
+ });
+
+ it("deletes existing Origins", async () => {
+ const spy = spyOn(service, "deleteOrigin").and.callThrough();
+ let orgs = await service.getOrigins();
+ const initialLength = orgs.length;
+ if (initialLength < 1) {
+ return fail("need at least one Origin");
+ }
+ const org = orgs[0];
+ component.origin = org;
+ component.new = false;
+
+ const asyncExpectation =
expectAsync(component.deleteOrigin()).toBeResolvedTo(undefined);
+ const dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ if (dialogs.length !== 1) {
+ return fail(`failed to open dialog; ${dialogs.length}
dialogs found`);
+ }
+ const dialog = dialogs[0];
+ const buttons = await
dialog.getAllHarnesses(MatButtonHarness.with({text:
/^[cC][oO][nN][fF][iI][rR][mM]$/}));
+ if (buttons.length !== 1) {
+ return fail(`'confirm' button not found;
${buttons.length} buttons found`);
+ }
+ await buttons[0].click();
+
+ expect(spy).toHaveBeenCalledOnceWith(org);
+
+ orgs = await service.getOrigins();
+ expect(orgs).not.toContain(org);
+ expect(orgs.length).toBe(initialLength - 1);
+
+ await asyncExpectation;
+ });
+
+});
diff --git
a/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.ts
b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.ts
new file mode 100644
index 0000000000..33d199da1e
--- /dev/null
+++
b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.ts
@@ -0,0 +1,198 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Location } from "@angular/common";
+import { Component, OnInit } from "@angular/core";
+import { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import type {
+ RequestOrigin,
+ RequestOriginResponse,
+ ResponseCacheGroup,
+ ResponseCoordinate,
+ ResponseDeliveryService,
+ ResponseProfile,
+ ResponseTenant,
+} from "trafficops-types";
+
+import {
+ CacheGroupService,
+ CoordinateService,
+ DeliveryServiceService,
+ OriginService,
+ ProfileService,
+ UserService,
+} from "src/app/api";
+import { DecisionDialogComponent } from
"src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { LoggingService } from "src/app/shared/logging.service";
+import { NavigationService } from
"src/app/shared/navigation/navigation.service";
+
+/**
+ * OriginDetailComponent is the controller for the origin add/edit form.
+ */
+@Component({
+ selector: "tp-origins-detail",
+ styleUrls: ["./origin-detail.component.scss"],
+ templateUrl: "./origin-detail.component.html",
+})
+export class OriginDetailComponent implements OnInit {
+ public new = false;
+ public origin!: RequestOriginResponse;
+ public tenants = new Array<ResponseTenant>();
+ public coordinates = new Array<ResponseCoordinate>();
+ public cacheGroups = new Array<ResponseCacheGroup>();
+ public profiles = new Array<ResponseProfile>();
+ public deliveryServices = new Array<ResponseDeliveryService>();
+ public protocols = new Array<string>();
+
+ constructor(
+ private readonly route: ActivatedRoute,
+ private readonly originService: OriginService,
+ private readonly location: Location,
+ private readonly dialog: MatDialog,
+ private readonly navSvc: NavigationService,
+ private readonly log: LoggingService,
+ private readonly userService: UserService,
+ private readonly coordinateService: CoordinateService,
+ private readonly cacheGroupService: CacheGroupService,
+ private readonly profileService: ProfileService,
+ private readonly dsService: DeliveryServiceService
+ ) {}
+
+ /**
+ * Angular lifecycle hook where data is initialized.
+ */
+ public async ngOnInit(): Promise<void> {
+ this.tenants = await this.userService.getTenants();
+ this.cacheGroups = await
this.cacheGroupService.getCacheGroups();
+ this.coordinates = await
this.coordinateService.getCoordinates();
+ this.profiles = await this.profileService.getProfiles();
+ this.deliveryServices = await
this.dsService.getDeliveryServices();
+ this.protocols = ["http", "https"];
+
+ const ID = this.route.snapshot.paramMap.get("id");
+ if (ID === null) {
+ this.log.error("missing required route parameter 'id'");
+ return;
+ }
+ if (ID === "new") {
+ this.navSvc.headerTitle.next("New Origin");
+ this.new = true;
+ this.origin = {
+ cachegroup: null,
+ cachegroupId: -1,
+ coordinate: null,
+ coordinateId: -1,
+ deliveryService: null,
+ deliveryServiceId: -1,
+ fqdn: "",
+ id: -1,
+ ip6Address: null,
+ ipAddress: null,
+ isPrimary: null,
+ lastUpdated: new Date(),
+ name: "",
+ port: null,
+ profile: null,
+ profileId: -1,
+ protocol: "https",
+ tenant: null,
+ tenantId: -1,
+ };
+ return;
+ }
+ const numID = parseInt(ID, 10);
+ if (Number.isNaN(numID)) {
+ this.log.error("route parameter 'id' was non-number: ",
ID);
+ return;
+ }
+ this.origin = await this.originService.getOrigins(numID);
+ this.navSvc.headerTitle.next(`Origin: ${this.origin.name}`);
+ }
+
+ /**
+ * Deletes the current origin.
+ */
+ public async deleteOrigin(): Promise<void> {
+ if (this.new) {
+ this.log.error("Unable to delete new origin");
+ return;
+ }
+ const ref = this.dialog.open(DecisionDialogComponent, {
+ data: {
+ message: `Are you sure you want to delete
origin ${this.origin.name}`,
+ title: "Confirm Delete",
+ },
+ });
+ ref.afterClosed().subscribe((result) => {
+ if (result) {
+ this.originService.deleteOrigin(this.origin);
+ this.location.back();
+ }
+ });
+ }
+
+ /**
+ * Submits new/updated origin.
+ *
+ * @param e HTML form submission event.
+ */
+ public async submit(e: Event): Promise<void> {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this.new) {
+ const {
+ cachegroupId,
+ coordinateId,
+ deliveryServiceId,
+ fqdn,
+ ipAddress,
+ ip6Address,
+ name,
+ port,
+ protocol,
+ profileId,
+ tenantId,
+ } = this.origin;
+
+ const requestOrigin: RequestOrigin = {
+ deliveryServiceId,
+ fqdn,
+ ip6Address,
+ ipAddress,
+ name,
+ port,
+ protocol,
+ tenantID: tenantId,
+ };
+
+ if (coordinateId !== -1) {
+ requestOrigin.coordinateId = coordinateId;
+ }
+
+ if (cachegroupId !== -1) {
+ requestOrigin.cachegroupId = cachegroupId;
+ }
+
+ if (profileId !== -1) {
+ requestOrigin.profileId = profileId;
+ }
+
+ this.origin = await
this.originService.createOrigin(requestOrigin);
+ this.new = false;
+ } else {
+ this.origin = await
this.originService.updateOrigin(this.origin);
+ }
+ }
+}
diff --git
a/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.html
b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.html
new file mode 100644
index 0000000000..a32045d348
--- /dev/null
+++
b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.html
@@ -0,0 +1,33 @@
+<!--
+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 class="page-content">
+ <div class="search-container">
+ <input type="search" name="fuzzControl" aria-label="Fuzzy
Search Origins" inputmode="search" role="search"
+ accesskey="/" placeholder="Fuzzy
Search" [formControl]="fuzzControl" (input)="updateURL()"/>
+ </div>
+ <tp-generic-table
+ [data]="origins | async"
+ [cols]="columnDefs"
+ [fuzzySearch]="fuzzySubject"
+ context="origins"
+ [doubleClickLink]="doubleClickLink"
+ [contextMenuItems]="contextMenuItems"
+ (contextMenuAction)="handleContextMenu($event)">
+ </tp-generic-table>
+</mat-card>
+
+<a class="page-fab" mat-fab title="Create a new Origin"
*ngIf="auth.hasPermission('ORIGIN:CREATE')" routerLink="new">
+ <mat-icon>add</mat-icon>
+</a>
diff --git
a/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.scss
b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.scss
new file mode 100644
index 0000000000..a76ede4a23
--- /dev/null
+++
b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.scss
@@ -0,0 +1,14 @@
+/*
+* 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.
+*/
+
diff --git
a/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.spec.ts
b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.spec.ts
new file mode 100644
index 0000000000..8800a3271e
--- /dev/null
+++
b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.spec.ts
@@ -0,0 +1,199 @@
+/*
+ * 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,
+ fakeAsync,
+ TestBed,
+ tick,
+} from "@angular/core/testing";
+import {
+ MatDialog,
+ MatDialogModule,
+ MatDialogRef,
+} from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { of } from "rxjs";
+import type { ResponseOrigin } from "trafficops-types";
+
+import { OriginService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { isAction } from
"src/app/shared/generic-table/generic-table.component";
+
+import { OriginsTableComponent } from "./origins-table.component";
+
+const testOrigin: ResponseOrigin = {
+ cachegroup: "",
+ cachegroupId: 1,
+ coordinate: "",
+ coordinateId: 1,
+ deliveryService: "",
+ deliveryServiceId: 1,
+ fqdn: "0",
+ id: 1,
+ ip6Address: "",
+ ipAddress: "",
+ isPrimary: false,
+ lastUpdated: new Date(),
+ name: "TestOrigin",
+ port: 80,
+ profile: "",
+ profileId: 1,
+ protocol: "https",
+ tenant: "*",
+ tenantId: 0,
+};
+
+describe("OriginsTableComponent", () => {
+ let component: OriginsTableComponent;
+ let fixture: ComponentFixture<OriginsTableComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [OriginsTableComponent],
+ imports: [APITestingModule, RouterTestingModule,
MatDialogModule],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(OriginsTableComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("sets the fuzzy search subject based on the search query param",
fakeAsync(() => {
+ const router = TestBed.inject(ActivatedRoute);
+ const searchString = "testorigin";
+ spyOnProperty(router, "queryParamMap").and.returnValue(
+ of(new Map([["search", searchString]]))
+ );
+
+ let searchValue = "not the right string";
+ component.fuzzySubject.subscribe((s) => (searchValue = s));
+
+ component.ngOnInit();
+ tick();
+
+ expect(searchValue).toBe(searchString);
+ }));
+
+ it("updates the fuzzy search output", fakeAsync(() => {
+ let called = false;
+ const text = "testorigin";
+ const spy = jasmine.createSpy("subscriber", (txt: string): void
=> {
+ if (!called) {
+ expect(txt).toBe("");
+ called = true;
+ } else {
+ expect(txt).toBe(text);
+ }
+ });
+ component.fuzzySubject.subscribe(spy);
+ tick();
+ expect(spy).toHaveBeenCalled();
+ component.fuzzControl.setValue(text);
+ component.updateURL();
+ tick();
+ expect(spy).toHaveBeenCalledTimes(2);
+ }));
+
+ it("handle the 'delete' context menu item", fakeAsync(async () => {
+ const item = component.contextMenuItems.find(
+ (c) => c.name === "Delete"
+ );
+ if (!item) {
+ return fail("missing 'Delete' context menu item");
+ }
+ if (!isAction(item)) {
+ return fail("expected an action, not a link");
+ }
+ expect(item.multiRow).toBeFalsy();
+ expect(item.disabled).toBeUndefined();
+
+ const api = TestBed.inject(OriginService);
+ const spy = spyOn(api, "deleteOrigin").and.callThrough();
+ expect(spy).not.toHaveBeenCalled();
+
+ const dialogService = TestBed.inject(MatDialog);
+ const openSpy = spyOn(dialogService, "open").and.returnValue({
+ afterClosed: () => of(true),
+ } as MatDialogRef<unknown>);
+
+ const origin = await api.createOrigin({
+ deliveryServiceId: 1,
+ fqdn: "0",
+ name: "*",
+ protocol: "https",
+ tenantID: 1,
+ });
+ expect(openSpy).not.toHaveBeenCalled();
+ const asyncExpectation = expectAsync(
+ component.handleContextMenu({
+ action: "delete",
+ data: origin,
+ })
+ ).toBeResolvedTo(undefined);
+ tick();
+
+ expect(openSpy).toHaveBeenCalled();
+ tick();
+
+ expect(spy).toHaveBeenCalled();
+
+ await asyncExpectation;
+ }));
+
+ it("generates 'Edit' context menu item href", () => {
+ const item = component.contextMenuItems.find((i) => i.name ===
"Edit");
+ if (!item) {
+ return fail("missing 'Edit' context menu item");
+ }
+ if (isAction(item)) {
+ return fail("expected a link, not an action");
+ }
+ if (typeof item.href !== "function") {
+ return fail(
+ `'Edit' context menu item should use a function
to determine href, instead uses: ${item.href}`
+ );
+ }
+ expect(item.href(testOrigin)).toBe(String(testOrigin.id));
+ expect(item.queryParams).toBeUndefined();
+ expect(item.fragment).toBeUndefined();
+ expect(item.newTab).toBeFalsy();
+ });
+
+ it("generates 'Open in New Tab' context menu item href", () => {
+ const item = component.contextMenuItems.find(
+ (i) => i.name === "Open in New Tab"
+ );
+ if (!item) {
+ return fail("missing 'Open in New Tab' context menu
item");
+ }
+ if (isAction(item)) {
+ return fail("expected a link, not an action");
+ }
+ if (typeof item.href !== "function") {
+ return fail(
+ `'Open in New Tab' context menu item should use
a function to determine href, instead uses: ${item.href}`
+ );
+ }
+ expect(item.href(testOrigin)).toBe(String(testOrigin.id));
+ expect(item.queryParams).toBeUndefined();
+ expect(item.fragment).toBeUndefined();
+ expect(item.newTab).toBeTrue();
+ });
+});
diff --git
a/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.ts
b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.ts
new file mode 100644
index 0000000000..984879aa6a
--- /dev/null
+++
b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.ts
@@ -0,0 +1,193 @@
+/*
+ * 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 } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { ColDef } from "ag-grid-community";
+import { BehaviorSubject } from "rxjs";
+import type { RequestOriginResponse } from "trafficops-types";
+
+import { OriginService } from "src/app/api";
+import { CurrentUserService } from
"src/app/shared/current-user/current-user.service";
+import { DecisionDialogComponent } from
"src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import type {
+ ContextMenuActionEvent,
+ ContextMenuItem,
+ DoubleClickLink,
+} from "src/app/shared/generic-table/generic-table.component";
+import { NavigationService } from
"src/app/shared/navigation/navigation.service";
+
+/**
+ * OriginsTableComponent is the controller for the "Origins" table.
+ */
+@Component({
+ selector: "tp-origins",
+ styleUrls: ["./origins-table.component.scss"],
+ templateUrl: "./origins-table.component.html",
+})
+export class OriginsTableComponent implements OnInit {
+ /** List of origins */
+ public origins: Promise<Array<RequestOriginResponse>>;
+
+ /** Definitions of the table's columns according to the ag-grid API */
+ public columnDefs: ColDef[] = [
+ {
+ field: "name",
+ headerName: "Name",
+ },
+ {
+ field: "tenant",
+ headerName: "Tenant",
+ },
+ {
+ field: "isPrimary",
+ filter: "tpBooleanFilter",
+ headerName: "Primary",
+ },
+ {
+ field: "deliveryService",
+ headerName: "Delivery Service",
+ },
+ {
+ field: "fqdn",
+ headerName: "FQDN",
+ },
+ {
+ cellRenderer: "sshCellRenderer",
+ field: "ipAddress",
+ headerName: "IPv4 Address",
+ },
+ {
+ cellRenderer: "sshCellRenderer",
+ field: "ip6Address",
+ headerName: "IPv6 Address",
+ },
+ {
+ field: "protocol",
+ headerName: "Protocol",
+ },
+ {
+ field: "port",
+ headerName: "Port",
+ },
+ {
+ field: "coordinate",
+ headerName: "Coordinate",
+ },
+ {
+ field: "cachegroup",
+ headerName: "CacheGroup",
+ },
+ {
+ field: "profile",
+ headerName: "Profile",
+ },
+ {
+ field: "lastUpdated",
+ filter: "agDateColumnFilter",
+ headerName: "Last Updated",
+ hide: true,
+ },
+ ];
+
+ /** Definitions for the context menu items (which act on augmented
origin data). */
+ public contextMenuItems: Array<ContextMenuItem<RequestOriginResponse>>
= [
+ {
+ href: (origin: RequestOriginResponse): string =>
`${origin.id}`,
+ name: "Edit",
+ },
+ {
+ href: (origin: RequestOriginResponse): string =>
`${origin.id}`,
+ name: "Open in New Tab",
+ newTab: true,
+ },
+ {
+ action: "delete",
+ multiRow: false,
+ name: "Delete",
+ },
+ ];
+
+ /** Defines what the table should do when a row is double-clicked. */
+ public doubleClickLink: DoubleClickLink<RequestOriginResponse> = {
+ href: (row: RequestOriginResponse): string =>
`/core/origins/${row.id}`,
+ };
+
+ /** A subject that child components can subscribe to for access to the
fuzzy search query text */
+ public fuzzySubject: BehaviorSubject<string>;
+
+ /** Form controller for the user search input. */
+ public fuzzControl = new FormControl<string>("", { nonNullable: true });
+
+ constructor(
+ private readonly route: ActivatedRoute,
+ private readonly navSvc: NavigationService,
+ private readonly api: OriginService,
+ private readonly dialog: MatDialog,
+ public readonly auth: CurrentUserService
+ ) {
+ this.fuzzySubject = new BehaviorSubject<string>("");
+ this.origins = this.api.getOrigins();
+ this.navSvc.headerTitle.next("Origins");
+ }
+
+ /** Initializes table data, loading it from Traffic Ops. */
+ public ngOnInit(): void {
+ this.route.queryParamMap.subscribe((m) => {
+ const search = m.get("search");
+ if (search) {
+
this.fuzzControl.setValue(decodeURIComponent(search));
+ this.updateURL();
+ }
+ });
+ }
+
+ /** Update the URL's 'search' query parameter for the user's search
input. */
+ public updateURL(): void {
+ this.fuzzySubject.next(this.fuzzControl.value);
+ }
+
+ /**
+ * Handles a context menu event.
+ *
+ * @param evt The action selected from the context menu.
+ */
+ public async handleContextMenu(
+ evt: ContextMenuActionEvent<RequestOriginResponse>
+ ): Promise<void> {
+ const data = evt.data as RequestOriginResponse;
+ switch (evt.action) {
+ case "delete":
+ const ref =
this.dialog.open(DecisionDialogComponent, {
+ data: {
+ message: `Are you sure you want
to delete origin ${data.name} with id ${data.id} ?`,
+ title: "Confirm Delete",
+ },
+ });
+ ref.afterClosed().subscribe((result) => {
+ if (result) {
+ this.api
+ .deleteOrigin(data.id)
+ .then(
+ async () =>
+
(this.origins = this.api.getOrigins())
+ );
+ }
+ });
+ break;
+ }
+ }
+}
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 aa69fad15b..e3331ac550 100644
---
a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++
b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -203,7 +203,7 @@ export class NavigationService {
href: "/core/types",
name: "Types"
}, {
- href: `${this.tpv1Url}/origins`,
+ href: "/core/origins",
name: "Origins"
},
{