You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by rs...@apache.org on 2023/05/10 20:44:25 UTC
[trafficcontrol] branch master updated: Add Server Capabilities to TPv2 (#7474)
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 <oc...@apache.org>
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",