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"
                                },
                                {

Reply via email to