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

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


The following commit(s) were added to refs/heads/master by this push:
     new fc961aefb4 Tpv2 coordinates (#7364)
fc961aefb4 is described below

commit fc961aefb4b33e12a92e181b6b7cb8a6cd8cffce
Author: Kurtis Michie <[email protected]>
AuthorDate: Thu Mar 16 10:54:31 2023 -0600

    Tpv2 coordinates (#7364)
    
    * Created coordinates table and details page
    
    * Moved coordinate components to cache-group folder. Corrected 
updateCoordinate path in cache-group.service.ts. Added more decimal places to 
latitude and longitude in cache-group-details.component.html.
    
    * Updated test case in coordinate-detail.component.spec.ts. Removed wrong 
test in coordinates-table.component.spec.ts.
    
    * Local testing and dev cleanup
    
    * Changelog documentation
    
    * Changed default lat and long value on new create to 0
    
    * Changed import location in globals.ts, removed TPv2 change in CHANGELOG.md
    
    * Changed location accuracy to less finite
    
    * Fixed GHA test failures
---
 .../traffic-portal/nightwatch/globals/globals.ts   |  20 ++-
 .../page_objects/cacheGroups/coordinateDetail.ts   |  45 ++++++
 .../page_objects/cacheGroups/coordinatesTable.ts   |  46 ++++++
 .../tests/cacheGroups/coordinates/detail.spec.ts   |  50 ++++++
 .../tests/cacheGroups/coordinates/table.spec.ts    |  24 +++
 .../src/app/api/cache-group.service.ts             |  63 +++++++-
 .../src/app/api/testing/cache-group.service.ts     |  87 ++++++++++-
 .../detail/coordinate-detail.component.html        |  44 ++++++
 .../detail/coordinate-detail.component.scss        |  25 +++
 .../detail/coordinate-detail.component.spec.ts     |  78 ++++++++++
 .../detail/coordinate-detail.component.ts          | 108 +++++++++++++
 .../table/coordinates-table.component.html         |  29 ++++
 .../table/coordinates-table.component.scss         |  14 ++
 .../table/coordinates-table.component.spec.ts      | 167 +++++++++++++++++++++
 .../table/coordinates-table.component.ts           | 134 +++++++++++++++++
 .../traffic-portal/src/app/core/core.module.ts     |   8 +-
 .../app/shared/navigation/navigation.service.ts    |   3 +
 17 files changed, 940 insertions(+), 5 deletions(-)

diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts 
b/experimental/traffic-portal/nightwatch/globals/globals.ts
index 2e143c64a8..73b4df34a3 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -19,6 +19,8 @@ import { NightwatchBrowser } from "nightwatch";
 import type { AsnsPageObject } from 
"nightwatch/page_objects/cacheGroups/asnsTable";
 import type { CacheGroupDetailPageObject } from 
"nightwatch/page_objects/cacheGroups/cacheGroupDetails";
 import type { CacheGroupsPageObject } from 
"nightwatch/page_objects/cacheGroups/cacheGroupsTable";
+import type { CoordinateDetailPageObject } from 
"nightwatch/page_objects/cacheGroups/coordinateDetail";
+import type { CoordinatesPageObject } from 
"nightwatch/page_objects/cacheGroups/coordinatesTable";
 import type { DivisionDetailPageObject } from 
"nightwatch/page_objects/cacheGroups/divisionDetail";
 import type { DivisionsPageObject } from 
"nightwatch/page_objects/cacheGroups/divisionsTable";
 import type { RegionDetailPageObject } from 
"nightwatch/page_objects/cacheGroups/regionDetail";
@@ -58,7 +60,9 @@ import {
        ResponseCacheGroup,
        ResponsePhysicalLocation,
        RequestPhysicalLocation,
-       RequestType
+       ResponseCoordinate,
+       RequestCoordinate,
+       RequestType,
 } from "trafficops-types";
 
 import {TypeDetailPageObject} from "../page_objects/types/typeDetail";
@@ -73,6 +77,8 @@ declare module "nightwatch" {
                cacheGroups: {
                        cacheGroupDetails: () => CacheGroupDetailPageObject;
                        cacheGroupsTable: () => CacheGroupsPageObject;
+                       coordinateDetail: () => CoordinateDetailPageObject;
+                       coordinatesTable: () => CoordinatesPageObject;
                        divisionDetail: () => DivisionDetailPageObject;
                        divisionsTable: () => DivisionsPageObject;
                        regionDetail: () => RegionDetailPageObject;
@@ -121,6 +127,7 @@ declare module "nightwatch" {
 export interface CreatedData {
        cacheGroup: ResponseCacheGroup;
        cdn: ResponseCDN;
+       coordinate: ResponseCoordinate;
        division: ResponseDivision;
        ds: ResponseDeliveryService;
        ds2: ResponseDeliveryService;
@@ -346,6 +353,17 @@ const globals = {
                        console.log(`Successfully created Phys Loc 
${respPhysLoc.name}`);
                        data.physLoc = respPhysLoc;
 
+                       const coordinate: RequestCoordinate = {
+                               latitude: 0,
+                               longitude: 0,
+                               name: `coord${globals.uniqueString}`
+                       };
+                       url = `${apiUrl}/coordinates`;
+                       resp = await client.post(url, 
JSON.stringify(coordinate));
+                       const respCoordinate: ResponseCoordinate = 
resp.data.response;
+                       console.log(`Successfully created Coordinate 
${respCoordinate.name}`);
+                       data.coordinate = respCoordinate;
+
                        const type: RequestType = {
                                description: "blah",
                                name: `type${globals.uniqueString}`,
diff --git 
a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinateDetail.ts
 
b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinateDetail.ts
new file mode 100644
index 0000000000..a9823030f9
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinateDetail.ts
@@ -0,0 +1,45 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EnhancedPageObject } from "nightwatch";
+
+/**
+ * Defines the PageObject for Coordinate Details.
+ */
+export type CoordinateDetailPageObject = EnhancedPageObject<{}, typeof 
coordinateDetailPageObject.elements>;
+
+const coordinateDetailPageObject = {
+       elements: {
+               id: {
+                       selector: "input[name='id']"
+               },
+               lastUpdated: {
+                       selector: "input[name='lastUpdated']"
+               },
+               latitude: {
+                       selector: "input[name='latitude']"
+               },
+               longitude: {
+                       selector: "input[name='longitude']"
+               },
+               name: {
+                       selector: "input[name='name']"
+               },
+               saveBtn: {
+                       selector: "button[type='submit']"
+               }
+       },
+};
+
+export default coordinateDetailPageObject;
diff --git 
a/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinatesTable.ts
 
b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinatesTable.ts
new file mode 100644
index 0000000000..cec1df756d
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/page_objects/cacheGroups/coordinatesTable.ts
@@ -0,0 +1,46 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EnhancedPageObject, EnhancedSectionInstance, NightwatchAPI } from 
"nightwatch";
+
+import { TABLE_COMMANDS, TableSectionCommands } from "../../globals/tables";
+
+/**
+ * Defines the Coordinates table commands
+ */
+type CoordinatesTableCommands = TableSectionCommands;
+
+/**
+ * Defines the Page Object for the Coordinates page.
+ */
+export type CoordinatesPageObject = EnhancedPageObject<{}, {},
+EnhancedSectionInstance<CoordinatesTableCommands>>;
+
+const coordinatesPageObject = {
+       api: {} as NightwatchAPI,
+       sections: {
+               coordinatesTable: {
+                       commands: {
+                               ...TABLE_COMMANDS
+                       },
+                       elements: {},
+                       selector: "mat-card"
+               }
+       },
+       url(): string {
+               return `${this.api.launchUrl}/core/coordinates`;
+       }
+};
+
+export default coordinatesPageObject;
diff --git 
a/experimental/traffic-portal/nightwatch/tests/cacheGroups/coordinates/detail.spec.ts
 
b/experimental/traffic-portal/nightwatch/tests/cacheGroups/coordinates/detail.spec.ts
new file mode 100644
index 0000000000..a82be9dd4d
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/tests/cacheGroups/coordinates/detail.spec.ts
@@ -0,0 +1,50 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+describe("Coordinate Detail Spec", () => {
+       it("Test coordinate", () => {
+               const page = browser.page.cacheGroups.coordinateDetail();
+               
browser.url(`${page.api.launchUrl}/core/coordinates/${browser.globals.testData.coordinate.id}`,
 res => {
+                       browser.assert.ok(res.status === 0);
+                       page.waitForElementVisible("mat-card")
+                               .assert.enabled("@latitude")
+                               .assert.enabled("@longitude")
+                               .assert.enabled("@name")
+                               .assert.enabled("@saveBtn")
+                               .assert.not.enabled("@id")
+                               .assert.not.enabled("@lastUpdated")
+                               .assert.valueEquals("@latitude", 
String(browser.globals.testData.coordinate.latitude))
+                               .assert.valueEquals("@longitude", 
String(browser.globals.testData.coordinate.longitude))
+                               .assert.valueEquals("@name", 
browser.globals.testData.coordinate.name)
+                               .assert.valueEquals("@id", 
String(browser.globals.testData.coordinate.id));
+               });
+       });
+
+       it("New coordinate", () => {
+               const page = browser.page.cacheGroups.coordinateDetail();
+               browser.url(`${page.api.launchUrl}/core/coordinates/new`, res 
=> {
+                       browser.assert.ok(res.status === 0);
+                       page.waitForElementVisible("mat-card")
+                               .assert.enabled("@latitude")
+                               .assert.enabled("@longitude")
+                               .assert.enabled("@name")
+                               .assert.enabled("@saveBtn")
+                               .assert.not.elementPresent("@id")
+                               .assert.not.elementPresent("@lastUpdated")
+                               .assert.valueEquals("@latitude", 
String(browser.globals.testData.coordinate.latitude))
+                               .assert.valueEquals("@longitude", 
String(browser.globals.testData.coordinate.longitude))
+                               .assert.valueEquals("@name", "");
+               });
+       });
+});
diff --git 
a/experimental/traffic-portal/nightwatch/tests/cacheGroups/coordinates/table.spec.ts
 
b/experimental/traffic-portal/nightwatch/tests/cacheGroups/coordinates/table.spec.ts
new file mode 100644
index 0000000000..b9fdcfd0dd
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/tests/cacheGroups/coordinates/table.spec.ts
@@ -0,0 +1,24 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+describe("Coordinates Spec", () => {
+       it("Loads elements", async () => {
+               browser.page.cacheGroups.coordinatesTable().navigate()
+                       .waitForElementPresent("input[name=fuzzControl]");
+               browser.elements("css selector", "div.ag-row", rows => {
+                       browser.assert.ok(rows.status === 0);
+                       browser.assert.ok((rows.value as []).length >= 2);
+               });
+       });
+});
diff --git a/experimental/traffic-portal/src/app/api/cache-group.service.ts 
b/experimental/traffic-portal/src/app/api/cache-group.service.ts
index 794f3966a1..632e58ae21 100644
--- a/experimental/traffic-portal/src/app/api/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/cache-group.service.ts
@@ -24,6 +24,8 @@ import type {
        CDN,
        CacheGroupQueueResponse,
        CacheGroupQueueRequest,
+       RequestCoordinate,
+       ResponseCoordinate,
 } from "trafficops-types";
 
 import { APIService } from "./base-api.service";
@@ -309,7 +311,6 @@ export class CacheGroupService extends APIService {
                        }
                        const r = await this.get<[ResponseRegion]>(path, 
undefined, params).toPromise();
                        return r[0];
-
                }
                return this.get<Array<ResponseRegion>>(path).toPromise();
        }
@@ -346,6 +347,66 @@ export class CacheGroupService extends APIService {
                await this.delete("regions/", undefined, { id : String(id) 
}).toPromise();
        }
 
+       public async getCoordinates(): Promise<Array<ResponseCoordinate>>;
+       public async getCoordinates(nameOrID: string | number): 
Promise<ResponseCoordinate>;
+
+       /**
+        * Gets an array of coordinates from Traffic Ops.
+        *
+        * @param nameOrID If given, returns only the Coordinate with the given 
name
+        * (string) or ID (number).
+        * @returns An Array of Coordinate objects - or a single Coordinate 
object if 'nameOrID'
+        * was given.
+        */
+       public async getCoordinates(nameOrID?: string | number): 
Promise<Array<ResponseCoordinate> | ResponseCoordinate> {
+               const path = "coordinates";
+               if(nameOrID) {
+                       let params;
+                       switch (typeof nameOrID) {
+                               case "string":
+                                       params = {name: nameOrID};
+                                       break;
+                               case "number":
+                                       params = {id: String(nameOrID)};
+                       }
+                       const r = await this.get<[ResponseCoordinate]>(path, 
undefined, params).toPromise();
+                       return r[0];
+               }
+               return this.get<Array<ResponseCoordinate>>(path).toPromise();
+       }
+
+       /**
+        * Replaces the current definition of a coordinate with the one given.
+        *
+        * @param coordinate The new coordinate.
+        * @returns The updated coordinate.
+        */
+       public async updateCoordinate(coordinate: ResponseCoordinate): 
Promise<ResponseCoordinate> {
+               const id = coordinate.id;
+               return this.put<ResponseCoordinate>("coordinates", coordinate, 
{ id : String(id) }).toPromise();
+       }
+
+       /**
+        * Creates a new coordinate.
+        *
+        * @param coordinate The coordinate to create.
+        * @returns The created coordinate.
+        */
+       public async createCoordinate(coordinate: RequestCoordinate): 
Promise<ResponseCoordinate> {
+               return this.post<ResponseCoordinate>("coordinates", 
coordinate).toPromise();
+       }
+
+       /**
+        * Deletes an existing coordinate.
+        *
+        * @param coordinateOrId Id of the coordinate to delete.
+        * @returns The deleted coordinate.
+        */
+       public async deleteCoordinate(coordinateOrId: number | 
ResponseCoordinate): Promise<void> {
+               const id = typeof(coordinateOrId) === "number" ? coordinateOrId 
: coordinateOrId.id;
+               await this.delete("coordinates/", undefined, { id : String(id) 
}).toPromise();
+       }
+
        public async getASNs(): Promise<Array<ResponseASN>>;
        public async getASNs(id: number): Promise<ResponseASN>;
 
diff --git 
a/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts 
b/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
index d473afec1e..a87c0a06a0 100644
--- a/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
@@ -17,12 +17,14 @@ import type {
        CacheGroupQueueResponse,
        CDN,
        RequestCacheGroup,
+       RequestCoordinate,
        RequestDivision,
        RequestRegion,
-       ResponseCacheGroup,
        ResponseASN,
+       ResponseCacheGroup,
+       ResponseCoordinate,
        ResponseDivision,
-       ResponseRegion
+       ResponseRegion,
 } from "trafficops-types";
 
 import { ServerService } from "./server.service";
@@ -180,6 +182,14 @@ export class CacheGroupService {
                        typeName: "TC_LOC"
                }
        ];
+       private readonly coordinates: Array<ResponseCoordinate> = [{
+               id: 1,
+               lastUpdated: new Date(),
+               latitude: 0,
+               longitude: 0,
+               name: "Coord1"
+       }
+       ];
 
        constructor(private readonly servers: ServerService) {}
 
@@ -593,6 +603,79 @@ export class CacheGroupService {
                }
                return this.regions.splice(index, 1)[0];
        }
+       public async getCoordinates(): Promise<Array<ResponseCoordinate>>;
+       public async getCoordinates(nameOrID: string | number): 
Promise<ResponseCoordinate>;
+
+       /**
+        * Gets an array of 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(c=>c.name === nameOrID);
+                                       break;
+                               case "number":
+                                       coordinate = 
this.coordinates.find(c=>c.id === nameOrID);
+                       }
+                       if (!coordinate) {
+                               throw new Error(`no such Coordinate: 
${nameOrID}`);
+                       }
+                       return coordinate;
+               }
+               return this.coordinates;
+       }
+
+       /**
+        * Replaces the current definition of a coordinate with the one given.
+        *
+        * @param coordinate The new coordinate.
+        * @returns The updated coordinate.
+        */
+       public async updateCoordinate(coordinate: ResponseCoordinate): 
Promise<ResponseCoordinate> {
+               const id = this.coordinates.findIndex(c => c.id === 
coordinate.id);
+               if (id === -1) {
+                       throw new Error(`no such Coordinate: ${coordinate.id}`);
+               }
+               this.coordinates[id] = coordinate;
+               return coordinate;
+       }
+
+       /**
+        * Creates a new coordinate.
+        *
+        * @param coordinate The coordinate to create.
+        * @returns The created coordinate.
+        */
+       public async createCoordinate(coordinate: RequestCoordinate): 
Promise<ResponseCoordinate> {
+               const crd = {
+                       ...coordinate,
+                       id: ++this.lastID,
+                       lastUpdated: new Date()
+               };
+               this.coordinates.push(crd);
+               return crd;
+       }
+
+       /**
+        * Deletes an existing coordinate.
+        *
+        * @param id Id of the coordinate to delete.
+        * @returns The deleted coordinate.
+        */
+       public async deleteCoordinate(id: number): Promise<ResponseCoordinate> {
+               const index = this.coordinates.findIndex(c => c.id === id);
+               if (index === -1) {
+                       throw new Error(`no such Coordinate: ${id}`);
+               }
+               return this.coordinates.splice(index, 1)[0];
+       }
 
        public async getASNs(): Promise<Array<ResponseASN>>;
        public async getASNs(id: number): Promise<ResponseASN>;
diff --git 
a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.html
 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.html
new file mode 100644
index 0000000000..633d413165
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.html
@@ -0,0 +1,44 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<mat-card>
+       <tp-loading *ngIf="!coordinate"></tp-loading>
+       <form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="coordinate">
+               <mat-card-content>
+                       <mat-form-field>
+                               <mat-label>Name</mat-label>
+                               <input matInput type="text" name="name" 
required [(ngModel)]="coordinate.name" />
+                       </mat-form-field>
+                       <mat-form-field>
+                               <mat-label>Latitude</mat-label>
+                               <input type="number" matInput min="-90" 
max="90" step="0.001" name="latitude" [(ngModel)]="coordinate.latitude" 
required/>
+                       </mat-form-field>
+                       <mat-form-field>
+                               <mat-label>Longitude</mat-label>
+                               <input type="number" matInput min="-180" 
max="180" step="0.001" name="longitude" [(ngModel)]="coordinate.longitude" 
required/>
+                       </mat-form-field>
+                       <mat-form-field *ngIf="!new">
+                               <mat-label>ID</mat-label>
+                               <input matInput type="text" name="id" disabled 
readonly [defaultValue]="coordinate.id" />
+                       </mat-form-field>
+                       <mat-form-field *ngIf="!new">
+                               <mat-label>Last Updated</mat-label>
+                               <input matInput type="text" name="lastUpdated" 
disabled readonly [defaultValue]="coordinate.lastUpdated" /> </mat-form-field>
+               </mat-card-content>
+               <mat-card-actions align="end">
+                       <button mat-raised-button type="button" *ngIf="!new" 
color="warn" (click)="deleteCoordinate()">Delete</button>
+                       <button mat-raised-button type="submit" 
color="primary">Save</button>
+               </mat-card-actions>
+       </form>
+</mat-card>
diff --git 
a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.scss
 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.scss
new file mode 100644
index 0000000000..f4746e2a22
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.scss
@@ -0,0 +1,25 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+mat-card {
+       margin: 1em auto;
+       width: 80%;
+       min-width: 350px;
+
+       mat-card-content {
+               display: grid;
+               grid-template-columns: 1fr;
+               grid-row-gap: 2em;
+               margin: 1em auto 50px;
+       }
+}
diff --git 
a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.spec.ts
new file mode 100644
index 0000000000..13d5ce17ea
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.spec.ts
@@ -0,0 +1,78 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatDialogModule } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { ReplaySubject } from "rxjs";
+
+import { APITestingModule } from "src/app/api/testing";
+import { NavigationService } from 
"src/app/shared/navigation/navigation.service";
+
+import { CoordinateDetailComponent } from "./coordinate-detail.component";
+
+describe("CoordinateDetailComponent", () => {
+       let component: CoordinateDetailComponent;
+       let fixture: ComponentFixture<CoordinateDetailComponent>;
+       let route: ActivatedRoute;
+       let paramMap: jasmine.Spy;
+
+       const navSvc = jasmine.createSpyObj([],{headerHidden: new 
ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [ CoordinateDetailComponent ],
+                       imports: [ APITestingModule, RouterTestingModule, 
MatDialogModule ],
+                       providers: [ { provide: NavigationService, useValue: 
navSvc } ]
+               })
+                       .compileComponents();
+
+               route = TestBed.inject(ActivatedRoute);
+               paramMap = spyOn(route.snapshot.paramMap, "get");
+               paramMap.and.returnValue(null);
+               fixture = TestBed.createComponent(CoordinateDetailComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+       });
+
+       it("should create", () => {
+               expect(component).toBeTruthy();
+               expect(paramMap).toHaveBeenCalled();
+       });
+
+       it("new coordinate", async () => {
+               paramMap.and.returnValue("new");
+
+               fixture = TestBed.createComponent(CoordinateDetailComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+               await fixture.whenStable();
+               expect(paramMap).toHaveBeenCalled();
+               expect(component.coordinate).not.toBeNull();
+               expect(component.coordinate.name).toBe("");
+               expect(component.new).toBeTrue();
+       });
+
+       it("existing coordinate", async () => {
+               paramMap.and.returnValue("1");
+
+               fixture = TestBed.createComponent(CoordinateDetailComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+               await fixture.whenStable();
+               expect(paramMap).toHaveBeenCalled();
+               expect(component.coordinate).not.toBeNull();
+               expect(component.coordinate.name).toBe("Coord1");
+               expect(component.new).toBeFalse();
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.ts
 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.ts
new file mode 100644
index 0000000000..fe18e46388
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/detail/coordinate-detail.component.ts
@@ -0,0 +1,108 @@
+/*
+* 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 { ResponseCoordinate } from "trafficops-types";
+
+import { CacheGroupService } from "src/app/api";
+import { DecisionDialogComponent } from 
"src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { NavigationService } from 
"src/app/shared/navigation/navigation.service";
+
+/**
+ * CoordinateDetailsComponent is the controller for the coordinate add/edit 
form.
+ */
+@Component({
+       selector: "tp-coordinates-detail",
+       styleUrls: ["./coordinate-detail.component.scss"],
+       templateUrl: "./coordinate-detail.component.html"
+})
+export class CoordinateDetailComponent implements OnInit {
+       public new = false;
+       public coordinate!: ResponseCoordinate;
+
+       constructor(private readonly route: ActivatedRoute, private readonly 
cacheGroupService: CacheGroupService,
+               private readonly location: Location, private readonly dialog: 
MatDialog, private readonly navSvc: NavigationService) { }
+
+       /**
+        * Angular lifecycle hook where data is initialized.
+        */
+       public async ngOnInit(): Promise<void> {
+               const ID = this.route.snapshot.paramMap.get("id");
+               if (ID === null) {
+                       console.error("missing required route parameter 'id'");
+                       return;
+               }
+
+               if (ID === "new") {
+                       this.navSvc.headerTitle.next("New Coordinate");
+                       this.new = true;
+                       this.coordinate = {
+                               id: -1,
+                               lastUpdated: new Date(),
+                               latitude: 0,
+                               longitude: 0,
+                               name: ""
+                       };
+                       return;
+               }
+               const numID = parseInt(ID, 10);
+               if (Number.isNaN(numID)) {
+                       console.error("route parameter 'id' was non-number:", 
ID);
+                       return;
+               }
+
+               this.coordinate = await 
this.cacheGroupService.getCoordinates(numID);
+               this.navSvc.headerTitle.next(`Coordinate: 
${this.coordinate.name}`);
+       }
+
+       /**
+        * Deletes the current coordinate.
+        */
+       public async deleteCoordinate(): Promise<void> {
+               if (this.new) {
+                       console.error("Unable to delete new coordinate");
+                       return;
+               }
+               const ref = this.dialog.open(DecisionDialogComponent, {
+                       data: {message: `Are you sure you want to delete 
coordinate ${this.coordinate.name} with id ${this.coordinate.id}`,
+                               title: "Confirm Delete"}
+               });
+               ref.afterClosed().subscribe(result => {
+                       if(result) {
+                               
this.cacheGroupService.deleteCoordinate(this.coordinate.id);
+                               this.location.back();
+                       }
+               });
+       }
+
+       /**
+        * Submits new/updated coordinate.
+        *
+        * @param e HTML form submission event.
+        */
+       public async submit(e: Event): Promise<void> {
+               e.preventDefault();
+               e.stopPropagation();
+               if(this.new) {
+                       this.coordinate = await 
this.cacheGroupService.createCoordinate(this.coordinate);
+                       this.new = false;
+               } else {
+                       this.coordinate = await 
this.cacheGroupService.updateCoordinate(this.coordinate);
+               }
+       }
+
+}
diff --git 
a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.html
 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.html
new file mode 100644
index 0000000000..c4c844e29a
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.html
@@ -0,0 +1,29 @@
+<!--
+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="table-page-content">
+       <div class="search-container">
+               <input type="search" name="fuzzControl" aria-label="Fuzzy 
Search Coordinates" autofocus inputmode="search" role="search" accesskey="/" 
placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()" />
+       </div>
+       <tp-generic-table
+               [data]="coordinates | async"
+               [cols]="columnDefs"
+               [fuzzySearch]="fuzzySubject"
+               context="coordinates"
+               [contextMenuItems]="contextMenuItems"
+               (contextMenuAction)="handleContextMenu($event)">
+       </tp-generic-table>
+</mat-card>
+
+<a class="page-fab" mat-fab title="Create a new Coordinate" 
*ngIf="auth.hasPermission('COORDINATE:CREATE')" 
routerLink="new"><mat-icon>add</mat-icon></a>
diff --git 
a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.scss
 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.scss
new file mode 100644
index 0000000000..a76ede4a23
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-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/cache-groups/coordinates/table/coordinates-table.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.spec.ts
new file mode 100644
index 0000000000..82ddec4918
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.spec.ts
@@ -0,0 +1,167 @@
+/*
+* 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, type MatDialogRef } from 
"@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { of } from "rxjs";
+
+import { CacheGroupService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { isAction } from 
"src/app/shared/generic-table/generic-table.component";
+
+import { CoordinatesTableComponent } from "./coordinates-table.component";
+
+const testCoordinate = {
+       id: 1,
+       lastUpdated: new Date(),
+       latitude: 0,
+       longitude: 0,
+       name: "TestQuest",
+};
+
+describe("CoordinatesTableComponent", () => {
+       let component: CoordinatesTableComponent;
+       let fixture: ComponentFixture<CoordinatesTableComponent>;
+
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [ CoordinatesTableComponent ],
+                       imports: [
+                               APITestingModule,
+                               RouterTestingModule,
+                               MatDialogModule
+                       ]
+               }).compileComponents();
+
+               fixture = TestBed.createComponent(CoordinatesTableComponent);
+               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 = "testquest";
+               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 = "testquest";
+               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("handles unrecognized contextmenu events", () => {
+               expect(async () => component.handleContextMenu({
+                       action: component.contextMenuItems[0].name,
+                       data: {id: 1, lastUpdated: new Date(), latitude: 0, 
longitude: 0, name: "Div"}
+               })).not.toThrow();
+       });
+
+       it("handles 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(CacheGroupService);
+               const spy = spyOn(api, "deleteCoordinate").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 crd = await api.createCoordinate({latitude: 0, longitude: 
0, name: "test"});
+               expect(openSpy).not.toHaveBeenCalled();
+               const asyncExpectation = 
expectAsync(component.handleContextMenu({action: "delete", data: 
crd})).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(testCoordinate)).toBe(String(testCoordinate.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(testCoordinate)).toBe(String(testCoordinate.id));
+               expect(item.queryParams).toBeUndefined();
+               expect(item.fragment).toBeUndefined();
+               expect(item.newTab).toBeTrue();
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.ts
 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.ts
new file mode 100644
index 0000000000..5106f2bb76
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/cache-groups/coordinates/table/coordinates-table.component.ts
@@ -0,0 +1,134 @@
+/*
+* 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 { BehaviorSubject } from "rxjs";
+import { ResponseCoordinate } from "trafficops-types";
+
+import { CacheGroupService } 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 } from 
"src/app/shared/generic-table/generic-table.component";
+import { NavigationService } from 
"src/app/shared/navigation/navigation.service";
+
+/**
+ * CoordinatesTableComponent is the controller for the "Coordinates" table.
+ */
+@Component({
+       selector: "tp-coordinates",
+       styleUrls: ["./coordinates-table.component.scss"],
+       templateUrl: "./coordinates-table.component.html"
+})
+export class CoordinatesTableComponent implements OnInit {
+       /** List of coordinates */
+       public coordinates: Promise<Array<ResponseCoordinate>>;
+
+       /** Definitions of the table's columns according to the ag-grid API */
+       public columnDefs = [
+               {
+                       field: "name",
+                       headerName: "Name"
+               },
+               {
+                       field: "latitude",
+                       headerName: "Latitude"
+               },
+               {
+                       field: "longitude",
+                       headerName: "Longitude"
+               },
+               {
+                       field: "id",
+                       headerName:" ID",
+                       hide: true
+               },
+               {
+                       field: "lastUpdated",
+                       headerName: "Last Updated"
+               }
+       ];
+
+       /** Definitions for the context menu items (which act on augmented 
coordinate data). */
+       public contextMenuItems: Array<ContextMenuItem<ResponseCoordinate>> = [
+               {
+                       href: (crd: ResponseCoordinate): string => `${crd.id}`,
+                       name: "Edit"
+               },
+               {
+                       href: (crd: ResponseCoordinate): string => `${crd.id}`,
+                       name: "Open in New Tab",
+                       newTab: true
+               },
+               {
+                       action: "delete",
+                       multiRow: false,
+                       name: "Delete"
+               },
+       ];
+
+       /** 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: CacheGroupService, private readonly 
dialog: MatDialog, public readonly auth: CurrentUserService) {
+               this.fuzzySubject = new BehaviorSubject<string>("");
+               this.coordinates = this.api.getCoordinates();
+               this.navSvc.headerTitle.next("Coordinates");
+       }
+
+       /** 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<ResponseCoordinate>): Promise<void> {
+               const data = evt.data as ResponseCoordinate;
+               switch(evt.action) {
+                       case "delete":
+                               const ref = 
this.dialog.open(DecisionDialogComponent, {
+                                       data: {message: `Are you sure you want 
to delete coordinate ${data.name} with id ${data.id}`, title: "Confirm Delete"}
+                               });
+                               ref.afterClosed().subscribe(result => {
+                                       if(result) {
+                                               
this.api.deleteCoordinate(data.id).then(async () => this.coordinates = 
this.api.getCoordinates());
+                                       }
+                               });
+                               break;
+               }
+       }
+}
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts 
b/experimental/traffic-portal/src/app/core/core.module.ts
index 32289cc806..3a37cac3fe 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -27,6 +27,8 @@ import { SharedModule } from "../shared/shared.module";
 import { AsnsTableComponent } from 
"./cache-groups/asns/table/asns-table.component";
 import { CacheGroupDetailsComponent } from 
"./cache-groups/cache-group-details/cache-group-details.component";
 import { CacheGroupTableComponent } from 
"./cache-groups/cache-group-table/cache-group-table.component";
+import { CoordinateDetailComponent } from 
"./cache-groups/coordinates/detail/coordinate-detail.component";
+import { CoordinatesTableComponent } from 
"./cache-groups/coordinates/table/coordinates-table.component";
 import { DivisionDetailComponent } from 
"./cache-groups/divisions/detail/division-detail.component";
 import { DivisionsTableComponent } from 
"./cache-groups/divisions/table/divisions-table.component";
 import { RegionDetailComponent } from 
"./cache-groups/regions/detail/region-detail.component";
@@ -78,6 +80,8 @@ export const ROUTES: Routes = [
        { component: TenantDetailsComponent, path: "tenants/:id"},
        { component: PhysLocDetailComponent, path: "phys-locs/:id" },
        { component: PhysLocTableComponent, path: "phys-locs" },
+       { component: CoordinateDetailComponent, path: "coordinates/:id" },
+       { component: CoordinatesTableComponent, path: "coordinates" },
        { component: TypesTableComponent, path: "types" },
        { component: TypeDetailComponent, path: "types/:id"},
 ].map(r => ({...r, canActivate: [AuthenticatedGuard]}));
@@ -115,8 +119,10 @@ export const ROUTES: Routes = [
                RegionsTableComponent,
                RegionDetailComponent,
                CacheGroupDetailsComponent,
+               CoordinatesTableComponent,
+               CoordinateDetailComponent,
                TypesTableComponent,
-               TypeDetailComponent
+               TypeDetailComponent,
        ],
        exports: [],
        imports: [
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 215f528323..4fa73801fc 100644
--- 
a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ 
b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -121,6 +121,9 @@ export class NavigationService {
                                children: [{
                                        href: "/core/cache-groups",
                                        name: "Cache Groups"
+                               }, {
+                                       href: "/core/coordinates",
+                                       name: "Coordinates"
                                }, {
                                        href: "/core/divisions",
                                        name: "Divisions"


Reply via email to