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

rshah 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 f02d2f0719 Add Server Capabilities to TPv2 (#7474)
f02d2f0719 is described below

commit f02d2f071941120a0213654514970a04d73fc1bd
Author: ocket8888 <[email protected]>
AuthorDate: Wed May 10 14:44:18 2023 -0600

    Add Server Capabilities to TPv2 (#7474)
    
    * generate table component
    
    * generate capability details component
    
    * Add routing and navigation
    
    * Add service methods for Capabilities
    
    * Create Capabilities table
    
    * Generate Capability details page
    
    * add a path for Capability creation
    
    * Add Capability form
    
    * Add table tests
    
    * Add form tests
    
    * Add e2e tests
    
    * Fix linting problems
    
    * fix browser object page structure
    
    * fix incorrect page reference
    
    * Move things around in the hopes that nightwatch will choose to work now
    
    * Fix import path
    
    * Add a missing 's' 🙄
    
    * Remove field that doesn't actually exist
    
    * move capabilities e2e page objects to more logically consistent location
---
 .../traffic-portal/nightwatch/globals/globals.ts   |  79 +++++---
 .../nightwatch/page_objects/common.ts              |   1 +
 .../servers/capabilities/capabilitiesTable.ts      |  45 +++++
 .../servers/capabilities/capabilityDetails.ts      |  36 ++++
 .../tests/servers/capabilities/detail.spec.ts      |  39 ++++
 .../tests/servers/capabilities/table.spec.ts       |  26 +++
 .../traffic-portal/src/app/api/server.service.ts   |  79 +++++++-
 .../src/app/api/testing/server.service.ts          | 108 +++++++++-
 .../traffic-portal/src/app/core/core.module.ts     |   7 +
 .../capabilities/capabilities.component.html       |  29 +++
 .../capabilities/capabilities.component.scss       |  13 ++
 .../capabilities/capabilities.component.spec.ts    | 223 +++++++++++++++++++++
 .../servers/capabilities/capabilities.component.ts | 170 ++++++++++++++++
 .../capability-details.component.html              |  33 +++
 .../capability-details.component.scss              |  18 ++
 .../capability-details.component.spec.ts           | 121 +++++++++++
 .../capability-details.component.ts                | 118 +++++++++++
 .../app/shared/navigation/navigation.service.ts    |   4 +
 18 files changed, 1117 insertions(+), 32 deletions(-)

diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts 
b/experimental/traffic-portal/nightwatch/globals/globals.ts
index e842afab71..bcc09ca6bf 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -44,41 +44,46 @@ import type { TenantDetailPageObject } from 
"nightwatch/page_objects/users/tenan
 import type { TenantsPageObject } from "nightwatch/page_objects/users/tenants";
 import type { UsersPageObject } from "nightwatch/page_objects/users/users";
 import {
-       CDN,
        GeoLimit,
        GeoProvider,
-       LoginRequest,
+       ProfileType,
        Protocol,
-       RequestDeliveryService,
-       ResponseCDN,
-       ResponseDeliveryService,
-       RequestTenant,
-       ResponseTenant,
-       TypeFromResponse,
-       RequestSteeringTarget,
-       ResponseASN,
-       RequestASN,
-       ResponseDivision,
-       RequestDivision,
-       ResponseRegion,
-       RequestRegion,
-       RequestCacheGroup,
-       ResponseCacheGroup,
-       ResponsePhysicalLocation,
-       RequestPhysicalLocation,
-       ResponseCoordinate,
-       RequestCoordinate,
-       RequestType,
-       ResponseStatus,
-       RequestStatus,
-       ResponseProfile,
-       RequestProfile,
-       ProfileType
+
+       type CDN,
+       type LoginRequest,
+       type RequestASN,
+       type RequestCacheGroup,
+       type RequestCoordinate,
+       type RequestDeliveryService,
+       type RequestDivision,
+       type RequestPhysicalLocation,
+       type RequestProfile,
+       type RequestRegion,
+       type RequestServerCapability,
+       type RequestStatus,
+       type RequestSteeringTarget,
+       type RequestTenant,
+       type RequestType,
+       type ResponseASN,
+       type ResponseCacheGroup,
+       type ResponseCDN,
+       type ResponseCoordinate,
+       type ResponseDeliveryService,
+       type ResponseDivision,
+       type ResponsePhysicalLocation,
+       type ResponseProfile,
+       type ResponseRegion,
+       type ResponseServerCapability,
+       type ResponseStatus,
+       type ResponseTenant,
+       type TypeFromResponse,
 } from "trafficops-types";
 
 import * as config from "../config.json";
-import {TypeDetailPageObject} from "../page_objects/types/typeDetail";
-import {TypesPageObject} from "../page_objects/types/typesTable";
+import type { CapabilitiesPageObject } from 
"../page_objects/servers/capabilities/capabilitiesTable";
+import type { CapabilityDetailsPageObject } from 
"../page_objects/servers/capabilities/capabilityDetails";
+import type { TypeDetailPageObject } from "../page_objects/types/typeDetail";
+import type { TypesPageObject } from "../page_objects/types/typesTable";
 
 declare module "nightwatch" {
        /**
@@ -112,6 +117,10 @@ declare module "nightwatch" {
                        profileDetail: () => ProfileDetailPageObject;
                };
                servers: {
+                       capabilities: {
+                               capabilityDetails: () => 
CapabilityDetailsPageObject;
+                               capabilitiesTable: () => CapabilitiesPageObject;
+                       };
                        physLocDetail: () => PhysLocDetailPageObject;
                        physLocTable: () => PhysLocTablePageObject;
                        servers: () => ServersPageObject;
@@ -150,6 +159,7 @@ declare module "nightwatch" {
  */
 export interface CreatedData {
        cacheGroup: ResponseCacheGroup;
+       capability: ResponseServerCapability;
        cdn: ResponseCDN;
        coordinate: ResponseCoordinate;
        division: ResponseDivision;
@@ -175,7 +185,7 @@ const globals = {
                        done();
                });
        },
-       apiVersion: "3.1",
+       apiVersion: "4.0",
        before: async (done: () => void): Promise<void> => {
                const apiUrl = 
`${globals.trafficOpsURL}/api/${globals.apiVersion}`;
                const client = axios.create({
@@ -424,6 +434,15 @@ const globals = {
                        console.log(`Successfully created Profile 
${respProfile.name}`);
                        data.profile = respProfile;
 
+                       const capability: RequestServerCapability = {
+                               name: `test${globals.uniqueString}`
+                       };
+                       url = `${apiUrl}/server_capabilities`;
+                       resp = await client.post(url, 
JSON.stringify(capability));
+                       const respCap: ResponseServerCapability = 
resp.data.response;
+                       console.log("Successfully created Capability:", 
respCap);
+                       data.capability = respCap;
+
                } catch(e) {
                        console.error("Request for", url, "failed:", (e as 
AxiosError).message);
                        throw e;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/common.ts 
b/experimental/traffic-portal/nightwatch/page_objects/common.ts
index 01534b4b51..c93a3292f5 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/common.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/common.ts
@@ -51,6 +51,7 @@ const commonPageObject = {
                                asns: "[aria-label='Navigate to ASNs']",
                                cacheGroups: "[aria-label='Navigate to Cache 
Groups']",
                                cacheGroupsContainer: "[aria-label='Toggle 
Cache Groups']",
+                               capabilities: "[aria-label='Navigate to 
Capabilities']",
                                changeLogs: "[aria-label='Navigate to Change 
Logs']",
                                configurationContainer: "[aria-label='Toggle 
Configuration']",
                                coordinates: "[aria-label='Navigate to 
Coordinates']",
diff --git 
a/experimental/traffic-portal/nightwatch/page_objects/servers/capabilities/capabilitiesTable.ts
 
b/experimental/traffic-portal/nightwatch/page_objects/servers/capabilities/capabilitiesTable.ts
new file mode 100644
index 0000000000..3bf94cc7de
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/page_objects/servers/capabilities/capabilitiesTable.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, EnhancedSectionInstance, NightwatchAPI } from 
"nightwatch";
+
+import { TABLE_COMMANDS, TableSectionCommands } from "../../../globals/tables";
+
+/**
+ * Defines the Capabilities table commands.
+ */
+type CapabilitiesTableCommands = TableSectionCommands;
+
+/**
+ * Defines the Page Object for the Capabilities page.
+ */
+export type CapabilitiesPageObject = EnhancedPageObject<{}, {}, 
EnhancedSectionInstance<CapabilitiesTableCommands>>;
+
+const capabilitiesPageObject = {
+       api: {} as NightwatchAPI,
+       sections: {
+               capabilitiesTable: {
+                       commands: {
+                               ...TABLE_COMMANDS
+                       },
+                       elements: {},
+                       selector: "mat-card"
+               }
+       },
+       url(): string {
+               return `${this.api.launchUrl}/core/capabilities`;
+       }
+};
+
+export default capabilitiesPageObject;
diff --git 
a/experimental/traffic-portal/nightwatch/page_objects/servers/capabilities/capabilityDetails.ts
 
b/experimental/traffic-portal/nightwatch/page_objects/servers/capabilities/capabilityDetails.ts
new file mode 100644
index 0000000000..a1dfb5d621
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/page_objects/servers/capabilities/capabilityDetails.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 type { EnhancedPageObject } from "nightwatch";
+
+/**
+ * Defines the PageObject for Capability Details.
+ */
+export type CapabilityDetailsPageObject = EnhancedPageObject<{}, typeof 
capabilityDetailsPageObject.elements>;
+
+const capabilityDetailsPageObject = {
+       elements: {
+               lastUpdated: {
+                       selector: "input[name='lastUpdated']"
+               },
+               name: {
+                       selector: "input[name='name']"
+               },
+               saveBtn: {
+                       selector: "button[type='submit']"
+               },
+       },
+};
+
+export default capabilityDetailsPageObject;
diff --git 
a/experimental/traffic-portal/nightwatch/tests/servers/capabilities/detail.spec.ts
 
b/experimental/traffic-portal/nightwatch/tests/servers/capabilities/detail.spec.ts
new file mode 100644
index 0000000000..cd1ae41540
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/tests/servers/capabilities/detail.spec.ts
@@ -0,0 +1,39 @@
+/*
+ * 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("Capability Details Spec", () => {
+       it("Test capability", () => {
+               const page = 
browser.page.servers.capabilities.capabilityDetails();
+               
browser.url(`${page.api.launchUrl}/core/capabilities/${browser.globals.testData.capability.name}`,
 res => {
+                       browser.assert.ok(res.status === 0);
+                       page.waitForElementVisible("form")
+                               .assert.enabled("@name")
+                               .assert.enabled("@saveBtn")
+                               .assert.not.enabled("@lastUpdated")
+                               .assert.valueEquals("@name", 
browser.globals.testData.capability.name);
+               });
+       });
+
+       it("New capability", () => {
+               const page = 
browser.page.servers.capabilities.capabilityDetails();
+               browser.url(`${page.api.launchUrl}/core/new-capability`, res => 
{
+                       browser.assert.ok(res.status === 0);
+                       page.waitForElementVisible("mat-card")
+                               .assert.enabled("@name")
+                               .assert.enabled("@saveBtn")
+                               .assert.not.elementPresent("@lastUpdated")
+                               .assert.valueEquals("@name", "");
+               });
+       });
+});
diff --git 
a/experimental/traffic-portal/nightwatch/tests/servers/capabilities/table.spec.ts
 
b/experimental/traffic-portal/nightwatch/tests/servers/capabilities/table.spec.ts
new file mode 100644
index 0000000000..245de9ef74
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/tests/servers/capabilities/table.spec.ts
@@ -0,0 +1,26 @@
+/*
+ * 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("Capabilities Spec", () => {
+       it("Loads elements", async () => {
+               await browser.page.common()
+                       .section.sidebar
+                       .navigateToNode("capabilities", ["serversContainer"]);
+               await browser.waitForElementPresent("input[name=fuzzControl]");
+               await 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/server.service.ts 
b/experimental/traffic-portal/src/app/api/server.service.ts
index 4a6ee2fb9c..7126930085 100644
--- a/experimental/traffic-portal/src/app/api/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/server.service.ts
@@ -14,7 +14,17 @@
 
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
-import type { RequestServer, RequestStatus, ResponseServer, ResponseStatus, 
Servercheck, ServerQueueResponse } from "trafficops-types";
+import type {
+       RequestServer,
+       RequestServerCapability,
+       RequestStatus,
+       ResponseServer,
+       ResponseServerCapability,
+       ResponseStatus,
+       ServerCapability,
+       Servercheck,
+       ServerQueueResponse,
+} from "trafficops-types";
 
 import { APIService } from "./base-api.service";
 
@@ -224,4 +234,71 @@ export class ServerService extends APIService {
                const id = typeof (statusId) === "number" ? statusId : 
statusId.id;
                return 
this.delete<ResponseStatus>(`statuses/${id}`).toPromise();
        }
+
+       /**
+        * Retrieves Server Capabilities from Traffic Ops.
+        *
+        * @returns All requested Capabilities.
+        */
+       public async getCapabilities(): 
Promise<Array<ResponseServerCapability>>;
+       /**
+        * Retrieves a specific Server Capability from Traffic Ops.
+        *
+        * @param name The name of the requested Server Capability.
+        * @returns The requested Capability.
+        * @throws {Error} if Traffic Ops responds with any number of 
Capabilities
+        * besides exactly one.
+        */
+       public async getCapabilities(name: string): 
Promise<ResponseServerCapability>;
+       /**
+        * Retrieves one or more Server Capabilities from Traffic Ops.
+        *
+        * @param name If given, only the Capability with this name will be
+        * returned.
+        * @returns Any and all requested Capabilities.
+        * @throws {Error} if a Capability is requested by name, but Traffic Ops
+        * responds with any number of Capabilities besides exactly one.
+        */
+       public async getCapabilities(name?: string): 
Promise<Array<ResponseServerCapability> | ResponseServerCapability> {
+               const path = "server_capabilities";
+               if (name) {
+                       const resp = await 
this.get<[ResponseServerCapability]>(path, undefined, {name}).toPromise();
+                       if (resp.length !== 1) {
+                               throw new Error(`Traffic Ops responded with 
${resp.length} Capabilities with name '${name}'`);
+                       }
+                       return resp[0];
+               }
+               return 
this.get<Array<ResponseServerCapability>>(path).toPromise();
+       }
+
+       /**
+        * Deletes a Server Capability.
+        *
+        * @param cap The Capability to be deleted, or just its name.
+        */
+       public async deleteCapability(cap: string | ServerCapability): 
Promise<void> {
+               const name = typeof(cap) === "string" ? cap : cap.name;
+               return this.delete("server_capabilities", undefined, 
{name}).toPromise();
+       }
+
+       /**
+        * Replaces an existing Server Capability definition with a new one.
+        *
+        * @param name The Capability's current Name.
+        * @param cap The Capability with desired modifications made.
+        * @returns The modified Capability.
+        */
+       public async updateCapability(name: string, cap: ServerCapability): 
Promise<ResponseServerCapability> {
+               return 
this.put<ResponseServerCapability>("server_capabilities", cap, 
{name}).toPromise();
+       }
+
+       /**
+        * Creates a new Server Capability.
+        *
+        * @param cap The new Capability.
+        * @returns The created Capability.
+        */
+       public async createCapability(cap: RequestServerCapability): 
Promise<ResponseServerCapability> {
+               return 
this.post<ResponseServerCapability>("server_capabilities", cap).toPromise();
+       }
 }
diff --git a/experimental/traffic-portal/src/app/api/testing/server.service.ts 
b/experimental/traffic-portal/src/app/api/testing/server.service.ts
index c316b0c9d3..d6b74f7f6f 100644
--- a/experimental/traffic-portal/src/app/api/testing/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/server.service.ts
@@ -13,7 +13,16 @@
 */
 
 import { Injectable } from "@angular/core";
-import type { RequestServer, RequestStatus, ResponseServer, ResponseStatus, 
Servercheck } from "trafficops-types";
+import type {
+       RequestServer,
+       RequestServerCapability,
+       RequestStatus,
+       ResponseServer,
+       ResponseServerCapability,
+       ResponseStatus,
+       ServerCapability,
+       Servercheck
+} from "trafficops-types";
 
 import { CDNService, PhysicalLocationService, ProfileService, TypeService } 
from "..";
 
@@ -86,6 +95,8 @@ export class ServerService {
                }
        ];
 
+       private readonly capabilities = new Array<ResponseServerCapability>();
+
        private idCounter = 1;
        private statusIdCounter = 6;
 
@@ -354,4 +365,99 @@ export class ServerService {
                }
                return this.statuses.splice(idx, 1)[0];
        }
+
+       /**
+        * Retrieves Server Capabilities from Traffic Ops.
+        *
+        * @returns All requested Capabilities.
+        */
+       public async getCapabilities(): 
Promise<Array<ResponseServerCapability>>;
+       /**
+        * Retrieves a specific Server Capability from Traffic Ops.
+        *
+        * @param name The name of the requested Server Capability.
+        * @returns The requested Capability.
+        * @throws {Error} if Traffic Ops responds with any number of 
Capabilities
+        * besides exactly one.
+        */
+       public async getCapabilities(name: string): 
Promise<ResponseServerCapability>;
+       /**
+        * Retrieves one or more Server Capabilities from Traffic Ops.
+        *
+        * @param name If given, only the Capability with this name will be
+        * returned.
+        * @returns Any and all requested Capabilities.
+        * @throws {Error} if a Capability is requested by name, but Traffic Ops
+        * responds with any number of Capabilities besides exactly one.
+        */
+       public async getCapabilities(name?: string): 
Promise<Array<ResponseServerCapability> | ResponseServerCapability> {
+               if (name) {
+                       const cap = this.capabilities.find(c => c.name === 
name);
+                       if (!cap) {
+                               throw new Error(`no such Capability with name 
'${name}'`);
+                       }
+                       return cap;
+               }
+               return this.capabilities;
+       }
+
+       /**
+        * Deletes a Server Capability.
+        *
+        * @param cap The Capability to be deleted, or just its name.
+        */
+       public async deleteCapability(cap: string | ServerCapability): 
Promise<void> {
+               const name = typeof(cap) === "string" ? cap : cap.name;
+               const idx = this.capabilities.findIndex(c => c.name === name);
+               if (idx < 0) {
+                       throw new Error(`no such Capability with name 
'${name}'`);
+               }
+               this.capabilities.splice(idx, 1);
+       }
+
+       /**
+        * Replaces an existing Server Capability definition with a new one.
+        *
+        * @param name The Capability's current Name.
+        * @param cap The Capability with desired modifications made.
+        * @returns The modified Capability.
+        */
+       public async updateCapability(name: string, cap: ServerCapability): 
Promise<ResponseServerCapability> {
+               const idx = this.capabilities.findIndex(c => c.name === name);
+               if (idx < 0) {
+                       throw new Error(`no such Capability with name 
'${name}'`);
+               }
+
+               if (this.capabilities.some(c => c.name === cap.name)) {
+                       throw new Error(`Capability with name '${cap.name}' 
already exists`);
+               }
+
+               const updated = {
+                       ...cap,
+                       lastUpdated: new Date(),
+               };
+
+               this.capabilities[idx] = updated;
+               return updated;
+       }
+
+       /**
+        * Creates a new Server Capability.
+        *
+        * @param cap The new Capability.
+        * @returns The created Capability.
+        */
+       public async createCapability(cap: RequestServerCapability): 
Promise<ResponseServerCapability> {
+               if (this.capabilities.some(c => c.name === cap.name)) {
+                       throw new Error(`Capability with name '${cap.name}' 
already exists`);
+               }
+
+               const created = {
+                       ...cap,
+                       lastUpdated: new Date()
+               };
+
+               this.capabilities.push(created);
+               return created;
+       }
 }
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts 
b/experimental/traffic-portal/src/app/core/core.module.ts
index 9d6f1ecf1a..72b657ab10 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -50,6 +50,8 @@ import { NewDeliveryServiceComponent } from 
"./deliveryservice/new-delivery-serv
 import { ISOGenerationFormComponent } from 
"./misc/isogeneration-form/isogeneration-form.component";
 import { ProfileDetailComponent } from 
"./profiles/profile-detail/profile-detail.component";
 import { ProfileTableComponent } from 
"./profiles/profile-table/profile-table.component";
+import { CapabilitiesComponent } from 
"./servers/capabilities/capabilities.component";
+import { CapabilityDetailsComponent } from 
"./servers/capabilities/capability-details/capability-details.component";
 import { PhysLocDetailComponent } from 
"./servers/phys-loc/detail/phys-loc-detail.component";
 import { PhysLocTableComponent } from 
"./servers/phys-loc/table/phys-loc-table.component";
 import { ServerDetailsComponent } from 
"./servers/server-details/server-details.component";
@@ -78,6 +80,9 @@ export const ROUTES: Routes = [
        { component: CDNDetailComponent, path: "cdns/:id" },
        { component: ServersTableComponent, path: "servers" },
        { component: ServerDetailsComponent, path: "servers/:id" },
+       { component: CapabilitiesComponent, path: "capabilities" },
+       { component: CapabilityDetailsComponent, path: "capabilities/:name" },
+       { component: CapabilityDetailsComponent, path: "new-capability" },
        { component: DeliveryserviceComponent, path: "deliveryservice/:id" },
        { component: InvalidationJobsComponent, path: 
"deliveryservice/:id/invalidation-jobs" },
        { component: CurrentuserComponent, path: "me" },
@@ -144,6 +149,8 @@ export const ROUTES: Routes = [
                ProfileTableComponent,
                CDNDetailComponent,
                ProfileDetailComponent,
+               CapabilitiesComponent,
+               CapabilityDetailsComponent,
        ],
        exports: [],
        imports: [
diff --git 
a/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.html
 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.html
new file mode 100644
index 0000000000..6322cedf25
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.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 Server Capabilities" autofocus inputmode="search" role="search" 
accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" 
(input)="updateURL()" />
+       </div>
+       <tp-generic-table
+               [data]="capabilities | async"
+               [cols]="columnDefs"
+               [fuzzySearch]="fuzzySubject"
+               context="capabilities"
+               [contextMenuItems]="contextMenuItems"
+               (contextMenuAction)="handleContextMenu($event)">
+       </tp-generic-table>
+</mat-card>
+
+<a class="page-fab" mat-fab title="Create a new Capability" 
*ngIf="auth.hasPermission('SERVER-CAPABILITY:CREATE')" 
href="/core/new-capability"><mat-icon>add</mat-icon></a>
diff --git 
a/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.scss
 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.scss
new file mode 100644
index 0000000000..ebe77042d3
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.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/servers/capabilities/capabilities.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.spec.ts
new file mode 100644
index 0000000000..7ea3988fa7
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.spec.ts
@@ -0,0 +1,223 @@
+/*
+* 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, fakeAsync, 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 { ServerService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { isAction } from 
"src/app/shared/generic-table/generic-table.component";
+
+import { CapabilitiesComponent } from "./capabilities.component";
+
+describe("CapabilitiesComponent", () => {
+       let component: CapabilitiesComponent;
+       let fixture: ComponentFixture<CapabilitiesComponent>;
+
+       const capability = {
+               lastUpdated: new Date(),
+               name: "test"
+       };
+
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [ CapabilitiesComponent ],
+                       imports: [ APITestingModule, RouterTestingModule, 
MatDialogModule ],
+               }).compileComponents();
+
+               fixture = TestBed.createComponent(CapabilitiesComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+       });
+
+       it("should create", () => {
+               expect(component).toBeTruthy();
+       });
+
+       it("builds a 'View Details' link", () => {
+               const item = component.contextMenuItems.find(i => i.name === 
"View Details");
+               if (!item) {
+                       return fail("missing 'View Details' context menu item");
+               }
+
+               if (isAction(item)) {
+                       return fail("incorrect type for 'View Details' menu 
item");
+               }
+
+               if (typeof(item.href) !== "function") {
+                       return fail("link should be built from data, not 
static");
+               }
+
+               expect(item.href(capability)).toBe(capability.name);
+       });
+
+       it("builds an 'Open in New Tab' link", () => {
+               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("incorrect type for 'Open in New Tab' menu 
item");
+               }
+
+               expect(item.newTab).toBe(true);
+
+               if (typeof(item.href) !== "function") {
+                       return fail("link should be built from data, not 
static");
+               }
+
+               expect(item.href(capability)).toBe(capability.name);
+       });
+
+       it("has context menu items that aren't implemented yet", () => {
+               let item = component.contextMenuItems.find(i => i.name === 
"View Servers");
+               if (!item) {
+                       return fail("missing 'View Servers' context menu item");
+               }
+               if (!isAction(item)) {
+                       return fail("incorrect type for 'View Servers' menu 
item");
+               }
+               if (!item.multiRow) {
+                       return fail("'View Servers' should be a multi-row 
action");
+               }
+               if (!item.disabled || !item.disabled([capability])) {
+                       return fail("'View Servers' should be disabled");
+               }
+
+               item = component.contextMenuItems.find(i => i.name === "Add to 
Server(s)");
+               if (!item) {
+                       return fail("missing 'Add to Server(s)' context menu 
item");
+               }
+               if (!isAction(item)) {
+                       return fail("incorrect type for 'Add to Server(s)' menu 
item");
+               }
+               if (!item.multiRow) {
+                       return fail("'Add to Server(s)' should be a multi-row 
action");
+               }
+               if (!item.disabled || !item.disabled([capability])) {
+                       return fail("'Add to Server(s)' should be disabled");
+               }
+
+               item = component.contextMenuItems.find(i => i.name === "View 
Delivery Services");
+               if (!item) {
+                       return fail("missing 'View Delivery Services' context 
menu item");
+               }
+               if (!isAction(item)) {
+                       return fail("incorrect type for 'View Delivery 
Services' menu item");
+               }
+               if (!item.multiRow) {
+                       return fail("'View Delivery Services' should be a 
multi-row action");
+               }
+               if (!item.disabled || !item.disabled([capability])) {
+                       return fail("'View Delivery Services' should be 
disabled");
+               }
+
+               item = component.contextMenuItems.find(i => i.name === "Add to 
Delivery Service(s)");
+               if (!item) {
+                       return fail("missing 'Add to Delivery Service(s)' 
context menu item");
+               }
+               if (!isAction(item)) {
+                       return fail("incorrect type for 'Add to Delivery 
Service(s)' menu item");
+               }
+               if (!item.multiRow) {
+                       return fail("'Add to Delivery Service(s)' should be a 
multi-row action");
+               }
+               if (!item.disabled || !item.disabled([capability])) {
+                       return fail("'Add to Delivery Service(s)' should be 
disabled");
+               }
+       });
+
+       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", async (): Promise<void> 
=> {
+               expect(async () => component.handleContextMenu({
+                       action: component.contextMenuItems[0].name,
+                       data: (await component.capabilities)[0]
+               })).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(ServerService);
+               const spy = spyOn(api, "deleteCapability").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 cap = await api.createCapability(capability);
+               expect(openSpy).not.toHaveBeenCalled();
+               const asyncExpectation = 
expectAsync(component.handleContextMenu({action: "delete", data: 
cap})).toBeResolvedTo(undefined);
+               tick();
+
+               expect(openSpy).toHaveBeenCalled();
+               tick();
+
+               expect(spy).toHaveBeenCalled();
+
+               await asyncExpectation;
+       }));
+
+       it("throws an error if improperly asked to delete more than one 
Capability", async () => {
+               await expectAsync(component.handleContextMenu({action: 
"delete", data: []})).toBeRejected();
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.ts
 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.ts
new file mode 100644
index 0000000000..6f1f5ddd9a
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capabilities.component.ts
@@ -0,0 +1,170 @@
+/*
+* 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 type { ResponseServerCapability } from "trafficops-types";
+
+import { ServerService } 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";
+
+/**
+ * Controller for the table that displays Server Capabilities.
+ */
+@Component({
+       selector: "tp-capabilities",
+       styleUrls: ["./capabilities.component.scss"],
+       templateUrl: "./capabilities.component.html",
+})
+export class CapabilitiesComponent implements OnInit {
+       /** All the physical locations which should appear in the table. */
+       public capabilities: Promise<Array<ResponseServerCapability>>;
+
+       /** Definitions of the table's columns according to the ag-grid API */
+       public columnDefs = [
+               {
+                       field: "name",
+                       headerName: "Name"
+               },
+               {
+                       field: "lastUpdated",
+                       headerName: "Last Updated",
+                       hide: true
+               },
+       ];
+
+       /** Definitions for the context menu items (which act on augmented 
cache-group data). */
+       public contextMenuItems: 
Array<ContextMenuItem<ResponseServerCapability>> = [
+               {
+                       href: (c: ResponseServerCapability): string => c.name,
+                       name: "View Details",
+               },
+               {
+                       href: (c: ResponseServerCapability): string => c.name,
+                       name: "Open in New Tab",
+                       newTab: true,
+               },
+               {
+                       action: "delete",
+                       multiRow: false,
+                       name: "Delete"
+               },
+               {
+                       action: "servers",
+                       // TODO: implement
+                       disabled: (): true => true,
+                       multiRow: true,
+                       name: "View Servers",
+               },
+               {
+                       action: "addServers",
+                       // TODO: implement
+                       disabled: (): true => true,
+                       multiRow: true,
+                       name: "Add to Server(s)",
+               },
+               {
+                       action: "dses",
+                       // TODO: implement
+                       disabled: (): true => true,
+                       multiRow: true,
+                       name: "View Delivery Services",
+               },
+               {
+                       action: "addDSes",
+                       // TODO: implement
+                       disabled: (): true => true,
+                       multiRow: true,
+                       name: "Add to Delivery Service(s)",
+               },
+       ];
+
+       /** 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});
+
+       /**
+        * Constructs the component with its required injections.
+        *
+        * @param api The Servers API which is used to provide row data.
+        * @param route A reference to the route of this view which is used to 
set the fuzzy search box text from the 'search' query parameter.
+        * @param router Angular router
+        * @param navSvc Manages the header
+        * @param dialog Dialog manager
+        */
+       constructor(
+               private readonly api: ServerService,
+               private readonly route: ActivatedRoute,
+               private readonly navSvc: NavigationService,
+               private readonly dialog: MatDialog,
+               public readonly auth: CurrentUserService
+       ) {
+               this.fuzzySubject = new BehaviorSubject<string>("");
+               this.capabilities = this.api.getCapabilities();
+               this.navSvc.headerTitle.next("Capabilities");
+       }
+
+       /** 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<ResponseServerCapability>): Promise<void> {
+               const data = evt.data;
+               switch (evt.action) {
+                       case "delete":
+                               if (Array.isArray(data)) {
+                                       throw new Error("cannot delete multiple 
Capabilities");
+                               }
+                               const ref = 
this.dialog.open(DecisionDialogComponent, {
+                                       data: {
+                                               message: `Are you sure you want 
to delete the '${data.name}' Capability?`,
+                                               title: "Confirm Delete"
+                                       }
+                               });
+                               const result = await 
ref.afterClosed().toPromise();
+                               if (result) {
+                                       
this.api.deleteCapability(data).then(async () => this.capabilities = 
this.api.getCapabilities());
+                               }
+                               break;
+                       default:
+                               console.warn("unrecognized context menu 
action:", evt.action);
+               }
+       }
+}
diff --git 
a/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.html
 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.html
new file mode 100644
index 0000000000..4a9fe4709d
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.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>
+       <tp-loading *ngIf="!capability"></tp-loading>
+       <form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="capability">
+               <mat-card-content>
+                       <mat-form-field>
+                               <mat-label>Name</mat-label>
+                               <input matInput type="text" name="name" 
required [(ngModel)]="capability.name" />
+                       </mat-form-field>
+                       <mat-form-field *ngIf="!new">
+                               <mat-label>Last Updated</mat-label>
+                               <input matInput type="text" name="lastUpdated" 
disabled readonly [defaultValue]="capability.lastUpdated" />
+                       </mat-form-field>
+               </mat-card-content>
+               <mat-card-actions align="end">
+                       <button mat-raised-button type="button" *ngIf="!new" 
color="warn" (click)="deleteCapability()">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/servers/capabilities/capability-details/capability-details.component.scss
 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.scss
new file mode 100644
index 0000000000..295a5f30c9
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.scss
@@ -0,0 +1,18 @@
+/*
+* 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-content {
+       display: grid;
+       grid-template-columns: 1fr;
+}
diff --git 
a/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.spec.ts
new file mode 100644
index 0000000000..1979704a9a
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.spec.ts
@@ -0,0 +1,121 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { ComponentFixture, TestBed } 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 { ServerService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+
+import { CapabilityDetailsComponent } from "./capability-details.component";
+
+describe("CapabilityDetailsComponent", () => {
+       let component: CapabilityDetailsComponent;
+       let fixture: ComponentFixture<CapabilityDetailsComponent>;
+       let paramMap: jasmine.Spy;
+       let route: ActivatedRoute;
+       const name = "test";
+
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [ CapabilityDetailsComponent ],
+                       imports: [ APITestingModule, RouterTestingModule, 
MatDialogModule ],
+               }).compileComponents();
+
+               route = TestBed.inject(ActivatedRoute);
+               paramMap = spyOn(route.snapshot.paramMap, "get");
+               paramMap.and.returnValue(name);
+               fixture = TestBed.createComponent(CapabilityDetailsComponent);
+               component = fixture.componentInstance;
+               component.capability = {...await 
TestBed.inject(ServerService).createCapability({name})};
+               fixture.detectChanges();
+       });
+
+       it("should create", () => {
+               expect(component).toBeTruthy();
+       });
+
+       it("sets up the form for a new Capability", async () => {
+               paramMap.and.returnValue(null);
+
+               fixture = TestBed.createComponent(CapabilityDetailsComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+               await fixture.whenStable();
+               expect(paramMap).toHaveBeenCalled();
+               expect(component.capability).not.toBeNull();
+               expect(component.capability.name).toBe("");
+               expect(component.new).toBeTrue();
+       });
+
+       it("existing Capability", async () => {
+               expect(paramMap).toHaveBeenCalled();
+               expect(component.capability).not.toBeNull();
+               expect(component.capability.name).toBe(name);
+               expect(component.new).toBeFalse();
+       });
+
+       it("opens a dialog for Capability deletion", async () => {
+               const api = TestBed.inject(ServerService);
+               const spy = spyOn(api, "deleteCapability").and.callThrough();
+               expect(spy).not.toHaveBeenCalled();
+
+               const dialogService = TestBed.inject(MatDialog);
+               const openSpy = spyOn(dialogService, "open").and.returnValue({
+                       afterClosed: () => of(true)
+               } as MatDialogRef<unknown>);
+
+               expect(openSpy).not.toHaveBeenCalled();
+
+               const asyncExpectation = 
expectAsync(component.deleteCapability()).toBeResolvedTo(undefined);
+
+               expect(openSpy).toHaveBeenCalled();
+               expect(spy).toHaveBeenCalled();
+
+               await asyncExpectation;
+       });
+
+       it("submits requests to create new Capabilities", async () => {
+               const api = TestBed.inject(ServerService);
+               const spy = spyOn(api, "createCapability").and.callThrough();
+               paramMap.and.returnValue(null);
+
+               fixture = TestBed.createComponent(CapabilityDetailsComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+               await fixture.whenStable();
+
+               expect(spy).not.toHaveBeenCalled();
+               await expectAsync(component.submit(new 
Event("submit"))).toBeResolvedTo(undefined);
+               expect(spy).toHaveBeenCalled();
+               expect(component.new).toBeFalse();
+       });
+
+       it("submits requests to update Capabilities", async () => {
+               const api = TestBed.inject(ServerService);
+               const spy = spyOn(api, "updateCapability").and.callThrough();
+               expect(spy).not.toHaveBeenCalled();
+
+               component.capability = {
+                       ...component.capability,
+                       name: `${component.capability.name}quest`
+               };
+
+               await expectAsync(component.submit(new 
Event("submit"))).toBeResolvedTo(undefined);
+               expect(spy).toHaveBeenCalled();
+               expect(component.new).toBeFalse();
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.ts
 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.ts
new file mode 100644
index 0000000000..54edbd65e0
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/servers/capabilities/capability-details/capability-details.component.ts
@@ -0,0 +1,118 @@
+/*
+* 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, Router } from "@angular/router";
+import { ResponseServerCapability } from "trafficops-types";
+
+import { ServerService } 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";
+
+/**
+ * Controller for the form for creating and editing Server Capabilities.
+ */
+@Component({
+       selector: "tp-capability-details",
+       styleUrls: ["./capability-details.component.scss"],
+       templateUrl: "./capability-details.component.html",
+})
+export class CapabilityDetailsComponent implements OnInit {
+       public new = false;
+
+       public capability!: ResponseServerCapability;
+
+       /**
+        * This caches the original name of the Capability, so that updates can 
be
+        * made.
+        */
+       private name = "";
+
+       constructor(
+               private readonly route: ActivatedRoute,
+               private readonly router: Router,
+               private readonly dialog: MatDialog,
+               private readonly navSvc: NavigationService,
+               private readonly api: ServerService,
+               private readonly location: Location
+       ) {}
+
+       /**
+        * Angular lifecycle hook.
+        */
+       public async ngOnInit(): Promise<void> {
+               const name = this.route.snapshot.paramMap.get("name");
+               if (name === null) {
+                       this.setHeader("New Capability");
+                       this.new = true;
+                       this.capability = {
+                               lastUpdated: new Date(),
+                               name: "",
+                       };
+                       return;
+               }
+
+               this.capability = await this.api.getCapabilities(name);
+               this.name = this.capability.name;
+               this.navSvc.headerTitle.next(`Capability: 
${this.capability.name}`);
+       }
+
+       /**
+        * Sets the value of the header text, and caches the Capability's 
initial
+        * name.
+        *
+        * @param name The name of the current Capability (before editing).
+        */
+       private setHeader(name: string): void {
+               this.name = name;
+               this.navSvc.headerTitle.next(`Capability: ${name}`);
+       }
+
+       /**
+        * Deletes the current physLocation.
+        */
+       public async deleteCapability(): Promise<void> {
+               const ref = this.dialog.open(DecisionDialogComponent, {
+                       data: {
+                               message: `Are you sure you want to delete the 
Capability '${this.capability.name}'?`,
+                               title: "Confirm Delete"
+                       }
+               });
+               ref.afterClosed().subscribe(result => {
+                       if(result) {
+                               this.api.deleteCapability(this.capability);
+                               this.location.back();
+                       }
+               });
+       }
+
+       /**
+        * Submits new/updated physLocation.
+        *
+        * @param e HTML click event.
+        */
+       public async submit(e: Event): Promise<void> {
+               e.preventDefault();
+               e.stopPropagation();
+               if(this.new) {
+                       this.capability = await 
this.api.createCapability(this.capability);
+                       this.new = false;
+               } else {
+                       this.capability = await 
this.api.updateCapability(this.name, this.capability);
+               }
+               
this.router.navigate([`/core/capabilities/${this.capability.name}`], 
{replaceUrl: true});
+               this.setHeader(this.name);
+       }
+}
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 84d3876a43..52799d9c51 100644
--- 
a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ 
b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -122,6 +122,10 @@ export class NavigationService {
                                href: "/core/statuses",
                                name: "Statuses"
                        },
+                       {
+                               href: "/core/capabilities",
+                               name: "Capabilities",
+                       },
                        {
                                children: [{
                                        href: "/core/cache-groups",

Reply via email to