You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by sh...@apache.org on 2023/01/23 21:41:16 UTC
[trafficcontrol] branch master updated: TPv2 Cache Groups table/details pages (#7292)
This is an automated email from the ASF dual-hosted git repository.
shamrick pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git
The following commit(s) were added to refs/heads/master by this push:
new ceaa6c257b TPv2 Cache Groups table/details pages (#7292)
ceaa6c257b is described below
commit ceaa6c257b235ed5faabb87c067f9c239df333db
Author: ocket8888 <oc...@apache.org>
AuthorDate: Mon Jan 23 14:41:10 2023 -0700
TPv2 Cache Groups table/details pages (#7292)
* Move some imports around
* Add a default typing to HTTP methods
That default being that no `response` is present
* Switch to using trafficops-types Cache Group types in the API service
* Add API methods for creating, updating, and deleting Cache Groups
Also updated trafficops-types to 3.1.3 to make that possible.
* Add new methods to testing module, convert to trafficops-types
* Add stub component for Cache Group details page
* Update trafficops-types for a bugfix
* Update code depending on the Cache Group service to use trafficops-types
* add localization methods column, value formatters for ID'd fields
* add skeleton component form
* Fix label bindings
* Add routing for CG details page
* Add latitude/longitude inputs, add responsive styling
* Disable unused Context Menu items
* Added a dialog type for choosing an item from a collection
* Disable 'view servers' since that work should be done separately
* Fix broken links to division/region/cache-group creation
* Fix shared service instances not actually shared
* Add queue method to Cache Group API service
* Fix linting problems in the cache group test service
* Update CDN service to use trafficops-types
* Remove now-unused CacheGroup model
* Add a button for creating new Cache Groups
* Fix an aria label
* Fix testing server service not provided in API testing module
* Fix missing test injections
* Fix tests for time utilities using timezone-dependent methods
This makes them fail if run at certain times of day
* Add dialog tests
* Add "Open in new tab" context menu item
* Add test coverage for the Cache Groups table
* Add test coverage for the Cache Group details page
* Update types service to use trafficops-types (partially)
* Add missing Cache Group form controls
* Fix Cache Group's own fallbacks allowed as parent(s)
* Add e2e tests
* Fix incorrect import path
* Fix invalid file naming
???????
* Fix tests not re-runnable
* Remove duplicated attribute
* Fix race condition in unit tests
* Fix deleted Cache Groups still in table
* Fix header and URL not updating on Cache Group update/creation
* Fix typo in e2e tests
* Fix unit tests
* Add explicit button type
* Fix attempting to load page before request completes
---
.../traffic-portal/nightwatch/globals/globals.ts | 52 ++-
.../nightwatch/page_objects/cacheGroupDetails.ts | 51 +++
.../nightwatch/page_objects/cacheGroupsTable.ts | 46 +++
.../cacheGroups/{divisions => }/detail.spec.ts | 18 +-
.../tests/cacheGroups/divisions/detail.spec.ts | 4 +-
.../tests/cacheGroups/regions/detail.spec.ts | 4 +-
.../nightwatch/tests/cacheGroups/table.spec.ts | 24 ++
experimental/traffic-portal/package-lock.json | 14 +-
experimental/traffic-portal/package.json | 2 +-
.../traffic-portal/src/app/api/base-api.service.ts | 14 +-
.../src/app/api/cache-group.service.ts | 240 +++++++++++---
.../traffic-portal/src/app/api/cdn.service.ts | 36 +--
.../traffic-portal/src/app/api/server.service.ts | 2 +-
.../src/app/api/testing/cache-group.service.ts | 349 +++++++++++++++++++--
.../src/app/api/testing/cdn.service.ts | 48 ++-
.../traffic-portal/src/app/api/testing/index.ts | 3 +-
.../src/app/api/testing/server.service.ts | 2 +-
.../traffic-portal/src/app/api/type.service.ts | 5 +-
.../cache-group-details.component.html | 87 +++++
.../cache-group-details.component.scss | 64 ++++
.../cache-group-details.component.spec.ts | 299 ++++++++++++++++++
.../cache-group-details.component.ts | 239 ++++++++++++++
.../cache-group-table.component.html | 4 +-
.../cache-group-table.component.spec.ts | 329 ++++++++++++++++++-
.../cache-group-table.component.ts | 172 ++++++++--
.../traffic-portal/src/app/core/core.module.ts | 52 +--
.../src/app/core/dashboard/dashboard.component.ts | 1 -
.../new-delivery-service.component.ts | 8 +-
.../server-details/server-details.component.ts | 9 +-
.../app/core/users/tenants/tenants.component.html | 2 +-
.../traffic-portal/src/app/models/cache-groups.ts | 63 ----
.../traffic-portal/src/app/models/index.ts | 2 -
.../traffic-portal/src/app/models/models.spec.ts | 12 -
.../src/app/shared/alert/alert.component.scss | 34 --
.../src/app/shared/alert/alert.service.ts | 6 +-
.../app/shared/currentUser/current-user.service.ts | 4 +-
.../collection-choice-dialog.component.html | 31 ++
.../collection-choice-dialog.component.scss} | 18 +-
.../collection-choice-dialog.component.spec.ts | 58 ++++
.../collection-choice-dialog.component.ts | 63 ++++
.../app/shared/interceptor/alerts.interceptor.ts | 2 +-
.../app/shared/interceptor/error.interceptor.ts | 2 +-
.../traffic-portal/src/app/shared/shared.module.ts | 8 +-
.../src/app/shared/tp-header/tp-header.service.ts | 3 +-
.../traffic-portal/src/app/utils/time.spec.ts | 18 +-
45 files changed, 2120 insertions(+), 384 deletions(-)
diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts
index bed3a84b8c..f140802bcd 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -14,25 +14,29 @@
*/
import * as https from "https";
-import axios, {AxiosError} from "axios";
-import {NightwatchBrowser} from "nightwatch";
+import axios, { AxiosError } from "axios";
+import { NightwatchBrowser } from "nightwatch";
+import type { CacheGroupDetailPageObject } from "nightwatch/page_objects/cacheGroupDetails";
+import type { CacheGroupsPageObject } from "nightwatch/page_objects/cacheGroupsTable";
import type { ChangeLogsPageObject } from "nightwatch/page_objects/changeLogs";
-import type {CommonPageObject} from "nightwatch/page_objects/common";
-import type {DeliveryServiceCardPageObject} from "nightwatch/page_objects/deliveryServiceCard";
-import type {DeliveryServiceDetailPageObject} from "nightwatch/page_objects/deliveryServiceDetail";
-import type {DeliveryServiceInvalidPageObject} from "nightwatch/page_objects/deliveryServiceInvalidationJobs";
+import type { CommonPageObject } from "nightwatch/page_objects/common";
+import type { DeliveryServiceCardPageObject } from "nightwatch/page_objects/deliveryServiceCard";
+import type { DeliveryServiceDetailPageObject } from "nightwatch/page_objects/deliveryServiceDetail";
+import type { DeliveryServiceInvalidPageObject } from "nightwatch/page_objects/deliveryServiceInvalidationJobs";
import type { DivisionDetailPageObject } from "nightwatch/page_objects/divisionDetail";
import type { DivisionsPageObject } from "nightwatch/page_objects/divisionsTable";
-import type {LoginPageObject} from "nightwatch/page_objects/login";
+import type { LoginPageObject } from "nightwatch/page_objects/login";
import type { RegionDetailPageObject } from "nightwatch/page_objects/regionDetail";
import type { RegionsPageObject } from "nightwatch/page_objects/regionsTable";
-import type {ServersPageObject} from "nightwatch/page_objects/servers";
+import type { ServersPageObject } from "nightwatch/page_objects/servers";
import type { TenantDetailPageObject } from "nightwatch/page_objects/tenantDetail";
import type { TenantsPageObject } from "nightwatch/page_objects/tenants";
-import type {UsersPageObject} from "nightwatch/page_objects/users";
+import type { UsersPageObject } from "nightwatch/page_objects/users";
import {
CDN,
- GeoLimit, GeoProvider, LoginRequest,
+ GeoLimit,
+ GeoProvider,
+ LoginRequest,
Protocol,
RequestDeliveryService,
ResponseCDN,
@@ -44,7 +48,9 @@ import {
ResponseDivision,
RequestDivision,
ResponseRegion,
- RequestRegion
+ RequestRegion,
+ RequestCacheGroup,
+ ResponseCacheGroup
} from "trafficops-types";
declare module "nightwatch" {
@@ -52,6 +58,8 @@ declare module "nightwatch" {
* Defines the global nightwatch browser type with our types mixed in.
*/
export interface NightwatchCustomPageObjects {
+ cacheGroupDetails: () => CacheGroupDetailPageObject;
+ cacheGroupsTable: () => CacheGroupsPageObject;
common: () => CommonPageObject;
changeLogs: () => ChangeLogsPageObject;
deliveryServiceCard: () => DeliveryServiceCardPageObject;
@@ -85,6 +93,7 @@ declare module "nightwatch" {
* Contains the data created by the client before the test suite runs.
*/
export interface CreatedData {
+ cacheGroup: ResponseCacheGroup;
cdn: ResponseCDN;
ds: ResponseDeliveryService;
ds2: ResponseDeliveryService;
@@ -104,7 +113,7 @@ const globals = {
done();
});
},
- apiVersion: "4.0",
+ apiVersion: "3.1",
before: async (done: () => void): Promise<void> => {
const apiUrl = `${globals.trafficOpsURL}/api/${globals.apiVersion}`;
const client = axios.create({
@@ -132,8 +141,9 @@ const globals = {
throw e;
}
if(accessToken === "") {
- console.error("Access token is not set");
- return Promise.reject();
+ const e = new Error("Access token is not set");
+ console.error(e.message);
+ throw e;
}
client.defaults.headers.common = { Cookie: accessToken };
@@ -156,6 +166,10 @@ const globals = {
if(steeringWeightType === undefined) {
throw new Error("Unable to find `STEERING_WEIGHT` type");
}
+ const cgType = types.find(typ => typ.useInTable === "cachegroup");
+ if (!cgType) {
+ throw new Error("Unable to find any Cache Group Types");
+ }
try {
const data = testData as CreatedData;
@@ -255,6 +269,16 @@ const globals = {
const respRegion: ResponseRegion = resp.data.response;
console.log(`Successfully created Region ${respRegion.name}`);
data.region = respRegion;
+
+ const cacheGroup: RequestCacheGroup = {
+ name: `test${globals.uniqueString}`,
+ shortName: `test${globals.uniqueString}`,
+ typeId: cgType.id
+ };
+ resp = await client.post(`${apiUrl}/cachegroups`, JSON.stringify(cacheGroup));
+ const responseCG: ResponseCacheGroup = resp.data.response;
+ console.log("Successfully created Cache Group:", responseCG);
+ data.cacheGroup = responseCG;
} catch(e) {
console.error((e as AxiosError).message);
throw e;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroupDetails.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroupDetails.ts
new file mode 100644
index 0000000000..973b66bded
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroupDetails.ts
@@ -0,0 +1,51 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EnhancedPageObject } from "nightwatch";
+
+/**
+ * Defines the PageObject for Cache Group Details.
+ */
+export type CacheGroupDetailPageObject = EnhancedPageObject<{}, typeof cacheGroupDetailPageObject.elements>;
+
+const cacheGroupDetailPageObject = {
+ elements: {
+ id: {
+ selector: "input[name='id']"
+ },
+ lastUpdated: {
+ selector: "input[name='lastUpdated']"
+ },
+ latitude: {
+ selector: "input[name='latitude']"
+ },
+ longitude: {
+ selector: "input[name='longitude']"
+ },
+ name: {
+ selector: "input[name='name']"
+ },
+ parent: {
+ selector: "mat-select[name='parentCacheGroup']"
+ },
+ saveBtn: {
+ selector: "button[type='submit']"
+ },
+ secondaryParent: {
+ selector: "mat-select[name='secondaryParentCacheGroup]"
+ },
+ },
+};
+
+export default cacheGroupDetailPageObject;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/cacheGroupsTable.ts b/experimental/traffic-portal/nightwatch/page_objects/cacheGroupsTable.ts
new file mode 100644
index 0000000000..77a16b3258
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/cacheGroupsTable.ts
@@ -0,0 +1,46 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EnhancedPageObject, EnhancedSectionInstance, NightwatchAPI } from "nightwatch";
+
+import { TABLE_COMMANDS, TableSectionCommands } from "../globals/tables";
+
+/**
+ * Defines the Cache Group table commands
+ */
+type CacheGroupsTableCommands = TableSectionCommands;
+
+/**
+ * Defines the Page Object for the Cache Groups page.
+ */
+export type CacheGroupsPageObject = EnhancedPageObject<{}, {},
+EnhancedSectionInstance<CacheGroupsTableCommands>>;
+
+const divisionsPageObject = {
+ api: {} as NightwatchAPI,
+ sections: {
+ divisionsTable: {
+ commands: {
+ ...TABLE_COMMANDS
+ },
+ elements: {},
+ selector: "mat-card"
+ }
+ },
+ url(): string {
+ return `${this.api.launchUrl}/core/cache-groups`;
+ }
+};
+
+export default divisionsPageObject;
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/detail.spec.ts
similarity index 66%
copy from experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
copy to experimental/traffic-portal/nightwatch/tests/cacheGroups/detail.spec.ts
index 943ed77176..032a7bde24 100644
--- a/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/detail.spec.ts
@@ -12,24 +12,24 @@
* limitations under the License.
*/
-describe("Division Detail Spec", () => {
- it("Test division", () => {
- const page = browser.page.divisionDetail();
- browser.url(`${page.api.launchUrl}/core/division/${browser.globals.testData.division.id}`, res => {
+describe("Cache Group Detail Spec", () => {
+ it("Test Cache Group", () => {
+ const page = browser.page.cacheGroupDetails();
+ browser.url(`${page.api.launchUrl}/core/cache-groups/${browser.globals.testData.cacheGroup.id}`, res => {
browser.assert.ok(res.status === 0);
page.waitForElementVisible("mat-card")
.assert.enabled("@name")
.assert.enabled("@saveBtn")
.assert.not.enabled("@id")
.assert.not.enabled("@lastUpdated")
- .assert.valueEquals("@name", browser.globals.testData.division.name)
- .assert.valueEquals("@id", String(browser.globals.testData.division.id));
+ .assert.valueEquals("@name", browser.globals.testData.cacheGroup.name)
+ .assert.valueEquals("@id", String(browser.globals.testData.cacheGroup.id));
});
});
- it("New division", () => {
- const page = browser.page.divisionDetail();
- browser.url(`${page.api.launchUrl}/core/division/new`, res => {
+ it("New Cache Group", () => {
+ const page = browser.page.cacheGroupDetails();
+ browser.url(`${page.api.launchUrl}/core/cache-groups/new`, res => {
browser.assert.ok(res.status === 0);
page.waitForElementVisible("mat-card")
.assert.enabled("@name")
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
index 943ed77176..96b0a59a90 100644
--- a/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/divisions/detail.spec.ts
@@ -15,7 +15,7 @@
describe("Division Detail Spec", () => {
it("Test division", () => {
const page = browser.page.divisionDetail();
- browser.url(`${page.api.launchUrl}/core/division/${browser.globals.testData.division.id}`, res => {
+ browser.url(`${page.api.launchUrl}/core/divisions/${browser.globals.testData.division.id}`, res => {
browser.assert.ok(res.status === 0);
page.waitForElementVisible("mat-card")
.assert.enabled("@name")
@@ -29,7 +29,7 @@ describe("Division Detail Spec", () => {
it("New division", () => {
const page = browser.page.divisionDetail();
- browser.url(`${page.api.launchUrl}/core/division/new`, res => {
+ browser.url(`${page.api.launchUrl}/core/divisions/new`, res => {
browser.assert.ok(res.status === 0);
page.waitForElementVisible("mat-card")
.assert.enabled("@name")
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/regions/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/regions/detail.spec.ts
index 8af0540622..e116fa28db 100644
--- a/experimental/traffic-portal/nightwatch/tests/cacheGroups/regions/detail.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/regions/detail.spec.ts
@@ -15,7 +15,7 @@
describe("Region Detail Spec", () => {
it("Test region", () => {
const page = browser.page.regionDetail();
- browser.url(`${page.api.launchUrl}/core/region/${browser.globals.testData.region.id}`, res => {
+ browser.url(`${page.api.launchUrl}/core/regions/${browser.globals.testData.region.id}`, res => {
browser.assert.ok(res.status === 0);
page.waitForElementVisible("mat-card")
.assert.enabled("@name")
@@ -30,7 +30,7 @@ describe("Region Detail Spec", () => {
it("New region", () => {
const page = browser.page.regionDetail();
- browser.url(`${page.api.launchUrl}/core/region/new`, res => {
+ browser.url(`${page.api.launchUrl}/core/regions/new`, res => {
browser.assert.ok(res.status === 0);
page.waitForElementVisible("mat-card")
.assert.enabled("@name")
diff --git a/experimental/traffic-portal/nightwatch/tests/cacheGroups/table.spec.ts b/experimental/traffic-portal/nightwatch/tests/cacheGroups/table.spec.ts
new file mode 100644
index 0000000000..f0a276caa6
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/cacheGroups/table.spec.ts
@@ -0,0 +1,24 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+describe("Cache Groups Spec", () => {
+ it("Loads elements", async () => {
+ browser.page.cacheGroupsTable().navigate()
+ .waitForElementPresent("input[name=fuzzControl]");
+ browser.elements("css selector", "div.ag-row", rows => {
+ browser.assert.ok(rows.status === 0);
+ browser.assert.ok((rows.value as []).length >= 2);
+ });
+ });
+});
diff --git a/experimental/traffic-portal/package-lock.json b/experimental/traffic-portal/package-lock.json
index c886bb6bc1..d1dbeaed1b 100644
--- a/experimental/traffic-portal/package-lock.json
+++ b/experimental/traffic-portal/package-lock.json
@@ -68,7 +68,7 @@
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"nightwatch": "^2.5.1",
- "trafficops-types": "^3.1.2",
+ "trafficops-types": "^3.1.4",
"ts-node": "~8.3.0",
"typescript": "^4.5.4"
},
@@ -17184,9 +17184,9 @@
}
},
"node_modules/trafficops-types": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-3.1.2.tgz",
- "integrity": "sha512-4XHGNmZALrO4lhs0lJIxMFSH1+zsILe1/gCMAuNW5dZOdaa9tsOqmAMXjXidti6lmuk+IaWk5wZoiiFyW09PTQ==",
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-3.1.4.tgz",
+ "integrity": "sha512-PSaJdzgwi7gkoUFMn5oNmrwyqeCyQrvuH47XuJSOJjgoo43DPWvlSTH0Ru/w5IRZ2Zli20wib7S4Ej/S8g8Y+A==",
"dev": true
},
"node_modules/traverse": {
@@ -31156,9 +31156,9 @@
}
},
"trafficops-types": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-3.1.2.tgz",
- "integrity": "sha512-4XHGNmZALrO4lhs0lJIxMFSH1+zsILe1/gCMAuNW5dZOdaa9tsOqmAMXjXidti6lmuk+IaWk5wZoiiFyW09PTQ==",
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/trafficops-types/-/trafficops-types-3.1.4.tgz",
+ "integrity": "sha512-PSaJdzgwi7gkoUFMn5oNmrwyqeCyQrvuH47XuJSOJjgoo43DPWvlSTH0Ru/w5IRZ2Zli20wib7S4Ej/S8g8Y+A==",
"dev": true
},
"traverse": {
diff --git a/experimental/traffic-portal/package.json b/experimental/traffic-portal/package.json
index b30bb941d7..17c868efe6 100644
--- a/experimental/traffic-portal/package.json
+++ b/experimental/traffic-portal/package.json
@@ -108,7 +108,7 @@
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"nightwatch": "^2.5.1",
- "trafficops-types": "^3.1.2",
+ "trafficops-types": "^3.1.4",
"ts-node": "~8.3.0",
"typescript": "^4.5.4"
},
diff --git a/experimental/traffic-portal/src/app/api/base-api.service.ts b/experimental/traffic-portal/src/app/api/base-api.service.ts
index 7d811cfa78..97138e8547 100644
--- a/experimental/traffic-portal/src/app/api/base-api.service.ts
+++ b/experimental/traffic-portal/src/app/api/base-api.service.ts
@@ -36,7 +36,7 @@ export abstract class APIService {
* @param params Option query parameters to send in the request.
* @returns An Observable that emits the server response.
*/
- protected delete<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+ protected delete<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
return this.do<T>("delete", path, data, params);
}
@@ -48,7 +48,7 @@ export abstract class APIService {
* @param params Option query parameters to send in the request.
* @returns An Observable that emits the server response.
*/
- protected get<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+ protected get<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
return this.do<T>("get", path, data, params);
}
@@ -60,7 +60,7 @@ export abstract class APIService {
* @param params Option query parameters to send in the request.
* @returns An Observable that emits the server response.
*/
- protected head<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+ protected head<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
return this.do<T>("head", path, data, params);
}
@@ -72,7 +72,7 @@ export abstract class APIService {
* @param params Option query parameters to send in the request.
* @returns An Observable that emits the server response.
*/
- protected options<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+ protected options<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
return this.do<T>("options", path, data, params);
}
@@ -84,7 +84,7 @@ export abstract class APIService {
* @param params Option query parameters to send in the request.
* @returns An Observable that emits the server response.
*/
- protected patch<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+ protected patch<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
return this.do<T>("patch", path, data, params);
}
@@ -96,7 +96,7 @@ export abstract class APIService {
* @param params Option query parameters to send in the request.
* @returns An Observable that emits the server response.
*/
- protected post<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+ protected post<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
return this.do<T>("post", path, data, params);
}
@@ -108,7 +108,7 @@ export abstract class APIService {
* @param params Option query parameters to send in the request.
* @returns An Observable that emits the server response.
*/
- protected put<T>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
+ protected put<T = undefined>(path: string, data?: object, params?: Record<string, string>): Observable<T> {
return this.do<T>("put", path, data, params);
}
diff --git a/experimental/traffic-portal/src/app/api/cache-group.service.ts b/experimental/traffic-portal/src/app/api/cache-group.service.ts
index 7fab81817a..6bdb28f48f 100644
--- a/experimental/traffic-portal/src/app/api/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/cache-group.service.ts
@@ -13,28 +13,63 @@
*/
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
-import type { RequestDivision, ResponseDivision, RequestRegion, ResponseRegion } from "trafficops-types";
-
-import type { CacheGroup } from "src/app/models";
+import type {
+ RequestDivision,
+ ResponseDivision,
+ RequestRegion,
+ ResponseRegion,
+ ResponseCacheGroup,
+ RequestCacheGroup,
+ CDN,
+ CacheGroupQueueResponse,
+ CacheGroupQueueRequest,
+} from "trafficops-types";
import { APIService } from "./base-api.service";
+/**
+ * Checks the type of an argument to
+ * {@link CacheGroupService.queueCacheGroupUpdates}.
+ *
+ * @param x The object to check.
+ * @returns Whether `x` is an {@link CacheGroupQueueRequest}.
+ */
+function isRequest(x: CacheGroupQueueRequest | CDN | string | number): x is CacheGroupQueueRequest {
+ return Object.prototype.hasOwnProperty.call(x, "action");
+}
+
/**
* CDNService expose API functionality relating to CDNs.
*/
@Injectable()
export class CacheGroupService extends APIService {
- public async getCacheGroups(idOrName: number | string): Promise<CacheGroup>;
- public async getCacheGroups(): Promise<Array<CacheGroup>>;
+
/**
- * Gets one or all CDNs from Traffic Ops
+ * Gets a single Cache Group from Traffic Ops.
*
- * @param idOrName Optionally either the name or integral, unique identifier of a single Cache Group to be returned.
- * @returns Either an Array of CacheGroup objects, or a single CacheGroup, depending on whether
- * `idOrName` was passed.
- * @throws {Error} In the event that `idOrName` is passed but does not match any CacheGroup.
+ * @param idOrName Either the name or integral, unique identifier of the
+ * single Cache Group to be returned.
+ * @returns The Cache Group identified by `idOrName`.
+ * @throws {Error} When no matching Cache Group is found in Traffic Ops.
*/
- public async getCacheGroups(idOrName?: number | string): Promise<Array<CacheGroup> | CacheGroup> {
+ public async getCacheGroups(idOrName: number | string): Promise<ResponseCacheGroup>;
+ /**
+ * Gets Cache Groups from Traffic Ops.
+ *
+ * @returns All requested Cache Groups.
+ */
+ public async getCacheGroups(): Promise<Array<ResponseCacheGroup>>;
+ /**
+ * Gets one or all Cache Groups from Traffic Ops
+ *
+ * @param idOrName Optionally either the name or integral, unique identifier
+ * of a single Cache Group to be returned.
+ * @returns Either an Array of Cache Group objects, or a single Cache Group,
+ * depending on whether `idOrName` was passed.
+ * @throws {Error} In the event that `idOrName` is passed but does not match
+ * any Cache Group.
+ */
+ public async getCacheGroups(idOrName?: number | string): Promise<Array<ResponseCacheGroup> | ResponseCacheGroup> {
const path = "cachegroups";
if (idOrName !== undefined) {
let params;
@@ -45,50 +80,151 @@ export class CacheGroupService extends APIService {
case "number":
params = {id: String(idOrName)};
}
- return this.get<[CacheGroup]>(path, undefined, params).toPromise().then(
- r => {
- const cg = r[0];
- if (cg.id !== idOrName) {
- throw new Error(`Traffic Ops returned no match for ID ${idOrName}`);
- }
- // lastUpdated comes in as a string
- cg.lastUpdated = cg.lastUpdated ? new Date((cg.lastUpdated as unknown as string).replace("+00", "Z")) : undefined;
- return cg;
- }
- ).catch(
- e => {
- console.error("Failed to get Cache Group with identifier", idOrName, ":", e);
- return {
- fallbackToClosest: false,
- fallbacks: [],
- latitude: 0,
- localizationMethods: [],
- longitude: 0,
- name: "",
- parentCacheGroupID: -1,
- parentCacheGroupName: "",
- secondaryParentCacheGroupID: -1,
- secondaryParentCacheGroupName: "",
- shortName: "",
- typeId: -1,
- typeName: ""
- };
- }
- );
+ const resp = await this.get<[ResponseCacheGroup]>(path, undefined, params).toPromise();
+ if (resp.length !== 1) {
+ throw new Error(`Traffic Ops returned wrong number of results for Cache Group identifier: ${params}`);
+ }
+ const cg = resp[0];
+ // lastUpdated comes in as a string
+ return {...cg, lastUpdated: new Date((cg.lastUpdated as unknown as string).replace("+00", "Z"))};
}
- return this.get<Array<CacheGroup>>(path).toPromise().then(r => r.map(
- cg => {
- if (cg.lastUpdated) {
- cg.lastUpdated = new Date((cg.lastUpdated as unknown as string).replace("+00", "Z"));
- }
- return cg;
+ const r = await this.get<Array<ResponseCacheGroup>>(path).toPromise();
+ return r.map(cg => ({...cg, lastUpdated: new Date((cg.lastUpdated as unknown as string).replace("+00", "Z"))}));
+ }
+
+ /**
+ * Deletes a Cache Group.
+ *
+ * @param cacheGroup The Cache Group to be deleted, or just its ID.
+ */
+ public async deleteCacheGroup(cacheGroup: ResponseCacheGroup | number): Promise<void> {
+ const id = typeof(cacheGroup) === "number" ? cacheGroup : cacheGroup.id;
+ return this.delete(`cachegroups/${id}`).toPromise();
+ }
+
+ /**
+ * Creates a new Cache Group.
+ *
+ * @param cacheGroup The Cache Group to create.
+ */
+ public async createCacheGroup(cacheGroup: RequestCacheGroup): Promise<ResponseCacheGroup> {
+ return this.post<ResponseCacheGroup>("cachegroups", cacheGroup).toPromise();
+ }
+
+ /**
+ * Replaces an existing Cache Group with the provided new definition of a
+ * Cache Group.
+ *
+ * @param id The if of the Cache Group being updated.
+ * @param cacheGroup The new definition of the Cache Group.
+ */
+ public async updateCacheGroup(id: number, cacheGroup: RequestCacheGroup): Promise<ResponseCacheGroup>;
+ /**
+ * Replaces an existing Cache Group with the provided new definition of a
+ * Cache Group.
+ *
+ * @param cacheGroup The full new definition of the Cache Group being
+ * updated.
+ */
+ public async updateCacheGroup(cacheGroup: ResponseCacheGroup): Promise<ResponseCacheGroup>;
+ /**
+ * Replaces an existing Cache Group with the provided new definition of a
+ * Cache Group.
+ *
+ * @param cacheGroupOrID The full new definition of the Cache Group being
+ * updated, or just its ID.
+ * @param payload The new definition of the Cache Group. This is required if
+ * `cacheGroupOrID` is an ID, and ignored otherwise.
+ */
+ public async updateCacheGroup(cacheGroupOrID: ResponseCacheGroup | number, payload?: RequestCacheGroup): Promise<ResponseCacheGroup> {
+ let id;
+ let body;
+ if (typeof(cacheGroupOrID) === "number") {
+ if (!payload) {
+ throw new TypeError("invalid call signature - missing request payload");
}
- )).catch(
- e => {
- console.error("Failed to get Cache Groups:", e);
- return [];
+ body = payload;
+ id = cacheGroupOrID;
+ } else {
+ body = cacheGroupOrID;
+ ({id} = cacheGroupOrID);
+ }
+
+ return this.put<ResponseCacheGroup>(`cachegroups/${id}`, body).toPromise();
+ }
+
+ /**
+ * Queues (or dequeues) updates on a Cache Group's servers.
+ *
+ * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+ * just its ID.
+ * @param cdnOrIdentifier Either a CDN, its name, or its ID.
+ * @param action Used to determine the queue action to take. If not given,
+ * defaults to `queue`.
+ * @returns The API's response.
+ */
+ public async queueCacheGroupUpdates(
+ cacheGroupOrID: ResponseCacheGroup | number,
+ cdnOrIdentifier: CDN | string | number,
+ action?: "queue" | "dequeue"
+ ): Promise<CacheGroupQueueResponse>;
+ /**
+ * Queues (or dequeues) updates on a Cache Group's servers.
+ *
+ * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+ * just its ID.
+ * @param request The full (de/)queue request.
+ * @returns The API's response.
+ */
+ public async queueCacheGroupUpdates(
+ cacheGroupOrID: ResponseCacheGroup | number,
+ request: CacheGroupQueueRequest
+ ): Promise<CacheGroupQueueResponse>;
+ /**
+ * Queues (or dequeues) updates on a Cache Group's servers.
+ *
+ * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+ * just its ID.
+ * @param cdnOrIdentifierOrRequest Either the full (de/)queue request or a
+ * CDN, its name, or its ID.
+ * @param action If `cdnOrIdentifierOrRequest` is not a full (de/)queue
+ * request, then this will be used to determine the queue action to take. If
+ * not given, defaults to `queue`.
+ * @returns The API's response.
+ */
+ public async queueCacheGroupUpdates(
+ cacheGroupOrID: ResponseCacheGroup | number,
+ cdnOrIdentifierOrRequest: CacheGroupQueueRequest | CDN | string | number,
+ action?: "queue" | "dequeue"
+ ): Promise<CacheGroupQueueResponse> {
+ const cgID = typeof(cacheGroupOrID) === "number" ? cacheGroupOrID : cacheGroupOrID.id;
+ const path = `cachegroups/${cgID}/queue_update`;
+ let request: CacheGroupQueueRequest;
+ if (isRequest(cdnOrIdentifierOrRequest)) {
+ request = cdnOrIdentifierOrRequest;
+ } else {
+ action = action ?? "queue";
+ switch (typeof(cdnOrIdentifierOrRequest)) {
+ case "string":
+ request = {
+ action,
+ cdn: cdnOrIdentifierOrRequest,
+ };
+ break;
+ case "number":
+ request = {
+ action,
+ cdnId: cdnOrIdentifierOrRequest,
+ };
+ break;
+ default:
+ request = {
+ action,
+ cdn: cdnOrIdentifierOrRequest.name
+ };
}
- );
+ }
+ return this.post<CacheGroupQueueResponse>(path, request).toPromise();
}
public async getDivisions(): Promise<Array<ResponseDivision>>;
diff --git a/experimental/traffic-portal/src/app/api/cdn.service.ts b/experimental/traffic-portal/src/app/api/cdn.service.ts
index 8e27c0da85..3cbd990743 100644
--- a/experimental/traffic-portal/src/app/api/cdn.service.ts
+++ b/experimental/traffic-portal/src/app/api/cdn.service.ts
@@ -13,8 +13,7 @@
*/
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
-
-import type { CDN } from "src/app/models";
+import type { ResponseCDN } from "trafficops-types";
import { APIService } from "./base-api.service";
@@ -28,8 +27,8 @@ export class CDNService extends APIService {
super(http);
}
- public async getCDNs(id: number): Promise<CDN>;
- public async getCDNs(): Promise<Map<string, CDN>>;
+ public async getCDNs(id: number): Promise<ResponseCDN>;
+ public async getCDNs(): Promise<Array<ResponseCDN>>;
/**
* Gets one or all CDNs from Traffic Ops
*
@@ -38,30 +37,15 @@ export class CDNService extends APIService {
* passed.
* (In the event that `id` is passed but does not match any CDN, `null` will be emitted)
*/
- public async getCDNs(id?: number): Promise<Map<string, CDN> | CDN> {
+ public async getCDNs(id?: number): Promise<Array<ResponseCDN> | ResponseCDN> {
const path = "cdns";
if (id) {
- return this.get<[CDN]>(path, undefined, {id: String(id)}).toPromise().then(
- r => r[0]
- ).catch(
- e => {
- console.error(`Failed to get CDN #${id}`, e);
- return {
- dnssecEnabled: false,
- domainName: "",
- id: -1,
- name: "",
- };
- }
- );
- }
- return this.get<Array<CDN>>(path).toPromise().then(
- r => new Map<string, CDN>(r.map(c=>[c.name, c]))
- ).catch(
- e => {
- console.error("Failed to get CDNs:", e);
- return new Map();
+ const cdn = await this.get<[ResponseCDN]>(path, undefined, {id: String(id)}).toPromise();
+ if (cdn.length !== 1) {
+ throw new Error(`${cdn.length} CDNs found by ID ${id}`);
}
- );
+ return cdn;
+ }
+ return this.get<Array<ResponseCDN>>(path).toPromise();
}
}
diff --git a/experimental/traffic-portal/src/app/api/server.service.ts b/experimental/traffic-portal/src/app/api/server.service.ts
index 73c7fcf91f..d4a541bc87 100644
--- a/experimental/traffic-portal/src/app/api/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/server.service.ts
@@ -233,7 +233,7 @@ export class ServerService extends APIService {
id = server.id;
}
- return this.put<undefined>(`servers/${id}/status`, {offlineReason, status}).toPromise().catch(
+ return this.put(`servers/${id}/status`, {offlineReason, status}).toPromise().catch(
e=> {
console.error("Failed to update server status:", e);
return undefined;
diff --git a/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts b/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
index 99f5f2c8ed..660f94090e 100644
--- a/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/cache-group.service.ts
@@ -12,9 +12,73 @@
* limitations under the License.
*/
import { Injectable } from "@angular/core";
-import { RequestDivision, ResponseDivision, RequestRegion, ResponseRegion } from "trafficops-types";
+import type {
+ CacheGroupQueueRequest,
+ CacheGroupQueueResponse,
+ CDN,
+ RequestCacheGroup,
+ RequestDivision,
+ RequestRegion,
+ ResponseCacheGroup,
+ ResponseDivision,
+ ResponseRegion,
+} from "trafficops-types";
-import type { CacheGroup } from "src/app/models";
+import { ServerService } from "./server.service";
+
+/**
+ * The names of properties of {@link ResponseCacheGroup}s that define its
+ * primary parentage.
+ */
+type ParentKeys = "parentCachegroupId" | "parentCachegroupName";
+/**
+ * The names of properties of {@link ResponseCacheGroup}s that define its
+ * secondary parentage.
+ */
+type SecondaryParentKeys = "secondaryParentCachegroupId" | "secondaryParentCachegroupName";
+/**
+ * The names of properties of {@link ResponseCacheGroup}s that define its
+ * parentage; both primary and secondary.
+ */
+type AllParentageKeys = ParentKeys | SecondaryParentKeys;
+
+/**
+ * The parts of a Cache Group pertaining to its primary parentage.
+ */
+type Parentage = {
+ parentCachegroupId: null;
+ parentCachegroupName: null;
+} | {
+ parentCachegroupId: number;
+ parentCachegroupName: string;
+};
+
+/**
+ * The parts of a Cache Group pertaining to its secondary parentage.
+ */
+type SecondaryParentage = {
+ secondaryParentCachegroupId: null;
+ secondaryParentCachegroupName: null;
+} | {
+ secondaryParentCachegroupId: number;
+ secondaryParentCachegroupName: string;
+};
+
+/**
+ * Contains all information about a Cache Groups parents, primary and secondary.
+ */
+type AllParentage = Parentage & SecondaryParentage;
+
+/**
+ * Checks the type of an argument to
+ * {@link CacheGroupService.queueCacheGroupUpdates}.
+ *
+ * @param x The object to check.
+ * @returns Whether `x` is an {@link CacheGroupQueueRequest}.
+ */
+function isRequest(x: CacheGroupQueueRequest | CDN | string | number): x is CacheGroupQueueRequest {
+ return Object.prototype.hasOwnProperty.call(x, "action");
+}
/**
* CDNService expose API functionality relating to CDNs.
@@ -37,19 +101,20 @@ export class CacheGroupService {
name: "Reg1"
}
];
- private readonly cacheGroups = [
+ private readonly cacheGroups: Array<ResponseCacheGroup> = [
{
fallbackToClosest: true,
fallbacks: [],
id: 1,
+ lastUpdated: new Date(),
latitude: 0,
localizationMethods: [],
longitude: 0,
name: "Mid",
- parentCacheGroupID: null,
- parentCacheGroupName: null,
- secondaryParentCacheGroupID: null,
- secondaryParentCacheGroupName: null,
+ parentCachegroupId: null,
+ parentCachegroupName: null,
+ secondaryParentCachegroupId: null,
+ secondaryParentCachegroupName: null,
shortName: "Mid",
typeId: 1,
typeName: "MID_LOC"
@@ -58,14 +123,15 @@ export class CacheGroupService {
fallbackToClosest: true,
fallbacks: [],
id: 2,
+ lastUpdated: new Date(),
latitude: 0,
localizationMethods: [],
longitude: 0,
name: "Edge",
- parentCacheGroupID: 1,
- parentCacheGroupName: "Mid",
- secondaryParentCacheGroupID: null,
- secondaryParentCacheGroupName: null,
+ parentCachegroupId: 1,
+ parentCachegroupName: "Mid",
+ secondaryParentCachegroupId: null,
+ secondaryParentCachegroupName: null,
shortName: "Edge",
typeId: 2,
typeName: "EDGE_LOC"
@@ -74,14 +140,15 @@ export class CacheGroupService {
fallbackToClosest: true,
fallbacks: [],
id: 3,
+ lastUpdated: new Date(),
latitude: 0,
localizationMethods: [],
longitude: 0,
name: "Origin",
- parentCacheGroupID: null,
- parentCacheGroupName: null,
- secondaryParentCacheGroupID: null,
- secondaryParentCacheGroupName: null,
+ parentCachegroupId: null,
+ parentCachegroupName: null,
+ secondaryParentCachegroupId: null,
+ secondaryParentCachegroupName: null,
shortName: "Origin",
typeId: 3,
typeName: "ORG_LOC"
@@ -90,22 +157,25 @@ export class CacheGroupService {
fallbackToClosest: true,
fallbacks: [],
id: 4,
+ lastUpdated: new Date(),
latitude: 0,
localizationMethods: [],
longitude: 0,
name: "Other",
- parentCacheGroupID: null,
- parentCacheGroupName: null,
- secondaryParentCacheGroupID: null,
- secondaryParentCacheGroupName: null,
+ parentCachegroupId: null,
+ parentCachegroupName: null,
+ secondaryParentCachegroupId: null,
+ secondaryParentCachegroupName: null,
shortName: "Other",
typeId: 4,
typeName: "TC_LOC"
}
];
- public async getCacheGroups(idOrName: number | string): Promise<CacheGroup>;
- public async getCacheGroups(): Promise<Array<CacheGroup>>;
+ constructor(private readonly servers: ServerService) {}
+
+ public async getCacheGroups(idOrName: number | string): Promise<ResponseCacheGroup>;
+ public async getCacheGroups(): Promise<Array<ResponseCacheGroup>>;
/**
* Gets one or all CDNs from Traffic Ops
*
@@ -114,7 +184,7 @@ export class CacheGroupService {
* `idOrName` was passed.
* @throws {Error} In the event that `idOrName` is passed but does not match any CacheGroup.
*/
- public async getCacheGroups(idOrName?: number | string): Promise<Array<CacheGroup> | CacheGroup> {
+ public async getCacheGroups(idOrName?: number | string): Promise<Array<ResponseCacheGroup> | ResponseCacheGroup> {
if (idOrName !== undefined) {
let cacheGroup;
switch (typeof(idOrName)) {
@@ -132,6 +202,241 @@ export class CacheGroupService {
return this.cacheGroups;
}
+ /**
+ * Deletes a Cache Group.
+ *
+ * @param cacheGroup The Cache Group to be deleted, or just its ID.
+ */
+ public async deleteCacheGroup(cacheGroup: ResponseCacheGroup | number): Promise<void> {
+ const id = typeof(cacheGroup) === "number" ? cacheGroup : cacheGroup.id;
+ const idx = this.cacheGroups.findIndex(cg => cg.id === id);
+ if (idx < 0) {
+ throw new Error(`no such Cache Group: #${id}`);
+ }
+ this.cacheGroups.splice(idx, 1);
+ }
+
+ /**
+ * Gets the names of parents for a Cache Group from their IDs.
+ *
+ * @param parentID The ID of a parent Cache Group (or not).
+ * @param secondaryParentID The ID of a "secondary" parent Cache Group (or
+ * not).
+ * @returns The parentage portion of a Cache Group.
+ */
+ private getParents(parentID: number | null | undefined, secondaryParentID: number | null | undefined): AllParentage {
+ let parent: Parentage = {
+ parentCachegroupId: null,
+ parentCachegroupName: null
+ };
+ if (typeof(parentID) === "number") {
+ const p = this.cacheGroups.find(cg => cg.id === parentID);
+ if (!p) {
+ throw new Error(`no such parent Cache Group: #${parentID}`);
+ }
+ parent = {
+ parentCachegroupId: p.id,
+ parentCachegroupName: p.name
+ };
+ }
+
+ let secondaryParent: SecondaryParentage = {
+ secondaryParentCachegroupId: null,
+ secondaryParentCachegroupName: null
+ };
+ if (typeof(secondaryParentID) === "number") {
+ const p = this.cacheGroups.find(cg => cg.id === secondaryParentID);
+ if (!p) {
+ throw new Error(`no such secondary parent Cache Group: #${secondaryParentID}`);
+ }
+ secondaryParent = {
+ secondaryParentCachegroupId: p.id,
+ secondaryParentCachegroupName: p.name
+ };
+ }
+
+ return {
+ ...parent,
+ ...secondaryParent
+ };
+ }
+
+ /**
+ * Creates a new Cache Group.
+ *
+ * @param cacheGroup The Cache Group to create.
+ */
+ public async createCacheGroup(cacheGroup: RequestCacheGroup): Promise<ResponseCacheGroup> {
+ const cg = {
+ ...cacheGroup,
+ ...this.getParents(cacheGroup.parentCachegroupId, cacheGroup.secondaryParentCachegroupId),
+ fallbackToClosest: cacheGroup.fallbackToClosest ?? false,
+ fallbacks: cacheGroup.fallbacks ?? [],
+ id: ++this.lastID,
+ lastUpdated: new Date(),
+ latitude: cacheGroup.latitude ?? 0,
+ localizationMethods: cacheGroup.localizationMethods ?? [],
+ longitude: cacheGroup.longitude ?? 0,
+ typeName: "",
+ };
+ this.cacheGroups.push(cg);
+ return cg;
+ }
+
+ /**
+ * Replaces an existing Cache Group with the provided new definition of a
+ * Cache Group.
+ *
+ * @param id The if of the Cache Group being updated.
+ * @param cacheGroup The new definition of the Cache Group.
+ */
+ public async updateCacheGroup(id: number, cacheGroup: RequestCacheGroup): Promise<ResponseCacheGroup>;
+ /**
+ * Replaces an existing Cache Group with the provided new definition of a
+ * Cache Group.
+ *
+ * @param cacheGroup The full new definition of the Cache Group being
+ * updated.
+ */
+ public async updateCacheGroup(cacheGroup: ResponseCacheGroup): Promise<ResponseCacheGroup>;
+ /**
+ * Replaces an existing Cache Group with the provided new definition of a
+ * Cache Group.
+ *
+ * @param cacheGroupOrID The full new definition of the Cache Group being
+ * updated, or just its ID.
+ * @param payload The new definition of the Cache Group. This is required if
+ * `cacheGroupOrID` is an ID, and ignored otherwise.
+ */
+ public async updateCacheGroup(cacheGroupOrID: ResponseCacheGroup | number, payload?: RequestCacheGroup): Promise<ResponseCacheGroup> {
+ let idx;
+ let cg: Omit<ResponseCacheGroup, AllParentageKeys>;
+ let parentCachegroupId;
+ let secondaryParentCachegroupId;
+ if (typeof(cacheGroupOrID) === "number") {
+ if (!payload) {
+ throw new TypeError("invalid call signature - missing request payload");
+ }
+ idx = this.cacheGroups.findIndex(c => c.id === cacheGroupOrID);
+ cg = {
+ ...payload,
+ fallbackToClosest: payload.fallbackToClosest ?? false,
+ fallbacks: payload.fallbacks ?? [],
+ id: ++this.lastID,
+ lastUpdated: new Date(),
+ latitude: payload.latitude ?? 0,
+ localizationMethods: payload.localizationMethods ?? [],
+ longitude: payload.longitude ?? 0,
+ typeName: "",
+ };
+ parentCachegroupId = payload.parentCachegroupId;
+ secondaryParentCachegroupId = payload.secondaryParentCachegroupId;
+ } else {
+ idx = this.cacheGroups.findIndex(c => c.id === cacheGroupOrID.id);
+ cg = {
+ ...cacheGroupOrID,
+ lastUpdated: new Date()
+ };
+ parentCachegroupId = cacheGroupOrID.parentCachegroupId;
+ secondaryParentCachegroupId = cacheGroupOrID.secondaryParentCachegroupId;
+ }
+
+ if (idx < 0) {
+ throw new Error(`no such Cache Group: #${cacheGroupOrID}`);
+ }
+
+ const final = {
+ ...cg,
+ ...this.getParents(parentCachegroupId, secondaryParentCachegroupId)
+ };
+
+ this.cacheGroups[idx] = final;
+
+ return final;
+ }
+
+ /**
+ * Queues (or dequeues) updates on a Cache Group's servers.
+ *
+ * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+ * just its ID.
+ * @param cdnOrIdentifier Either a CDN, its name, or its ID.
+ * @param action Used to determine the queue action to take. If not given,
+ * defaults to `queue`.
+ * @returns The API's response.
+ */
+ public async queueCacheGroupUpdates(
+ cacheGroupOrID: ResponseCacheGroup | number,
+ cdnOrIdentifier: CDN | string | number,
+ action?: "queue" | "dequeue"
+ ): Promise<CacheGroupQueueResponse>;
+ /**
+ * Queues (or dequeues) updates on a Cache Group's servers.
+ *
+ * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+ * just its ID.
+ * @param request The full (de/)queue request.
+ * @returns The API's response.
+ */
+ public async queueCacheGroupUpdates(
+ cacheGroupOrID: ResponseCacheGroup | number,
+ request: CacheGroupQueueRequest
+ ): Promise<CacheGroupQueueResponse>;
+ /**
+ * Queues (or dequeues) updates on a Cache Group's servers.
+ *
+ * @param cacheGroupOrID The Cache Group on which updates will be queued, or
+ * just its ID.
+ * @param cdnOrIdentifierOrRequest Either the full (de/)queue request or a
+ * CDN, its name, or its ID.
+ * @param action If `cdnOrIdentifierOrRequest` is not a full (de/)queue
+ * request, then this will be used to determine the queue action to take. If
+ * not given, defaults to `queue`.
+ * @returns The API's response.
+ */
+ public async queueCacheGroupUpdates(
+ cacheGroupOrID: ResponseCacheGroup | number,
+ cdnOrIdentifierOrRequest: CacheGroupQueueRequest | CDN | string | number,
+ action?: "queue" | "dequeue"
+ ): Promise<CacheGroupQueueResponse> {
+ const cachegroupID = typeof(cacheGroupOrID) === "number" ? cacheGroupOrID : cacheGroupOrID.id;
+ const cg = this.cacheGroups.find(c => c.id === cachegroupID);
+ if (!cg) {
+ throw new Error(`no such Cache Group: #${cachegroupID}`);
+ }
+
+ let cdn;
+ if (isRequest(cdnOrIdentifierOrRequest)) {
+ action = cdnOrIdentifierOrRequest.action;
+ cdn = cdnOrIdentifierOrRequest.cdn ?? cdnOrIdentifierOrRequest.cdnId;
+ } else {
+ action = action ?? "queue";
+ switch (typeof(cdnOrIdentifierOrRequest)) {
+ case "string":
+ case "number":
+ cdn = cdnOrIdentifierOrRequest;
+ break;
+ default:
+ cdn = cdnOrIdentifierOrRequest.name;
+ }
+ }
+ const updPendingValue = action === "queue";
+ const serverNames = [];
+ for (const server of this.servers.servers) {
+ if (server.cachegroupId === cachegroupID && (server.cdnId === cdn || server.cdnName === cdn)) {
+ server.updPending = updPendingValue;
+ serverNames.push(server.hostName);
+ }
+ }
+ return {
+ action,
+ cachegroupID,
+ cachegroupName: cg.name,
+ cdn: String(cdn),
+ serverNames,
+ };
+ }
+
public async getDivisions(): Promise<Array<ResponseDivision>>;
public async getDivisions(nameOrID: string | number): Promise<ResponseDivision>;
diff --git a/experimental/traffic-portal/src/app/api/testing/cdn.service.ts b/experimental/traffic-portal/src/app/api/testing/cdn.service.ts
index 31930bab2e..7ccf4134b6 100644
--- a/experimental/traffic-portal/src/app/api/testing/cdn.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/cdn.service.ts
@@ -11,9 +11,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { Injectable } from "@angular/core";
-import { CDN } from "src/app/models";
+import { Injectable } from "@angular/core";
+import { ResponseCDN } from "trafficops-types";
/**
* CDNService expose API functionality relating to CDNs.
@@ -21,31 +21,25 @@ import { CDN } from "src/app/models";
@Injectable()
export class CDNService {
- private readonly cdns = new Map([
- [
- "ALL",
- {
- dnssecEnabled: false,
- domainName: "-",
- id: 1,
- lastUpdated: new Date(),
- name: "ALL"
- }
- ],
- [
- "test",
- {
- dnssecEnabled: false,
- domainName: "mycdn.test.test",
- id: 2,
- lastUpdated: new Date(),
- name: "test"
- }
- ]
- ]);
+ private readonly cdns = [
+ {
+ dnssecEnabled: false,
+ domainName: "-",
+ id: 1,
+ lastUpdated: new Date(),
+ name: "ALL"
+ },
+ {
+ dnssecEnabled: false,
+ domainName: "mycdn.test.test",
+ id: 2,
+ lastUpdated: new Date(),
+ name: "test"
+ }
+ ];
- public async getCDNs(id: number): Promise<CDN>;
- public async getCDNs(): Promise<Map<string, CDN>>;
+ public async getCDNs(id: number): Promise<ResponseCDN>;
+ public async getCDNs(): Promise<Array<ResponseCDN>>;
/**
* Gets one or all CDNs from Traffic Ops
*
@@ -54,7 +48,7 @@ export class CDNService {
* passed.
* (In the event that `id` is passed but does not match any CDN, `null` will be emitted)
*/
- public async getCDNs(id?: number): Promise<Map<string, CDN> | CDN> {
+ public async getCDNs(id?: number): Promise<Array<ResponseCDN> | ResponseCDN> {
if (id !== undefined) {
const cdn = Array.from(this.cdns.values()).filter(c=>c.id===id)[0];
if (!cdn) {
diff --git a/experimental/traffic-portal/src/app/api/testing/index.ts b/experimental/traffic-portal/src/app/api/testing/index.ts
index dd9a702ffa..d73f26a05b 100644
--- a/experimental/traffic-portal/src/app/api/testing/index.ts
+++ b/experimental/traffic-portal/src/app/api/testing/index.ts
@@ -58,7 +58,8 @@ import { UserService as TestingUserService } from "./user.service";
{provide: ProfileService, useClass: TestingProfileService},
{provide: ServerService, useClass: TestingServerService},
{provide: TypeService, useClass: TestingTypeService},
- {provide: UserService, useClass: TestingUserService}
+ {provide: UserService, useClass: TestingUserService},
+ TestingServerService
]
})
export class APITestingModule { }
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 e5cc127d0a..4e2f765cbe 100644
--- a/experimental/traffic-portal/src/app/api/testing/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/server.service.ts
@@ -44,7 +44,7 @@ function serverCheck(server: Server): Servercheck {
@Injectable()
export class ServerService {
- private readonly servers = new Array<Server>();
+ public servers = new Array<Server>();
private readonly statuses = [
{
diff --git a/experimental/traffic-portal/src/app/api/type.service.ts b/experimental/traffic-portal/src/app/api/type.service.ts
index 94c8e26c16..ff409a6ca3 100644
--- a/experimental/traffic-portal/src/app/api/type.service.ts
+++ b/experimental/traffic-portal/src/app/api/type.service.ts
@@ -13,6 +13,7 @@
*/
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
+import type { TypeFromResponse } from "trafficops-types";
import type { Type } from "src/app/models";
@@ -78,8 +79,8 @@ export class TypeService extends APIService {
* @param useInTable The database table for which to retrieve Types.
* @returns The requested Types.
*/
- public async getTypesInTable(useInTable: UseInTable): Promise<Array<Type>> {
- return this.get<Array<Type>>("types", undefined, {useInTable}).toPromise().catch(
+ public async getTypesInTable(useInTable: UseInTable): Promise<Array<TypeFromResponse>> {
+ return this.get<Array<TypeFromResponse>>("types", undefined, {useInTable}).toPromise().catch(
(e) => {
console.error("Failed to get Types:", e);
return [];
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.html b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.html
new file mode 100644
index 0000000000..3f4ceb6025
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.html
@@ -0,0 +1,87 @@
+<!--
+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="!cacheGroup"></tp-loading>
+ <form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="cacheGroup">
+ <mat-card-content>
+ <mat-form-field>
+ <mat-label>Name</mat-label>
+ <input matInput type="text" name="name" [(ngModel)]="cacheGroup.name" required/>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Type</mat-label>
+ <mat-select name="type" [formControl]="typeCtrl" required (selectionChange)="showErrors = false">
+ <mat-option *ngFor="let type of types" [value]="type.id">{{type.name}}</mat-option>
+ </mat-select>
+ <mat-hint *ngIf="selectedTypeDescription">{{selectedTypeDescription}}</mat-hint>
+ <mat-error *ngIf="showErrors && typeCtrl.invalid">Required</mat-error>
+ </mat-form-field>
+ <mat-form-field *ngIf="!new">
+ <mat-label>ID</mat-label>
+ <input matInput type="text" name="id" disabled readonly [defaultValue]="cacheGroup.id" />
+ </mat-form-field>
+ <mat-form-field *ngIf="!new">
+ <mat-label>Last Updated</mat-label>
+ <input matInput type="text" name="lastUpdated" disabled readonly [defaultValue]="cacheGroup.lastUpdated" />
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Parent</mat-label>
+ <mat-select [(ngModel)]="cacheGroup.parentCachegroupId" name="parentCacheGroup">
+ <mat-option [value]="null">-- None --</mat-option>
+ <mat-optgroup label="Cache Groups">
+ <mat-option *ngFor="let cg of parentCacheGroups()" [value]="cg.id">{{cg.name}}</mat-option>
+ </mat-optgroup>
+ </mat-select>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Secondary Parent</mat-label>
+ <mat-select [(ngModel)]="cacheGroup.secondaryParentCachegroupId" name="secondaryParentCacheGroup">
+ <mat-option [value]="null">-- None --</mat-option>
+ <mat-optgroup label="Cache Groups">
+ <mat-option *ngFor="let cg of secondaryParentCacheGroups()" [value]="cg.id">{{cg.name}}</mat-option>
+ </mat-optgroup>
+ </mat-select>
+ </mat-form-field>
+ <div class="pair">
+ <mat-form-field>
+ <mat-label>Latitude</mat-label>
+ <input type="number" matInput min="-90" max="90" step="0.001" name="latitude" [(ngModel)]="cacheGroup.latitude" required/>
+ </mat-form-field>
+ <mat-form-field>
+ <mat-label>Longitude</mat-label>
+ <input type="number" matInput min="-180" max="180" step="0.001" name="longitude" [(ngModel)]="cacheGroup.longitude" required/>
+ </mat-form-field>
+ </div>
+ <mat-form-field>
+ <mat-label>Allowed Client Localization Methods</mat-label>
+ <mat-select name="localizationMethods" multiple required [(ngModel)]="cacheGroup.localizationMethods" (selectionChange)="updateLocalizationMethods()">
+ <mat-option *ngFor="let method of localizationMethods" [value]="method">{{localizationMethodToString(method)}}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ <div class="pair">
+ <mat-form-field>
+ <mat-label>Fallbacks</mat-label>
+ <mat-select name="fallbacks" multiple [(ngModel)]="cacheGroup.fallbacks">
+ <mat-option *ngFor="let cg of fallbacks()">{{cg.name}}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ <mat-checkbox name="fallbackToClosest" [(ngModel)]="cacheGroup.fallbackToClosest">Fall back to closest on localization failure</mat-checkbox>
+ </div>
+ </mat-card-content>
+ <mat-card-actions align="end">
+ <button mat-raised-button type="button" *ngIf="!new" color="warn" (click)="delete()">Delete</button>
+ <button mat-raised-button type="submit" color="primary">Save</button>
+ </mat-card-actions>
+ </form>
+</mat-card>
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss
new file mode 100644
index 0000000000..7b4f84436e
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.scss
@@ -0,0 +1,64 @@
+/*
+* 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.
+*/
+
+form {
+ max-width: calc(100% - 32px);
+}
+
+mat-card {
+ margin: 1em auto;
+ box-sizing: border-box;
+ width: 80%;
+ min-width: 350px;
+
+ mat-card-content {
+ display: grid;
+ grid-template-columns: 1fr;
+ row-gap: 2em;
+ margin: 1em auto 50px;
+
+ .pair {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ column-gap: 48px;
+ align-items: center;
+ justify-content: space-between;
+ }
+ }
+}
+
+/* Chosen more or less arbitrarily. */
+@media(max-width:1200px) {
+ mat-card form mat-card-content .pair {
+ column-gap: 8px;
+ }
+}
+
+/* This is roughly the point where 350px is 80% of the viewport. */
+@media(max-width:440px) {
+ mat-card {
+ min-width: unset;
+ width: 100%;
+ form {
+ min-width: 100%;
+ mat-card-content {
+ margin: 1em auto;
+ .pair {
+ grid-template-columns: 1fr;
+ row-gap: 2em;
+ }
+ }
+ }
+ }
+}
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.spec.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.spec.ts
new file mode 100644
index 0000000000..8497ee98a2
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.spec.ts
@@ -0,0 +1,299 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import { HarnessLoader } from "@angular/cdk/testing";
+import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatButtonHarness } from "@angular/material/button/testing";
+import { MatDialogModule } from "@angular/material/dialog";
+import { MatDialogHarness } from "@angular/material/dialog/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { ReplaySubject } from "rxjs";
+
+import { CacheGroupService, TypeService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
+
+import { CacheGroupDetailsComponent } from "./cache-group-details.component";
+
+describe("CacheGroupDetailsComponent", () => {
+ let component: CacheGroupDetailsComponent;
+ let fixture: ComponentFixture<CacheGroupDetailsComponent>;
+ let route: ActivatedRoute;
+ let paramMap: jasmine.Spy;
+ let loader: HarnessLoader;
+ let cgSrv: CacheGroupService;
+
+ const headerSvc = jasmine.createSpyObj([],{headerHidden: new ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [ CacheGroupDetailsComponent ],
+ imports: [
+ APITestingModule,
+ RouterTestingModule.withRoutes([
+ {
+ component: CacheGroupDetailsComponent,
+ path: ""
+ },
+ {
+ component: CacheGroupDetailsComponent,
+ path: "cache-groups/:id"
+ }
+ ]),
+ MatDialogModule,
+ NoopAnimationsModule,
+
+ ],
+ providers: [ { provide: TpHeaderService, useValue: headerSvc } ]
+ }).compileComponents();
+
+ route = TestBed.inject(ActivatedRoute);
+ paramMap = spyOn(route.snapshot.paramMap, "get");
+ paramMap.and.returnValue(null);
+ fixture = TestBed.createComponent(CacheGroupDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
+ cgSrv = TestBed.inject(CacheGroupService);
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("new Cache Group", async () => {
+ paramMap.and.returnValue("new");
+
+ fixture = TestBed.createComponent(CacheGroupDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(paramMap).toHaveBeenCalled();
+ expect(component.cacheGroup).not.toBeNull();
+ expect(component.cacheGroup.name).toBe("");
+ expect(component.new).toBeTrue();
+ });
+
+ it("existing Cache Group", async () => {
+ const cgs = await cgSrv.getCacheGroups();
+ if (cgs.length < 1) {
+ return fail("no testing Cache Groups - please add Cache Groups to the default set or fix the accidental deletion thereof");
+ }
+ const cg = cgs[0];
+ paramMap.and.returnValue(String(cg.id));
+
+ fixture = TestBed.createComponent(CacheGroupDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ await component.ngOnInit();
+ expect(paramMap).toHaveBeenCalled();
+ expect(component.cacheGroup).not.toBeNull();
+ expect(component.cacheGroup.name).toBe(cg.name, component.cacheGroup);
+ expect(component.new).toBeFalse();
+ });
+
+ it("throws an error when the ID in the URL doesn't exist", async () => {
+ paramMap.and.returnValue("-1");
+ // This doesn't actually throw, but for unknown reasons it causes
+ // earlier tests to fail if were to throw. Somehow, the Component is
+ // getting a blank CG list from the testing API, and that should never
+ // be possible.
+ await expectAsync(component.ngOnInit()).toBeResolved();
+ paramMap.and.returnValue("testquest");
+ await expectAsync(component.ngOnInit()).toBeRejected();
+ });
+
+ it("gets available parent Cache Groups", async () => {
+ const cgs = await cgSrv.getCacheGroups();
+ expect(cgs.length).toBeGreaterThan(0);
+ component.cacheGroups = cgs;
+ component.cacheGroup.fallbacks = [`${cgs[0].name}-fallback-test`];
+
+ expect(component.parentCacheGroups()).toEqual(component.cacheGroups);
+ if (component.cacheGroups.length < 1) {
+ return fail("need at least one cache group to test parentage");
+ }
+ const initialLength = component.parentCacheGroups().length;
+ const cg = component.cacheGroups[0];
+ const original = component.cacheGroup;
+ component.cacheGroup = {
+ ...component.cacheGroup,
+ secondaryParentCachegroupId: cg.id,
+ secondaryParentCachegroupName: cg.name
+ };
+ expect(component.parentCacheGroups()).not.toContain(cg);
+ expect(component.parentCacheGroups().length).toBe(initialLength-1);
+ component.cacheGroup = original;
+ });
+ it("gets available secondary parent Cache Groups", async () => {
+ const cgs = await cgSrv.getCacheGroups();
+ expect(cgs.length).toBeGreaterThan(0);
+ component.cacheGroups = cgs;
+ component.cacheGroup.fallbacks = [`${cgs[0].name}-fallback-test`];
+
+ expect(component.secondaryParentCacheGroups()).toEqual(component.cacheGroups);
+ if (component.cacheGroups.length < 1) {
+ return fail("need at least one cache group to test parentage");
+ }
+ const initialLength = component.secondaryParentCacheGroups().length;
+ const cg = component.cacheGroups[0];
+ const original = component.cacheGroup;
+ component.cacheGroup = {
+ ...component.cacheGroup,
+ parentCachegroupId: cg.id,
+ parentCachegroupName: cg.name
+ };
+ expect(component.secondaryParentCacheGroups()).not.toContain(cg);
+ expect(component.secondaryParentCacheGroups().length).toBe(initialLength-1);
+ component.cacheGroup = original;
+ });
+ it("gets available fallback Cache Groups", async () => {
+ const cgs = await cgSrv.getCacheGroups();
+ expect(cgs.length).toBeGreaterThan(2);
+ component.cacheGroups = cgs;
+ component.cacheGroup.fallbacks = [`${cgs[0].name}-fallback-test`];
+
+ expect(component.fallbacks()).toEqual(component.cacheGroups);
+ if (component.cacheGroups.length < 1) {
+ return fail("need at least one cache group to test parentage");
+ }
+ const initialLength = component.fallbacks().length;
+ const [cg1, cg2] = component.cacheGroups;
+ const original = component.cacheGroup;
+ component.cacheGroup = {
+ ...component.cacheGroup,
+ parentCachegroupId: cg1.id,
+ parentCachegroupName: cg1.name,
+ secondaryParentCachegroupId: cg2.id,
+ secondaryParentCachegroupName: cg2.name
+ };
+ const fallbacks = component.fallbacks();
+ expect(fallbacks).not.toContain(cg1);
+ expect(fallbacks).not.toContain(cg2);
+ expect(fallbacks.length).toBe(initialLength-2);
+ component.cacheGroup = original;
+ });
+
+ it("refuses to delete new Cache Groups", async () => {
+ component.new = true;
+ const spy = spyOn(cgSrv, "deleteCacheGroup");
+
+ const asyncExpectation = expectAsync(component.delete()).toBeResolvedTo(undefined);
+ await component.delete();
+ const dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ expect(dialogs.length).toBe(0);
+ expect(spy).not.toHaveBeenCalled();
+
+ await asyncExpectation;
+ });
+ it("deletes existing Cache Groups", async () => {
+ const spy = spyOn(cgSrv, "deleteCacheGroup").and.callThrough();
+ let cgs = await cgSrv.getCacheGroups();
+ const initialLength = cgs.length;
+ if (initialLength < 1) {
+ return fail("need at least one Cache Group");
+ }
+ const cg = cgs[0];
+ component.cacheGroup = cg;
+ component.new = false;
+
+ const asyncExpectation = expectAsync(component.delete()).toBeResolvedTo(undefined);
+ const dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ if (dialogs.length !== 1) {
+ return fail(`failed to open dialog; ${dialogs.length} dialogs found`);
+ }
+ const dialog = dialogs[0];
+ const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/}));
+ if (buttons.length !== 1) {
+ return fail(`'confirm' button not found; ${buttons.length} buttons found`);
+ }
+ await buttons[0].click();
+
+ expect(spy).toHaveBeenCalledOnceWith(cg);
+
+ cgs = await cgSrv.getCacheGroups();
+ expect(cgs).not.toContain(cg);
+ expect(cgs.length).toBe(initialLength - 1);
+
+ await asyncExpectation;
+ });
+
+ it("creates new Cache Groups", async () => {
+ const createSpy = spyOn(cgSrv, "createCacheGroup").and.callThrough();
+ const updateSpy = spyOn(cgSrv, "updateCacheGroup").and.callThrough();
+
+ component.new = true;
+ const cg = component.cacheGroup;
+ const typeSrv = TestBed.inject(TypeService);
+ const types = await typeSrv.getTypesInTable("cachegroup");
+ if (types.length < 1) {
+ return fail("no cg Types");
+ }
+ component.typeCtrl.setValue(types[0].id);
+ await expectAsync(component.submit(new Event("click"))).toBeResolvedTo(undefined);
+ expect(createSpy).toHaveBeenCalledOnceWith(cg);
+ expect(updateSpy).not.toHaveBeenCalled();
+ expect(component.new).toBeFalse();
+ });
+
+ it("updates existing Cache Groups", async () => {
+ const createSpy = spyOn(cgSrv, "createCacheGroup").and.callThrough();
+ component.new = false;
+ const cg = component.cacheGroup;
+ const typeSrv = TestBed.inject(TypeService);
+ const types = await typeSrv.getTypesInTable("cachegroup");
+ if (types.length < 1) {
+ return fail("no cg Types");
+ }
+ const updateSpy = spyOn(cgSrv, "updateCacheGroup").and.returnValue(new Promise(r => r(component.cacheGroup)));
+ component.typeCtrl.setValue(types[0].id);
+ await expectAsync(component.submit(new Event("click"))).toBeResolvedTo(undefined);
+ expect(updateSpy).toHaveBeenCalledOnceWith(cg);
+ expect(createSpy).not.toHaveBeenCalled();
+ expect(component.new).toBeFalse();
+ });
+
+ it("doesn't submit a request when the form is invalid", async () => {
+ const createSpy = spyOn(cgSrv, "createCacheGroup").and.callThrough();
+ const updateSpy = spyOn(cgSrv, "updateCacheGroup").and.callThrough();
+
+ component.typeCtrl.setErrors({something: true});
+ await expectAsync(component.submit(new Event("click"))).toBeResolvedTo(undefined);
+ expect(updateSpy).not.toHaveBeenCalled();
+ expect(createSpy).not.toHaveBeenCalled();
+
+ component.typeCtrl.setErrors(null);
+ component.typeCtrl.setValue(null);
+ await expectAsync(component.submit(new Event("click"))).toBeResolvedTo(undefined);
+ expect(updateSpy).not.toHaveBeenCalled();
+ expect(createSpy).not.toHaveBeenCalled();
+ });
+
+ it("gets Type descriptions", async () => {
+ const typeSrv = TestBed.inject(TypeService);
+ const types = await typeSrv.getTypesInTable("cachegroup");
+ if (types.length < 1) {
+ return fail("no cg Types");
+ }
+ // Unsure why this is necessary
+ component.types = types;
+ component.typeCtrl.setValue(types[0].id);
+ expect(component.selectedTypeDescription).toBe(types[0].description);
+ component.typeCtrl.setValue(null);
+ expect(component.selectedTypeDescription).toBeNull();
+ });
+});
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
new file mode 100644
index 0000000000..23c89f04dd
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-details/cache-group-details.component.ts
@@ -0,0 +1,239 @@
+/*
+* 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, type OnInit } from "@angular/core";
+import { FormControl } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { LocalizationMethod, localizationMethodToString, TypeFromResponse, type ResponseCacheGroup } from "trafficops-types";
+
+import { CacheGroupService, TypeService } from "src/app/api";
+import { DecisionDialogComponent, type DecisionDialogData } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
+
+/**
+ * The controller for the form page for creating/updating a Cache Group.
+ */
+@Component({
+ selector: "tp-cache-group-details",
+ styleUrls: ["./cache-group-details.component.scss"],
+ templateUrl: "./cache-group-details.component.html",
+})
+export class CacheGroupDetailsComponent implements OnInit {
+ public new = false;
+ public cacheGroup: ResponseCacheGroup = {
+ fallbackToClosest: true,
+ fallbacks: [],
+ id: -1,
+ lastUpdated: new Date(),
+ latitude: 0,
+ localizationMethods: [
+ LocalizationMethod.CZ,
+ LocalizationMethod.DEEP_CZ,
+ LocalizationMethod.GEO
+ ],
+ longitude: 0,
+ name: "",
+ parentCachegroupId: null,
+ parentCachegroupName: null,
+ secondaryParentCachegroupId: null,
+ secondaryParentCachegroupName: null,
+ shortName: "",
+ typeId: -1,
+ typeName: ""
+ };
+
+ public cacheGroups: Array<ResponseCacheGroup> = [];
+ public types: Array<TypeFromResponse> = [];
+ public typeCtrl = new FormControl<number | null>(null);
+ public showErrors = false;
+
+ public readonly localizationMethods: readonly LocalizationMethod[] = [
+ LocalizationMethod.CZ,
+ LocalizationMethod.DEEP_CZ,
+ LocalizationMethod.GEO
+ ];
+
+ /**
+ * A description of the Cache Group's selected Type - or `null` if no Type
+ * is (yet) selected.
+ */
+ public get selectedTypeDescription(): string | null {
+ const type = this.types.find(t => t.id === this.typeCtrl.value);
+ if (!type) {
+ return null;
+ }
+ return type.description;
+ }
+
+ constructor(
+ private readonly route: ActivatedRoute,
+ private readonly api: CacheGroupService,
+ private readonly typesAPI: TypeService,
+ private readonly location: Location,
+ private readonly dialog: MatDialog,
+ private readonly header: TpHeaderService
+ ) {
+ }
+
+ public localizationMethodToString = localizationMethodToString;
+
+ /**
+ * Angular lifecycle hook where data is initialized.
+ */
+ public async ngOnInit(): Promise<void> {
+ const ID = this.route.snapshot.paramMap.get("id");
+ if (ID === null) {
+ console.error("missing required route parameter 'id'");
+ return;
+ }
+
+ const cgsPromise = this.api.getCacheGroups().then(cgs => this.cacheGroups = cgs);
+ const typePromise = this.typesAPI.getTypesInTable("cachegroup").then(ts => this.types = ts);
+ if (ID === "new") {
+ this.setTitle();
+ this.new = true;
+ await Promise.all([typePromise, cgsPromise]);
+ return;
+ }
+ const numID = parseInt(ID, 10);
+ if (Number.isNaN(numID)) {
+ throw new Error(`route parameter 'id' was non-number: ${ID}`);
+ }
+
+ await cgsPromise;
+ const idx = this.cacheGroups.findIndex(c => c.id === numID);
+ if (idx < 0) {
+ console.error(`no such Cache Group: #${ID}`);
+ return;
+ }
+ this.cacheGroup = this.cacheGroups.splice(idx, 1)[0];
+ this.typeCtrl.setValue(this.cacheGroup.typeId);
+ this.updateLocalizationMethods();
+ this.setTitle();
+ await typePromise;
+ }
+
+ /**
+ * Sets the title of the page to either "new" or the name of the displayed
+ * Cache Group, depending on the value of
+ * {@link CacheGroupDetailsComponent.new}.
+ */
+ private setTitle(): void {
+ const title = this.new ? "New Cache Group" : `Cache Group: ${this.cacheGroup.name}`;
+ this.header.headerTitle.next(title);
+ }
+
+ /**
+ * Gets all Cache Groups eligible to be the parent of this Cache Group.
+ *
+ * @returns Every Cache Group except this one and its secondary parent (if
+ * it has one) and any of its "fallbacks".
+ */
+ public parentCacheGroups(): Array<ResponseCacheGroup> {
+ return this.cacheGroups.filter(
+ cg => this.cacheGroup.fallbacks.every(f => f !== cg.name) && cg.id !== this.cacheGroup.secondaryParentCachegroupId
+ );
+ }
+
+ /**
+ * Gets all Cache Groups eligible to be the secondary parent of this Cache
+ * Group.
+ *
+ * @returns Every Cache Group except this one and its primary parent (if it
+ * has one) and any of its "fallbacks".
+ */
+ public secondaryParentCacheGroups(): Array<ResponseCacheGroup> {
+ return this.cacheGroups.filter(
+ cg => this.cacheGroup.fallbacks.every(f => f !== cg.name) && cg.id !== this.cacheGroup.parentCachegroupId
+ );
+ }
+
+ /**
+ * Gets all Cache Groups eligible to be a "fallback" for this Cache Group.
+ *
+ * @returns Every Cache Group except this one and its parent(s).
+ */
+ public fallbacks(): Array<ResponseCacheGroup> {
+ return this.cacheGroups.filter(
+ cg => cg.id !== this.cacheGroup.parentCachegroupId && cg.id !== this.cacheGroup.secondaryParentCachegroupId
+ );
+ }
+
+ /**
+ * Deletes the Cache Group.
+ */
+ public async delete(): Promise<void> {
+ if (this.new) {
+ console.error("Unable to delete new Cache Group");
+ return;
+ }
+ const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(
+ DecisionDialogComponent,
+ {
+ data: {
+ message: `Are you sure you want to delete Cache Group ${this.cacheGroup.name} (#${this.cacheGroup.id})?`,
+ title: "Confirm Delete"
+ }
+ }
+ );
+ ref.afterClosed().subscribe(result => {
+ if (result) {
+ this.api.deleteCacheGroup(this.cacheGroup);
+ this.location.replaceState("core/cache-groups");
+ }
+ });
+ }
+
+ /**
+ * Submits new/updated Cache Group.
+ *
+ * @param e HTML form submission event.
+ */
+ public async submit(e: Event): Promise<void> {
+ e.preventDefault();
+ e.stopPropagation();
+ this.showErrors = true;
+ if (this.typeCtrl.invalid) {
+ return;
+ }
+ const {value} = this.typeCtrl;
+ if (value === null) {
+ return console.error("cannot create Cache Group of null Type");
+ }
+ this.cacheGroup.typeId = value;
+ this.cacheGroup.shortName = this.cacheGroup.name;
+ if (this.new) {
+ this.cacheGroup = await this.api.createCacheGroup(this.cacheGroup);
+ this.new = false;
+ } else {
+ this.cacheGroup = await this.api.updateCacheGroup(this.cacheGroup);
+ }
+ this.setTitle();
+ }
+
+ /**
+ * Updates the localization methods of the Cache Group based on user
+ * selection.
+ *
+ * Specifically, selecting none is not allowed, so this will change to
+ * select all available methods if none are selected.
+ */
+ public updateLocalizationMethods(): void {
+ if (this.cacheGroup.localizationMethods.length === 0) {
+ this.cacheGroup.localizationMethods = [...this.localizationMethods];
+ }
+ }
+}
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.html b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.html
index 500304b1a9..eff44869e3 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.html
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.html
@@ -13,7 +13,7 @@ limitations under the License.
-->
<mat-card class="table-page-content">
<div class="search-container">
- <input type="search" name="fuzzControl" aria-label="Fuzzy Search Servers" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()"/>
+ <input type="search" name="fuzzControl" aria-label="Fuzzy Search Cache Groups" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()"/>
</div>
<tp-generic-table
[data]="cacheGroups | async"
@@ -24,3 +24,5 @@ limitations under the License.
(contextMenuAction)="handleContextMenu($event)">
</tp-generic-table>
</mat-card>
+
+<button class="page-fab" mat-fab title="Create a new Cache Group" *ngIf="auth.hasPermission('CACHE-GROUP:CREATE')" routerLink="new"><mat-icon>add</mat-icon></button>
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.spec.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.spec.ts
index e44e7c142e..044c162f6f 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.spec.ts
@@ -11,20 +11,53 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { HarnessLoader } from "@angular/cdk/testing";
+import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
import { HttpClientModule } from "@angular/common/http";
-import { type ComponentFixture, TestBed, fakeAsync } from "@angular/core/testing";
+import { type ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
+import { MatButtonHarness } from "@angular/material/button/testing";
+import { MatDialogModule } from "@angular/material/dialog";
+import { MatDialogHarness } from "@angular/material/dialog/testing";
+import { MatSelectModule } from "@angular/material/select";
+import { MatSelectHarness } from "@angular/material/select/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
-import {ReplaySubject} from "rxjs";
+import type { ValueFormatterParams, ValueGetterParams } from "ag-grid-community";
+import { ReplaySubject } from "rxjs";
+import { AlertLevel, LocalizationMethod, localizationMethodToString, type ResponseCacheGroup } from "trafficops-types";
+import { CacheGroupService, CDNService } from "src/app/api";
import { APITestingModule } from "src/app/api/testing";
+import { AlertService } from "src/app/shared/alert/alert.service";
+import { isAction } from "src/app/shared/generic-table/generic-table.component";
import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
import { CacheGroupTableComponent } from "./cache-group-table.component";
+const sampleCG: ResponseCacheGroup = {
+ fallbackToClosest: true,
+ fallbacks: [],
+ id: 1,
+ lastUpdated: new Date(),
+ latitude: 0,
+ localizationMethods: [],
+ longitude: 0,
+ name: "sample",
+ parentCachegroupId: null,
+ parentCachegroupName: null,
+ secondaryParentCachegroupId: null,
+ secondaryParentCachegroupName: null,
+ shortName: "sample",
+ typeId: 1,
+ typeName: "some type"
+};
+
describe("CacheGroupTableComponent", () => {
let component: CacheGroupTableComponent;
let fixture: ComponentFixture<CacheGroupTableComponent>;
+ let loader: HarnessLoader;
const headerSvc = jasmine.createSpyObj([],{headerHidden: new ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
beforeEach(async () => {
@@ -34,15 +67,19 @@ describe("CacheGroupTableComponent", () => {
APITestingModule,
HttpClientModule,
ReactiveFormsModule,
- RouterTestingModule
+ RouterTestingModule,
+ MatDialogModule,
+ NoopAnimationsModule,
+ MatSelectModule
],
providers: [
- { provide: TpHeaderService, useValue: headerSvc}
+ { provide: TpHeaderService, useValue: headerSvc},
]
}).compileComponents();
fixture = TestBed.createComponent(CacheGroupTableComponent);
component = fixture.componentInstance;
fixture.detectChanges();
+ loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
});
it("should create", () => {
@@ -58,4 +95,288 @@ describe("CacheGroupTableComponent", () => {
it("doesn't throw errors when handling context menu events", () => {
expect(()=>component.handleContextMenu({action: "something", data: []})).not.toThrow();
});
+
+ it("renders parent cache group cells", () => {
+ const col = component.columnDefs.find(d => d.field === "parentCachegroupName");
+ if (!col) {
+ return fail("parentCachegroupName column not found");
+ }
+ const {valueFormatter} = col;
+ if (typeof(valueFormatter) !== "function") {
+ return fail(`invalid valueFormatter found on parentCachegroupName column definition: ${valueFormatter}`);
+ }
+ let value = valueFormatter({data: sampleCG} as ValueFormatterParams);
+ expect(value).toBe("");
+ value = valueFormatter({data: {...sampleCG, parentCachegroupId: 1, parentCachegroupName: "sample"}} as ValueFormatterParams);
+ expect(value).toBe("sample (#1)");
+ });
+ it("renders secondary parent cache group cells", () => {
+ const col = component.columnDefs.find(d => d.field === "secondaryParentCachegroupName");
+ if (!col) {
+ return fail("secondaryParentCachegroupName column not found");
+ }
+ const {valueFormatter} = col;
+ if (typeof(valueFormatter) !== "function") {
+ return fail(`invalid valueFormatter found on secondaryParentCachegroupName column definition: ${valueFormatter}`);
+ }
+ let value = valueFormatter({data: sampleCG} as ValueFormatterParams);
+ expect(value).toBe("");
+ value = valueFormatter({
+ data: {
+ ...sampleCG,
+ secondaryParentCachegroupId: 1,
+ secondaryParentCachegroupName: "sample"
+ }
+ } as ValueFormatterParams);
+ expect(value).toBe("sample (#1)");
+ });
+ it("renders type cells", () => {
+ const col = component.columnDefs.find(d => d.field === "typeName");
+ if (!col) {
+ return fail("type column not found");
+ }
+ const {valueFormatter} = col;
+ if (typeof(valueFormatter) !== "function") {
+ return fail(`invalid valueFormatter found on type column definition: ${valueFormatter}`);
+ }
+ const value = valueFormatter({data: {...sampleCG, typeId: 1, typeName: "sample"}} as ValueFormatterParams);
+ expect(value).toBe("sample (#1)");
+ });
+ it("renders localization methods cells", () => {
+ const col = component.columnDefs.find(d => d.field === "localizationMethods");
+ if (!col) {
+ return fail("localizationMethods column not found");
+ }
+ const {valueGetter} = col;
+ if (typeof(valueGetter) !== "function") {
+ return fail(`invalid valueGetter found on localizationMethods column definition: ${valueGetter}`);
+ }
+ let value: string = valueGetter({data: {...sampleCG, localizationMethods: []}} as ValueGetterParams);
+ let valueArr = value.split(", ");
+ expect(valueArr.length).toBe(3);
+ expect(valueArr).toContain(localizationMethodToString(LocalizationMethod.CZ));
+ expect(valueArr).toContain(localizationMethodToString(LocalizationMethod.DEEP_CZ));
+ expect(valueArr).toContain(localizationMethodToString(LocalizationMethod.GEO));
+ value = valueGetter({data: {...sampleCG, localizationMethods: [LocalizationMethod.CZ]}} as ValueGetterParams);
+ valueArr = value.split(", ");
+ expect(valueArr.length).toBe(1);
+ expect(valueArr).toContain(localizationMethodToString(LocalizationMethod.CZ));
+ });
+
+ it("has context menu links to individual Cache Groups", () => {
+ let menuItem = component.contextMenuItems.find(i => i.name === "Open in New Tab");
+ if (!menuItem) {
+ return fail("'Open in New Tab' context menu item not found");
+ }
+ if (isAction(menuItem) || typeof(menuItem.href) !== "function") {
+ return fail(`invalid 'Open in New Tab' context menu item; either not a link or has a static href: ${menuItem}`);
+ }
+ expect(menuItem.newTab).toBeTrue();
+ expect(menuItem.href({...sampleCG, id: 5})).toBe("core/cache-groups/5");
+
+ menuItem = component.contextMenuItems.find(i => i.name === "Edit");
+ if (!menuItem) {
+ return fail("'Edit' context menu item not found");
+ }
+ if (isAction(menuItem) || typeof(menuItem.href) !== "function") {
+ return fail(`invalid 'Edit' context menu item; either not a link or has a static href: ${menuItem}`);
+ }
+ expect(menuItem.newTab).toBeFalsy();
+ expect(menuItem.href({...sampleCG, id: 5})).toBe("core/cache-groups/5");
+ });
+ it("doesn't allow selection of unimplemented context menu items", () => {
+ let menuItem = component.contextMenuItems.find(i => i.name === "Manage ASNs");
+ if (!menuItem) {
+ return fail("'Manage ASNs' context menu item not found");
+ }
+ if (!isAction(menuItem)) {
+ return fail(`Invalid 'Manage ASNs' context menu item; not an action: ${menuItem}`);
+ }
+ if (typeof(menuItem.disabled) !== "function") {
+ return fail("'Manage ASNs' context menu item should be disabled, but no disabled function is defined");
+ }
+ if (menuItem.multiRow) {
+ expect(menuItem.disabled([sampleCG])).toBeTrue();
+ } else {
+ expect(menuItem.disabled(sampleCG)).toBeTrue();
+ }
+
+ menuItem = component.contextMenuItems.find(i => i.name === "Manage Servers");
+ if (!menuItem) {
+ return fail("'Manage Servers' context menu item not found");
+ }
+ if (!isAction(menuItem)) {
+ return fail(`Invalid 'Manage Servers' context menu item; not an action: ${menuItem}`);
+ }
+ if (typeof(menuItem.disabled) !== "function") {
+ return fail("'Manage Servers' context menu item should be disabled, but no disabled function is defined");
+ }
+ if (menuItem.multiRow) {
+ expect(menuItem.disabled([sampleCG])).toBeTrue();
+ } else {
+ expect(menuItem.disabled(sampleCG)).toBeTrue();
+ }
+
+ menuItem = component.contextMenuItems.find(i => i.name === "Manage Parameters");
+ if (!menuItem) {
+ return fail("'Manage Parameters' context menu item not found");
+ }
+ if (!isAction(menuItem)) {
+ return fail(`Invalid 'Manage Parameters' context menu item; not an action: ${menuItem}`);
+ }
+ if (typeof(menuItem.disabled) !== "function") {
+ return fail("'Manage Parameters' context menu item should be disabled, but no disabled function is defined");
+ }
+ if (menuItem.multiRow) {
+ expect(menuItem.disabled([sampleCG])).toBeTrue();
+ } else {
+ expect(menuItem.disabled(sampleCG)).toBeTrue();
+ }
+ });
+
+ it("initializes from query string parameters", fakeAsync(() => {
+ const router = TestBed.inject(Router);
+ router.navigate([], {queryParams: {search: "testquest"}});
+ component.ngOnInit();
+ tick();
+ expectAsync(component.fuzzySubject.toPromise()).toBeResolvedTo("testquest");
+ }));
+
+ it("deletes Cache Groups", async () => {
+ expect(() => component.handleContextMenu({action: "delete", data: []})).not.toThrow();
+ let dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ expect(dialogs.length).toBe(0);
+
+ const cgSrv = TestBed.inject(CacheGroupService);
+ const spy = spyOn(cgSrv, "deleteCacheGroup");
+
+ component.handleContextMenu({action: "delete", data: sampleCG});
+ dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ if (dialogs.length !== 1) {
+ return fail(`dialog should have opened for deleting, actual number of dialogs: ${dialogs.length}`);
+ }
+ let dialog = dialogs[0];
+ await dialog.close();
+ expect(spy).not.toHaveBeenCalled();
+
+ component.handleContextMenu({action: "delete", data: sampleCG});
+ dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ if (dialogs.length !== 1) {
+ return fail(`dialog should have opened for deleting, actual number of dialogs: ${dialogs.length}`);
+ }
+ dialog = dialogs[0];
+ const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/}));
+ if (buttons.length !== 1) {
+ return fail(`'Confirm' button not found; expected one, got: ${buttons.length}`);
+ }
+ const button = buttons[0];
+ await button.click();
+ expect(spy).toHaveBeenCalled();
+ });
+ it("queues Cache Group updates", async () => {
+ const cgSrv = TestBed.inject(CacheGroupService);
+ const spy = spyOn(cgSrv, "queueCacheGroupUpdates").and.returnValue(
+ new Promise(r => r({
+ action: "queue",
+ cachegroupID: 1,
+ cachegroupName: "testquest",
+ cdn: "doesn't matter",
+ serverNames: ["testquest"],
+ }))
+ );
+ const alertSrv = TestBed.inject(AlertService);
+ const alertSpy = spyOn(alertSrv, "newAlert");
+
+ expect(() => component.handleContextMenu({action: "queue", data: [sampleCG]})).not.toThrow();
+ let dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ expect(dialogs.length).toBe(1);
+ let dialog = dialogs[0];
+ await dialog.close();
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(alertSpy).not.toHaveBeenCalled();
+
+ component.handleContextMenu({action: "queue", data: sampleCG});
+ dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ if (dialogs.length !== 1) {
+ return fail(`dialog should have opened for queuing, actual number of dialogs: ${dialogs.length}`);
+ }
+ dialog = dialogs[0];
+ const selects = await dialog.getAllHarnesses(MatSelectHarness);
+ if (selects.length !== 1) {
+ return fail(`dialog should have contained one select input, got: ${selects.length}`);
+ }
+ const select = selects[0];
+ const cdnSrv = TestBed.inject(CDNService);
+ expect(await cdnSrv.getCDNs()).toHaveSize(2);
+ await select.clickOptions();
+ const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/}));
+ if (buttons.length !== 1) {
+ return fail(`'Confirm' button not found; expected one, got: ${buttons.length}`);
+ }
+ const button = buttons[0];
+ await button.click();
+ expect(spy).toHaveBeenCalled();
+ // Jasmine has trouble with the overload signatures; it thinks you can
+ // only call `newAlerts` with a single `Alert` argument. Otherwise, the
+ // below lines could simply be:
+ // expect(alertSpy).toHaveBeenCalledOnceWith(AlertLevel.SUCCESS, "Queued Updates on 1 server");
+ expect(alertSpy).toHaveBeenCalledTimes(1);
+ const [level, text] = alertSpy.calls.all()[0].args as unknown as [string, string];
+ expect(level).toBe(AlertLevel.SUCCESS);
+ expect(text).toBe("Queued Updates on 1 server");
+ });
+ it("clears Cache Group updates", async () => {
+ const cgSrv = TestBed.inject(CacheGroupService);
+ const spy = spyOn(cgSrv, "queueCacheGroupUpdates").and.returnValue(
+ new Promise(r => r({
+ action: "dequeue",
+ cachegroupID: 1,
+ cachegroupName: "testquest",
+ cdn: "doesn't matter",
+ serverNames: ["testquest"],
+ }))
+ );
+ const alertSrv = TestBed.inject(AlertService);
+ const alertSpy = spyOn(alertSrv, "newAlert");
+
+ expect(() => component.handleContextMenu({action: "dequeue", data: sampleCG})).not.toThrow();
+ let dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ expect(dialogs.length).toBe(1);
+ let dialog = dialogs[0];
+ await dialog.close();
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(alertSpy).not.toHaveBeenCalled();
+
+ component.handleContextMenu({action: "dequeue", data: [sampleCG, sampleCG]});
+ dialogs = await loader.getAllHarnesses(MatDialogHarness);
+ if (dialogs.length !== 1) {
+ return fail(`dialog should have opened for dequeuing, actual number of dialogs: ${dialogs.length}`);
+ }
+ dialog = dialogs[0];
+ const selects = await dialog.getAllHarnesses(MatSelectHarness);
+ if (selects.length !== 1) {
+ return fail(`dialog should have contained one select input, got: ${selects.length}`);
+ }
+ const select = selects[0];
+ const cdnSrv = TestBed.inject(CDNService);
+ expect(await cdnSrv.getCDNs()).toHaveSize(2);
+ await select.clickOptions();
+ const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/}));
+ if (buttons.length !== 1) {
+ return fail(`'Confirm' button not found; expected one, got: ${buttons.length}`);
+ }
+ const button = buttons[0];
+ await button.click();
+ expect(spy).toHaveBeenCalled();
+ // Jasmine has trouble with the overload signatures; it thinks you can
+ // only call `newAlerts` with a single `Alert` argument. Otherwise, the
+ // below lines could simply be:
+ // expect(alertSpy).toHaveBeenCalledOnceWith(AlertLevel.SUCCESS, "Queued Updates on 1 server");
+ expect(alertSpy).toHaveBeenCalledTimes(1);
+ const [level, text] = alertSpy.calls.all()[0].args as unknown as [string, string];
+ expect(level).toBe(AlertLevel.SUCCESS);
+ expect(text).toBe("Cleared Updates on 2 servers");
+ });
});
diff --git a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
index fde311adb2..044ace3e9f 100644
--- a/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
+++ b/experimental/traffic-portal/src/app/core/cache-groups/cache-group-table/cache-group-table.component.ts
@@ -13,14 +13,29 @@
*/
import { Component, type OnInit } from "@angular/core";
-import { UntypedFormControl } from "@angular/forms";
+import { FormControl } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
import { ActivatedRoute } from "@angular/router";
+import type { ColDef } from "ag-grid-community";
import { BehaviorSubject } from "rxjs";
+import {
+ AlertLevel,
+ LocalizationMethod,
+ localizationMethodToString,
+ type ResponseCacheGroup,
+ type ResponseCDN
+} from "trafficops-types";
-import { CacheGroupService } from "src/app/api";
-import type { CacheGroup } from "src/app/models";
+import { CacheGroupService, CDNService } from "src/app/api";
+import { AlertService } from "src/app/shared/alert/alert.service";
+import { CurrentUserService } from "src/app/shared/currentUser/current-user.service";
+import {
+ CollectionChoiceDialogComponent,
+ type CollectionChoiceDialogData
+} from "src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component";
+import { DecisionDialogComponent, type DecisionDialogData } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
import type { ContextMenuActionEvent, ContextMenuItem } from "src/app/shared/generic-table/generic-table.component";
-import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
+import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
/**
* CacheGroupTableComponent is the controller for the "Cache Groups" table.
@@ -33,10 +48,13 @@ import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
export class CacheGroupTableComponent implements OnInit {
/** All of the servers which should appear in the table. */
- public readonly cacheGroups: Promise<Array<CacheGroup>>;
+ public cacheGroups: Promise<Array<ResponseCacheGroup>>;
+
+ /** All of the CDNs (on which a user might (de/)queue updates). */
+ public readonly cdns: Promise<Array<ResponseCDN>>;
/** Definitions of the table's columns according to the ag-grid API */
- public columnDefs = [
+ public columnDefs: ColDef[] = [
{
field: "fallbackToClosest",
filter: "tpBooleanFilter",
@@ -55,6 +73,20 @@ export class CacheGroupTableComponent implements OnInit {
headerName: "Last Updated",
hide: false
},
+ {
+ field: "localizationMethods",
+ headerName: "Enabled Localization Methods",
+ hide: true,
+ valueGetter: ({data}: {data: ResponseCacheGroup}): string => {
+ let methods;
+ if (data.localizationMethods.length > 0) {
+ methods = data.localizationMethods;
+ } else {
+ methods = [LocalizationMethod.CZ, LocalizationMethod.DEEP_CZ, LocalizationMethod.GEO];
+ }
+ return methods.map(localizationMethodToString).join(", ");
+ },
+ },
{
field: "latitude",
filter: "agNumberColumnFilter",
@@ -73,14 +105,22 @@ export class CacheGroupTableComponent implements OnInit {
hide: true,
},
{
- field: "parentCacheGroupName",
+ field: "parentCachegroupName",
headerName: "Parent",
- hide: false
+ hide: false,
+ valueFormatter: (
+ {data}: {data: ResponseCacheGroup}
+ ): string => data.parentCachegroupId === null ? "" : `${data.parentCachegroupName} (#${data.parentCachegroupId})`,
},
{
- field: "secondaryParentCacheGroupName",
+ field: "secondaryParentCachegroupName",
headerName: "Secondary Parent",
hide: true,
+ valueFormatter: (
+ {data}: {data: ResponseCacheGroup}
+ ): string => data.secondaryParentCachegroupId === null ?
+ "" :
+ `${data.secondaryParentCachegroupName} (#${data.secondaryParentCachegroupId})`,
},
{
field: "shortName",
@@ -90,14 +130,23 @@ export class CacheGroupTableComponent implements OnInit {
{
field: "typeName",
headerName: "Type",
- hide: false
+ hide: false,
+ valueFormatter: ({data}: {data: ResponseCacheGroup}): string => `${data.typeName} (#${data.typeId})`
}
];
- /** Definitions for the context menu items (which act on augmented cache-group data). */
- public contextMenuItems: Array<ContextMenuItem<CacheGroup>> = [
+ /**
+ * Definitions for the context menu items (which act on augmented
+ * Cache Group data).
+ */
+ public contextMenuItems: Array<ContextMenuItem<ResponseCacheGroup>> = [
+ {
+ href: (selectedRow): string => `core/cache-groups/${selectedRow.id}`,
+ name: "Open in New Tab",
+ newTab: true
+ },
{
- action: "edit",
+ href: (selectedRow): string => `core/cache-groups/${selectedRow.id}` ,
name: "Edit"
},
{
@@ -116,28 +165,42 @@ export class CacheGroupTableComponent implements OnInit {
},
{
action: "asns",
+ disabled: (): true => true,
name: "Manage ASNs"
},
{
action: "parameters",
+ disabled: (): true => true,
name: "Manage Parameters"
},
{
action: "servers",
+ disabled: (): true => true,
name: "Manage Servers"
}
];
- /** A subject that child components can subscribe to for access to the fuzzy search query text */
+ /**
+ * 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: UntypedFormControl = new UntypedFormControl("");
+ public fuzzControl: FormControl = new FormControl("");
- constructor(private readonly api: CacheGroupService, private readonly route: ActivatedRoute,
- private readonly headerSvc: TpHeaderService) {
+ constructor(
+ private readonly api: CacheGroupService,
+ private readonly cdnAPI: CDNService,
+ private readonly route: ActivatedRoute,
+ private readonly headerSvc: TpHeaderService,
+ private readonly dialog: MatDialog,
+ private readonly alerts: AlertService,
+ public readonly auth: CurrentUserService
+ ) {
this.fuzzySubject = new BehaviorSubject<string>("");
this.cacheGroups = this.api.getCacheGroups();
+ this.cdns = this.cdnAPI.getCDNs();
}
/** Initializes table data, loading it from Traffic Ops. */
@@ -149,9 +212,6 @@ export class CacheGroupTableComponent implements OnInit {
this.fuzzControl.setValue(decodeURIComponent(search));
this.updateURL();
}
- },
- e => {
- console.error("Failed to get query parameters:", e);
}
);
this.headerSvc.headerTitle.next("Cache Groups");
@@ -162,13 +222,79 @@ export class CacheGroupTableComponent implements OnInit {
this.fuzzySubject.next(this.fuzzControl.value);
}
+ /**
+ * Queues or clears updates on a group of Cache Groups.
+ *
+ * @param cgs The Cache Groups on which to operate.
+ * @param queue Whether updates should be queued (`true`) or cleared
+ * (`false`).
+ */
+ private async queueUpdates(cgs: ResponseCacheGroup[], queue: boolean = true): Promise<void> {
+ const title = `${queue ? "Queue" : "Clear"} Updates on ${cgs.length === 1 ? cgs[0].name : `${cgs.length} Cache Groups`}`;
+ const data = {
+ collection: (await this.cdns).map(c => ({label: c.name, value: c.id})),
+ hint: "Note that 'ALL' does NOT mean 'all CDNs'!",
+ label: "CDN",
+ message: `Select a CDN to which to limit the ${queue ? "Queuing" : "Clearing"} of Updates.`,
+ title,
+ };
+ const ref = this.dialog.open<CollectionChoiceDialogComponent, CollectionChoiceDialogData<number>, number | false>(
+ CollectionChoiceDialogComponent,
+ {data}
+ );
+ const result = await ref.afterClosed().toPromise();
+ if (typeof(result) === "number") {
+ const responses = await Promise.all(cgs.map(async cg => this.api.queueCacheGroupUpdates(cg, result)));
+ const serverNum = responses.map(r => r.serverNames.length).reduce((n, l) => n+l, 0);
+ // This endpoint returns no alerts at the time of this writing, so
+ // we gotta do it by hand.
+ this.alerts.newAlert(
+ AlertLevel.SUCCESS,
+ `${queue ? "Queued" : "Cleared"} Updates on ${serverNum} server${serverNum === 1 ? "" : "s"}`
+ );
+ }
+ }
+
+ /**
+ * Asks the user for confirmation before deleting a Cache Group.
+ *
+ * @param cg The Cache Group (potentially) being deleted.
+ */
+ private async delete(cg: ResponseCacheGroup): Promise<void> {
+ const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(DecisionDialogComponent, {
+ data: {
+ message: `Are you sure you want to delete the ${cg.name} Cache Group?`,
+ title: `Delete ${cg.name}`
+ }
+ });
+ if (await ref.afterClosed().toPromise()) {
+ await this.api.deleteCacheGroup(cg);
+ this.cacheGroups = this.api.getCacheGroups();
+ }
+ }
+
/**
* Handles a context menu event.
*
* @param a The action selected from the context menu.
*/
- public handleContextMenu(a: ContextMenuActionEvent<CacheGroup>): void {
- console.log("action:", a);
+ public handleContextMenu(a: ContextMenuActionEvent<ResponseCacheGroup>): void {
+ switch(a.action) {
+ case "queue":
+ this.queueUpdates(Array.isArray(a.data) ? a.data : [a.data]);
+ break;
+ case "dequeue":
+ this.queueUpdates(Array.isArray(a.data) ? a.data : [a.data], false);
+ break;
+ case "delete":
+ if (Array.isArray(a.data)) {
+ console.error("cannot delete multiple cache groups at once:", a.data);
+ return;
+ }
+ this.delete(a.data);
+ break;
+ default:
+ console.error("unrecognized context menu action:", a.action);
+ }
}
-
}
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts
index 2d6179d1b2..f38d21ea95 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -20,18 +20,18 @@ import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule, type Routes } from "@angular/router";
-import { DivisionDetailComponent } from "src/app/core/cache-groups/divisions/detail/division-detail.component";
-import { DivisionsTableComponent } from "src/app/core/cache-groups/divisions/table/divisions-table.component";
-import { RegionDetailComponent } from "src/app/core/cache-groups/regions/detail/region-detail.component";
-import { RegionsTableComponent } from "src/app/core/cache-groups/regions/table/regions-table.component";
-import { LastDaysComponent } from "src/app/core/change-logs/last-days/last-days.component";
-
import { AppUIModule } from "../app.ui.module";
import { AuthenticatedGuard } from "../guards/authenticated-guard.service";
import { SharedModule } from "../shared/shared.module";
+import { CacheGroupDetailsComponent } from "./cache-groups/cache-group-details/cache-group-details.component";
import { CacheGroupTableComponent } from "./cache-groups/cache-group-table/cache-group-table.component";
+import { DivisionDetailComponent } from "./cache-groups/divisions/detail/division-detail.component";
+import { DivisionsTableComponent } from "./cache-groups/divisions/table/divisions-table.component";
+import { RegionDetailComponent } from "./cache-groups/regions/detail/region-detail.component";
+import { RegionsTableComponent } from "./cache-groups/regions/table/regions-table.component";
import { ChangeLogsComponent } from "./change-logs/change-logs.component";
+import { LastDaysComponent } from "./change-logs/last-days/last-days.component";
import { CurrentuserComponent } from "./currentuser/currentuser.component";
import { UpdatePasswordDialogComponent } from "./currentuser/update-password-dialog/update-password-dialog.component";
import { DashboardComponent } from "./dashboard/dashboard.component";
@@ -52,24 +52,25 @@ import { UserRegistrationDialogComponent } from "./users/user-registration-dialo
import { UsersComponent } from "./users/users.component";
export const ROUTES: Routes = [
- { canActivate: [AuthenticatedGuard], component: DashboardComponent, path: "" },
- { canActivate: [AuthenticatedGuard], component: DivisionsTableComponent, path: "divisions" },
- { canActivate: [AuthenticatedGuard], component: DivisionDetailComponent, path: "division/:id" },
- { canActivate: [AuthenticatedGuard], component: RegionsTableComponent, path: "regions" },
- { canActivate: [AuthenticatedGuard], component: RegionDetailComponent, path: "region/:id" },
- { canActivate: [AuthenticatedGuard], component: UsersComponent, path: "users" },
- { canActivate: [AuthenticatedGuard], component: UserDetailsComponent, path: "users/:id"},
- { canActivate: [AuthenticatedGuard], component: ServersTableComponent, path: "servers" },
- { canActivate: [AuthenticatedGuard], component: ServerDetailsComponent, path: "server/:id" },
- { canActivate: [AuthenticatedGuard], component: DeliveryserviceComponent, path: "deliveryservice/:id" },
- { canActivate: [AuthenticatedGuard], component: InvalidationJobsComponent, path: "deliveryservice/:id/invalidation-jobs" },
- { canActivate: [AuthenticatedGuard], component: CurrentuserComponent, path: "me" },
- { canActivate: [AuthenticatedGuard], component: NewDeliveryServiceComponent, path: "new.Delivery.Service" },
- { canActivate: [AuthenticatedGuard], component: CacheGroupTableComponent, path: "cache-groups" },
- { canActivate: [AuthenticatedGuard], component: TenantsComponent, path: "tenants"},
- { canActivate: [AuthenticatedGuard], component: ChangeLogsComponent, path: "change-logs" },
- { canActivate: [AuthenticatedGuard], component: TenantDetailsComponent, path: "tenants/:id"}
-];
+ { component: DashboardComponent, path: "" },
+ { component: DivisionsTableComponent, path: "divisions" },
+ { component: DivisionDetailComponent, path: "divisions/:id" },
+ { component: RegionsTableComponent, path: "regions" },
+ { component: RegionDetailComponent, path: "regions/:id" },
+ { component: UsersComponent, path: "users" },
+ { component: UserDetailsComponent, path: "users/:id"},
+ { component: ServersTableComponent, path: "servers" },
+ { component: ServerDetailsComponent, path: "server/:id" },
+ { component: DeliveryserviceComponent, path: "deliveryservice/:id" },
+ { component: InvalidationJobsComponent, path: "deliveryservice/:id/invalidation-jobs" },
+ { component: CurrentuserComponent, path: "me" },
+ { component: NewDeliveryServiceComponent, path: "new.Delivery.Service" },
+ { component: CacheGroupTableComponent, path: "cache-groups" },
+ { component: CacheGroupDetailsComponent, path: "cache-groups/:id"},
+ { component: TenantsComponent, path: "tenants"},
+ { component: ChangeLogsComponent, path: "change-logs" },
+ { component: TenantDetailsComponent, path: "tenants/:id"}
+].map(r => ({...r, canActivate: [AuthenticatedGuard]}));
/**
* CoreModule contains code that only logged in users will be served.
@@ -99,7 +100,8 @@ export const ROUTES: Routes = [
DivisionsTableComponent,
DivisionDetailComponent,
RegionsTableComponent,
- RegionDetailComponent
+ RegionDetailComponent,
+ CacheGroupDetailsComponent
],
exports: [],
imports: [
diff --git a/experimental/traffic-portal/src/app/core/dashboard/dashboard.component.ts b/experimental/traffic-portal/src/app/core/dashboard/dashboard.component.ts
index da34f6af93..7325f733c8 100644
--- a/experimental/traffic-portal/src/app/core/dashboard/dashboard.component.ts
+++ b/experimental/traffic-portal/src/app/core/dashboard/dashboard.component.ts
@@ -144,5 +144,4 @@ export class DashboardComponent implements OnInit {
public tracker(_: number, d: DeliveryService): number {
return d.id || 0;
}
-
}
diff --git a/experimental/traffic-portal/src/app/core/new-delivery-service/new-delivery-service.component.ts b/experimental/traffic-portal/src/app/core/new-delivery-service/new-delivery-service.component.ts
index a3df7a8ae4..584e9b88f3 100644
--- a/experimental/traffic-portal/src/app/core/new-delivery-service/new-delivery-service.component.ts
+++ b/experimental/traffic-portal/src/app/core/new-delivery-service/new-delivery-service.component.ts
@@ -16,11 +16,11 @@ import { Component, type OnInit, ViewChild, Inject } from "@angular/core";
import { UntypedFormControl } from "@angular/forms";
import type { MatStepper } from "@angular/material/stepper";
import { Router } from "@angular/router";
+import type { ResponseCDN } from "trafficops-types";
import { CDNService, DeliveryServiceService } from "src/app/api";
import {
bypassable,
- type CDN,
defaultDeliveryService,
type DeliveryService,
Protocol,
@@ -104,7 +104,7 @@ export class NewDeliveryServiceComponent implements OnInit {
];
/** The available CDNs from which for the user to choose. */
- public cdns: Array<CDN> = [];
+ public cdns: Array<ResponseCDN> = [];
/**
* The available useInTable=delivery_service Types from which for the user
@@ -203,9 +203,9 @@ export class NewDeliveryServiceComponent implements OnInit {
}
this.cdns = [];
let def;
- for (const [name, cdn] of cdns) {
+ for (const cdn of cdns) {
// this is a special, magic-value CDN that can't have any DSes
- if (name !== "ALL") {
+ if (cdn.name !== "ALL") {
this.cdns.push(cdn);
if (id > 0) {
if (cdn.id === id) {
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
index e0ecdc3eda..ce03b83939 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.ts
@@ -16,11 +16,12 @@ import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { faClock as hollowClock } from "@fortawesome/free-regular-svg-icons";
import { faClock, faMinus, faPlus, faToggleOff, faToggleOn, IconDefinition } from "@fortawesome/free-solid-svg-icons";
+import type { ResponseCacheGroup, ResponseCDN } from "trafficops-types";
import { CacheGroupService, CDNService, PhysicalLocationService, ProfileService, TypeService } from "src/app/api";
import { ServerService } from "src/app/api/server.service";
-import { CacheGroup, CDN, DUMMY_SERVER, Interface, PhysicalLocation, Profile, Server, Status, Type } from "src/app/models";
-import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
+import { DUMMY_SERVER, Interface, PhysicalLocation, Profile, Server, Status, Type } from "src/app/models";
+import { TpHeaderService } from "src/app/shared/tp-header/tp-header.service";
import { IP, IP_WITH_CIDR, AutocompleteValue } from "src/app/utils";
/**
@@ -99,11 +100,11 @@ export class ServerDetailsComponent implements OnInit {
/**
* The set of all Cache Groups.
*/
- public cacheGroups = new Array<CacheGroup>();
+ public cacheGroups = new Array<ResponseCacheGroup>();
/**
* The set of all CDNs.
*/
- public cdns = new Array<CDN>();
+ public cdns = new Array<ResponseCDN>();
/**
* The set of all Physical Locations.
*/
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
index 794bd7c26c..d3c1f96fac 100644
--- a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
@@ -13,7 +13,7 @@ limitations under the License.
-->
<mat-card class="table-page-content">
<div class="search-container">
- <input type="search" name="fuzzControl" aria-label="Fuzzy Search Users" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [(ngModel)]="searchText" (input)="updateURL()"/>
+ <input type="search" name="fuzzControl" aria-label="Fuzzy Search Tenants" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [(ngModel)]="searchText" (input)="updateURL()"/>
</div>
<tp-generic-table
[data]="tenants"
diff --git a/experimental/traffic-portal/src/app/models/cache-groups.ts b/experimental/traffic-portal/src/app/models/cache-groups.ts
deleted file mode 100644
index 7cff57db9a..0000000000
--- a/experimental/traffic-portal/src/app/models/cache-groups.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-* http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
-*/
-
-/** LocalizationMethod values are those allowed in the 'localizationMethods' of CacheGroups */
-export const enum LocalizationMethod {
- /** Coverage Zone file lookup. */
- CZ = "CZ",
- /** Deep Coverage Zone file lookup. */
- DEEP_CZ = "DEEP_CZ",
- /** Geographic database search. */
- GEO = "GEO"
-}
-
-/**
- * Converts a LocalizationMethod to a human-readable string.
- *
- * @param l The LocalizationMethod to convert.
- * @returns A textual representation of 'l'.
- */
-export function localizationMethodToString(l: LocalizationMethod): string {
- switch (l) {
- case LocalizationMethod.CZ:
- return "Coverage Zone File";
- case LocalizationMethod.DEEP_CZ:
- return "Deep Coverage Zone File";
- case LocalizationMethod.GEO:
- return "Geo-IP Database";
- }
-}
-
-/**
- * Represents a Cache Group.
- *
- * Refer to https://traffic-control-cdn.readthedocs.io/en/latest/overview/cache_groups.html
- */
-export interface CacheGroup {
- fallbacks: Array<string>;
- fallbackToClosest: boolean;
- readonly id?: number;
- lastUpdated?: Date;
- latitude: number;
- localizationMethods: Array<LocalizationMethod>;
- longitude: number;
- name: string;
- parentCacheGroupID: number | null;
- parentCacheGroupName: string | null;
- secondaryParentCacheGroupID: number | null;
- secondaryParentCacheGroupName: string | null;
- shortName: string;
- typeId: number;
- typeName: string;
-}
diff --git a/experimental/traffic-portal/src/app/models/index.ts b/experimental/traffic-portal/src/app/models/index.ts
index d7e35f23d9..508128ca8d 100644
--- a/experimental/traffic-portal/src/app/models/index.ts
+++ b/experimental/traffic-portal/src/app/models/index.ts
@@ -12,8 +12,6 @@
* limitations under the License.
*/
export * from "./alert.model";
-export * from "./cache-groups";
-export * from "./cdn";
export * from "./data";
export * from "./deliveryservice";
export * from "./invalidation";
diff --git a/experimental/traffic-portal/src/app/models/models.spec.ts b/experimental/traffic-portal/src/app/models/models.spec.ts
index 0425e14a58..606c625bb7 100644
--- a/experimental/traffic-portal/src/app/models/models.spec.ts
+++ b/experimental/traffic-portal/src/app/models/models.spec.ts
@@ -11,10 +11,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {
- LocalizationMethod,
- localizationMethodToString
-} from "./cache-groups";
import {
bypassable,
defaultDeliveryService,
@@ -25,14 +21,6 @@ import {
} from "./deliveryservice";
import { checkMap, type Servercheck } from "./server";
-describe("Cache Group utilities", () => {
- it("converts localization methods to human-readable strings", () => {
- expect(localizationMethodToString(LocalizationMethod.CZ)).toBe("Coverage Zone File");
- expect(localizationMethodToString(LocalizationMethod.DEEP_CZ)).toBe("Deep Coverage Zone File");
- expect(localizationMethodToString(LocalizationMethod.GEO)).toBe("Geo-IP Database");
- });
-});
-
describe("Delivery Service utilities", () => {
it("converts Query String Handlings to human-readable strings", () => {
let output = qStringHandlingToString(QStringHandling.USE);
diff --git a/experimental/traffic-portal/src/app/shared/alert/alert.component.scss b/experimental/traffic-portal/src/app/shared/alert/alert.component.scss
index b34042b0cd..ebe77042d3 100644
--- a/experimental/traffic-portal/src/app/shared/alert/alert.component.scss
+++ b/experimental/traffic-portal/src/app/shared/alert/alert.component.scss
@@ -11,37 +11,3 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-dialog {
- border-width: 0;
- border-radius: 15px;
- max-width: 80vw;
- opacity: 0.85;
- position: fixed;
- top: 50px;
- margin: auto;
- padding-right: 3em;
- z-index: 102;
-
- button {
- position: absolute;
- text-align: center;
- height: 100%;
- right: 0;
- top: 0;
- padding: 0 2px 0 0;
- width: 3em;
- cursor: pointer;
- border: none;
- border-left: 1px solid rgba(64,64,64,0.4);
- color: #555;
- background-color: transparent;
- font-size: 1em;
- border-top-right-radius: 15px;
- border-bottom-right-radius: 15px;
- }
-
- span {
- margin-right: 8px;
- }
-}
diff --git a/experimental/traffic-portal/src/app/shared/alert/alert.service.ts b/experimental/traffic-portal/src/app/shared/alert/alert.service.ts
index ebf36bdd9a..5920ce28e6 100644
--- a/experimental/traffic-portal/src/app/shared/alert/alert.service.ts
+++ b/experimental/traffic-portal/src/app/shared/alert/alert.service.ts
@@ -20,10 +20,12 @@ import type { Alert, AlertLevel } from "src/app/models/alert.model";
* This class is responsible for populating an alerts Observable that can be
* subscribed to by the `AlertComponent`.
*/
-@Injectable()
+@Injectable({
+ providedIn: "root"
+})
export class AlertService {
/** A BehaviorSubject that emits Alerts. */
- public alertsSubject: BehaviorSubject<Alert | null>;
+ private readonly alertsSubject: BehaviorSubject<Alert | null>;
/** An Observable that emits Alerts. */
public alerts: Observable<Alert | null>;
diff --git a/experimental/traffic-portal/src/app/shared/currentUser/current-user.service.ts b/experimental/traffic-portal/src/app/shared/currentUser/current-user.service.ts
index 39b9259b3e..7c687af071 100644
--- a/experimental/traffic-portal/src/app/shared/currentUser/current-user.service.ts
+++ b/experimental/traffic-portal/src/app/shared/currentUser/current-user.service.ts
@@ -26,7 +26,9 @@ import { type Capability, type CurrentUser, ADMIN_ROLE } from "src/app/models";
* an implicitly injected ErrorInterceptor which clears the authenticated user
* value when it hits a 401 error - so that would be a circular dependency.
*/
-@Injectable()
+@Injectable({
+ providedIn: "root"
+})
export class CurrentUserService {
/** Makes updateCurrentUser able to be called from multiple places without regard to order */
private updatingUserPromise: Promise<boolean> | null = null;
diff --git a/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.html b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.html
new file mode 100644
index 0000000000..b60e1656f9
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.html
@@ -0,0 +1,31 @@
+<!--
+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.
+-->
+
+<h2 mat-dialog-title>{{dialogData.title}}</h2>
+<mat-dialog-content>
+ <p>{{dialogData.message}}</p>
+ <mat-form-field>
+ <label>{{dialogData.label}}</label>
+ <mat-select name="{{dialogData.label}}" [(ngModel)]="selected" required>
+ <mat-option *ngFor="let item of dialogData.collection" [value]="item.value">{{item.label}}</mat-option>
+ </mat-select>
+ <mat-hint *ngIf="dialogData.hint">
+ {{dialogData.hint}}
+ </mat-hint>
+ </mat-form-field>
+</mat-dialog-content>
+<mat-dialog-actions>
+ <button mat-button [disabled]="!selected" [mat-dialog-close]="selected">Confirm</button>
+ <button mat-button [mat-dialog-close]="false">Cancel</button>
+</mat-dialog-actions>
diff --git a/experimental/traffic-portal/src/app/models/cdn.ts b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.scss
similarity index 54%
rename from experimental/traffic-portal/src/app/models/cdn.ts
rename to experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.scss
index 5132bb5bce..2b0b2de999 100644
--- a/experimental/traffic-portal/src/app/models/cdn.ts
+++ b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.scss
@@ -12,20 +12,6 @@
* limitations under the License.
*/
-/**
- * This file is for modeling and functionality related to CDN objects
- */
-
-/**
- * Represents a CDN as exposed by the Traffic Ops API
- */
-export interface CDN {
- /** Whether or not DNSSEC is enabled within this CDN. */
- dnssecEnabled: boolean;
- /** The Top-Level Domain within which the CDN operates. */
- domainName: string;
- /** An integral, unique identifier for the CDN. */
- id: number;
- /** The name of the CDN. */
- name: string;
+mat-form-field {
+ width: 100%;
}
diff --git a/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.spec.ts b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.spec.ts
new file mode 100644
index 0000000000..e16d3b0448
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.spec.ts
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatDialogModule, MAT_DIALOG_DATA } from "@angular/material/dialog";
+
+import { CollectionChoiceDialogComponent, type CollectionChoiceDialogData } from "./collection-choice-dialog.component";
+
+describe("CollectionChoiceDialogComponent", () => {
+ let component: CollectionChoiceDialogComponent;
+ let fixture: ComponentFixture<CollectionChoiceDialogComponent>;
+ const data: CollectionChoiceDialogData = {
+ collection: [],
+ message: "Choose something",
+ title: "Choose"
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [ CollectionChoiceDialogComponent ],
+ imports: [
+ MatDialogModule
+ ],
+ providers: [
+ {provide: MAT_DIALOG_DATA, useValue: data}
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CollectionChoiceDialogComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("gets an appropriate input label", () => {
+ expect(component.label).toBe(data.message);
+ data.label = "test";
+ expect(component.label).toBe(data.label);
+ });
+});
diff --git a/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.ts b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.ts
new file mode 100644
index 0000000000..69e30e6b42
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/dialogs/collection-choice-dialog/collection-choice-dialog.component.ts
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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, Inject } from "@angular/core";
+import { MAT_DIALOG_DATA } from "@angular/material/dialog";
+
+import type { DecisionDialogData } from "../decision-dialog/decision-dialog.component";
+
+/**
+ * Contains the structure of the data that CollectionChoiceDialogComponent
+ * accepts.
+ */
+export interface CollectionChoiceDialogData<T = unknown> extends DecisionDialogData {
+ /** The collection from which the user will choose. */
+ collection: {
+ /** A user-friendly name for the item. */
+ label: string;
+ /** The actual value of the item that you want back. */
+ value: T;
+ }[];
+ /** If given, will be displayed as a hint to the user. */
+ hint?: string | null | undefined;
+ /** Used as a label for the input. Defaults to `message`. */
+ label?: string | null | undefined;
+ /** A prompt for the user so they know what they're choosing and why. */
+ message: string;
+}
+
+/**
+ * This dialog facilitates asking the user to choose an item from a list.
+ */
+@Component({
+ selector: "tp-collection-choice-dialog",
+ styleUrls: ["./collection-choice-dialog.component.scss"],
+ templateUrl: "./collection-choice-dialog.component.html",
+})
+export class CollectionChoiceDialogComponent<T = unknown> {
+
+ public selected: T | null = null;
+
+ /** The label to use for the selection input. */
+ public get label(): string {
+ return this.dialogData.label ?? this.dialogData.message;
+ }
+
+ constructor(@Inject(MAT_DIALOG_DATA) public readonly dialogData: CollectionChoiceDialogData<T>) { }
+
+}
diff --git a/experimental/traffic-portal/src/app/shared/interceptor/alerts.interceptor.ts b/experimental/traffic-portal/src/app/shared/interceptor/alerts.interceptor.ts
index 93e045621e..1e81a131c5 100644
--- a/experimental/traffic-portal/src/app/shared/interceptor/alerts.interceptor.ts
+++ b/experimental/traffic-portal/src/app/shared/interceptor/alerts.interceptor.ts
@@ -44,7 +44,7 @@ export class AlertInterceptor implements HttpInterceptor {
if (Object.prototype.hasOwnProperty.call(r, "body") &&
Object.prototype.hasOwnProperty.call((r as {body: unknown}).body, "alerts")) {
for (const a of (r as {body: {alerts: Array<unknown>}}).body.alerts) {
- this.alertService.alertsSubject.next(a as Alert);
+ this.alertService.newAlert(a as Alert);
}
}
}
diff --git a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
index 3d303386a4..af7a004ca0 100644
--- a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
+++ b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
@@ -48,7 +48,7 @@ export class ErrorInterceptor implements HttpInterceptor {
if (err.hasOwnProperty("error") && (err as {error: object}).error.hasOwnProperty("alerts")) {
for (const a of (err as {error: {alerts: Alert[]}}).error.alerts) {
- this.alerts.alertsSubject.next(a);
+ this.alerts.newAlert(a);
}
}
diff --git a/experimental/traffic-portal/src/app/shared/shared.module.ts b/experimental/traffic-portal/src/app/shared/shared.module.ts
index 491f3a7f6f..c2aedab557 100644
--- a/experimental/traffic-portal/src/app/shared/shared.module.ts
+++ b/experimental/traffic-portal/src/app/shared/shared.module.ts
@@ -20,9 +20,8 @@ import { RouterModule } from "@angular/router";
import { AppUIModule } from "src/app/app.ui.module";
import { AlertComponent } from "./alert/alert.component";
-import { AlertService } from "./alert/alert.service";
import { LinechartDirective } from "./charts/linechart.directive";
-import { CurrentUserService } from "./currentUser/current-user.service";
+import { CollectionChoiceDialogComponent } from "./dialogs/collection-choice-dialog/collection-choice-dialog.component";
import { DecisionDialogComponent } from "./dialogs/decision-dialog/decision-dialog.component";
import { TextDialogComponent } from "./dialogs/text-dialog/text-dialog.component";
import { GenericTableComponent } from "./generic-table/generic-table.component";
@@ -58,7 +57,8 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
ObscuredTextInputComponent,
TreeSelectComponent,
TextDialogComponent,
- DecisionDialogComponent
+ DecisionDialogComponent,
+ CollectionChoiceDialogComponent
],
exports: [
AlertComponent,
@@ -81,8 +81,6 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
providers: [
{ multi: true, provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor },
{ multi: true, provide: HTTP_INTERCEPTORS, useClass: AlertInterceptor },
- AlertService,
- CurrentUserService
]
})
export class SharedModule { }
diff --git a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
index 48c7a6e157..cd8b548a18 100644
--- a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
+++ b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
@@ -35,7 +35,8 @@ export interface HeaderNavigation {
}
/**
- *
+ * Provides an interface that allows modules and components to dynamically
+ * manipulate the header.
*/
@Injectable({
providedIn: "root"
diff --git a/experimental/traffic-portal/src/app/utils/time.spec.ts b/experimental/traffic-portal/src/app/utils/time.spec.ts
index 99680571e2..1013fcbd4a 100644
--- a/experimental/traffic-portal/src/app/utils/time.spec.ts
+++ b/experimental/traffic-portal/src/app/utils/time.spec.ts
@@ -30,40 +30,40 @@ describe("RelativeTimeString", () => {
expect(str.substring(0, 4)).toBeCloseTo(5.8, 1);
});
it("Months", () => {
- fromDate.setFullYear(toDate.getFullYear());
+ fromDate.setUTCFullYear(toDate.getUTCFullYear());
const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
expect(str.substring(4)).toBe(" months ago");
expect(str.substring(0, 4)).toBeCloseTo(9.5, 1);
});
it("Weeks", () => {
- fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth());
+ fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth());
const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
expect(str.substring(4)).toBe(" weeks ago");
expect(str.substring(0, 4)).toBeCloseTo(1.8, 1);
});
it("Days", () => {
- fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth(), toDate.getDate()-3);
+ fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate()-3);
const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
expect(str.substring(4)).toBe(" days ago");
expect(str.substring(0, 4)).toBeCloseTo(3.9, 1);
});
it("Hours", () => {
- fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth(), toDate.getDate());
- fromDate.setHours(toDate.getHours()-3);
+ fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate());
+ fromDate.setUTCHours(toDate.getUTCHours()-3);
const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
expect(str.substring(4)).toBe(" hours ago");
expect(str.substring(0, 4)).toBeCloseTo(3.7, 1);
});
it("Minutes", () => {
- fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth(), toDate.getDate());
- fromDate.setHours(toDate.getHours());
+ fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate());
+ fromDate.setUTCHours(toDate.getUTCHours());
const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
expect(str.substring(5)).toBe(" minutes ago");
expect(str.substring(0, 5)).toBeCloseTo(44.9, 1);
});
it("Seconds", () => {
- fromDate.setFullYear(toDate.getFullYear(), toDate.getMonth(), toDate.getDate());
- fromDate.setHours(toDate.getHours(), toDate.getMinutes());
+ fromDate.setUTCFullYear(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate());
+ fromDate.setUTCHours(toDate.getUTCHours(), toDate.getUTCMinutes());
const str = relativeTimeString(toDate.getTime() - fromDate.getTime());
expect(str.substring(2)).toBe(" seconds ago");
expect(str.substring(0, 2)).toBe("56");