You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by oc...@apache.org on 2023/07/03 19:03:25 UTC

[trafficcontrol] branch master updated: Traffic Portal v2 CDNs table page (#7459)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new d8352bbfc1 Traffic Portal v2 CDNs table page (#7459)
d8352bbfc1 is described below

commit d8352bbfc1533b78d3b4cd2c236351f743a6e1cc
Author: Zach Hoffman <zr...@apache.org>
AuthorDate: Mon Jul 3 13:03:18 2023 -0600

    Traffic Portal v2 CDNs table page (#7459)
    
    * Traffic Portal v2 CDNs table page
    
    * Update JSDoc
    
    * Remove unused attribute
    
    * Use anchor tag for links
    
    * Grammar
    
    * Check for array type
    
    * Enable profiles link
    
    * Enable servers link
    
    * Accept either ResponseCDN or a CDN ID
    
    * Disable unimplemented snapshot diff
    
    * queueCDNUpates() can accept an ID
    
    * Add queueUpdates() to mock CDN service
    
    * Separate field for query parameters
    
    * Manage -> View
    
    * Do not hard-code action name
    
    * Implement `[doubleClickLink]`
    
    * reformat
    
    * Use queueServerUpdates() instead of queueCDNUpdates()
    
    * Regex for Cleard -> Cleared
    
    * Field name is domainName
    
    * Add CDNTableComponent unit tests
    
    * Reformat TreeNavNode[] literal
    
    * Add CDNs table to the sidebar
    
    * Error message wording
    
    * Add a test for queueing CDN updates
    
    * ng lint --fix
    
    * table-page-content -> page-content for #7582 changes
---
 .../core/cdns/cdn-table/cdn-table.component.html   |  29 +++
 .../core/cdns/cdn-table/cdn-table.component.scss   |  13 +
 .../cdns/cdn-table/cdn-table.component.spec.ts     |  99 ++++++++
 .../app/core/cdns/cdn-table/cdn-table.component.ts | 276 +++++++++++++++++++++
 .../traffic-portal/src/app/core/core.module.ts     |   3 +
 .../app/shared/navigation/navigation.service.ts    |  69 +++---
 6 files changed, 458 insertions(+), 31 deletions(-)

diff --git a/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.html b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.html
new file mode 100644
index 0000000000..3160ba2037
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.html
@@ -0,0 +1,29 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<mat-card class="page-content">
+	<div class="search-container">
+		<input type="search" name="fuzzControl" aria-label="Fuzzy Search CDNs" inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()"/>
+	</div>
+	<tp-generic-table
+		[data]="cdns | async"
+		[doubleClickLink]="doubleClickLink"
+		[cols]="columnDefs"
+		[fuzzySearch]="fuzzySubject"
+		context="cdns"
+		[contextMenuItems]="contextMenuItems"
+		(contextMenuAction)="handleContextMenu($event)">
+	</tp-generic-table>
+</mat-card>
+
+<a class="page-fab" mat-fab title="Create a new CDN" *ngIf="auth.hasPermission('CDN:CREATE')" routerLink="new"><mat-icon>add</mat-icon></a>
diff --git a/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.scss b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.scss
new file mode 100644
index 0000000000..ebe77042d3
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.scss
@@ -0,0 +1,13 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
diff --git a/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.spec.ts b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.spec.ts
new file mode 100644
index 0000000000..8ffed35515
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.spec.ts
@@ -0,0 +1,99 @@
+/*
+* 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 { HttpClientModule } from "@angular/common/http";
+import { ComponentFixture, TestBed } 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 { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { RouterTestingModule } from "@angular/router/testing";
+import { ReplaySubject } from "rxjs";
+import { ResponseCDN } from "trafficops-types";
+
+import { CDNService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { NavigationService } from "src/app/shared/navigation/navigation.service";
+
+import { CDNTableComponent } from "./cdn-table.component";
+
+const sampleCDN: ResponseCDN = {
+	dnssecEnabled: false,
+	domainName: "*",
+	id: 2,
+	lastUpdated: new Date(),
+	name: "*",
+};
+
+describe("CDNTableComponent", () => {
+	let component: CDNTableComponent;
+	let fixture: ComponentFixture<CDNTableComponent>;
+	let loader: HarnessLoader;
+
+	const navService = jasmine.createSpyObj([],{headerHidden: new ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
+
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [CDNTableComponent],
+			imports: [
+				APITestingModule,
+				HttpClientModule,
+				ReactiveFormsModule,
+				RouterTestingModule,
+				MatDialogModule,
+				NoopAnimationsModule,
+				MatSelectModule,
+			],
+			providers: [
+				{provide: NavigationService, useValue: navService},
+			],
+		})
+			.compileComponents();
+
+		fixture = TestBed.createComponent(CDNTableComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
+	});
+
+	it("should create", () => {
+		expect(component).toBeTruthy();
+	});
+
+	it("queues CDN updates", async () => {
+		component = fixture.componentInstance;
+		const service = TestBed.inject(CDNService);
+		const queueSpy = spyOn(service, "queueServerUpdates");
+		expect(queueSpy).not.toHaveBeenCalled();
+
+		let dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		expect(dialogs.length).toBe(0);
+
+		component.handleContextMenu({action: "queue", data: sampleCDN});
+		dialogs = await loader.getAllHarnesses(MatDialogHarness);
+		expect(dialogs.length).toBe(1);
+		const dialog = dialogs[0];
+		const buttons = await dialog.getAllHarnesses(MatButtonHarness);
+		expect(buttons.length).toBe(2);
+		const button = buttons[0];
+		await button.click();
+
+		expect(queueSpy).toHaveBeenCalledTimes(1);
+	});
+
+});
diff --git a/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.ts b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.ts
new file mode 100644
index 0000000000..a5d6d07cb0
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/cdns/cdn-table/cdn-table.component.ts
@@ -0,0 +1,276 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { Component, OnInit } from "@angular/core";
+import { FormControl } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute, Params } from "@angular/router";
+import { ColDef } from "ag-grid-community";
+import { BehaviorSubject } from "rxjs";
+import { AlertLevel, ResponseCDN } from "trafficops-types";
+
+import { CDNService } from "src/app/api";
+import { AlertService } from "src/app/shared/alert/alert.service";
+import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import {
+	DecisionDialogComponent,
+	DecisionDialogData
+} from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import type {
+	ContextMenuActionEvent,
+	ContextMenuItem,
+	DoubleClickLink
+} from "src/app/shared/generic-table/generic-table.component";
+
+/**
+ * CDNTableComponent is the controller for the "CDNs" table.
+ */
+@Component({
+	selector: "tp-cdn-table",
+	styleUrls: ["./cdn-table.component.scss"],
+	templateUrl: "./cdn-table.component.html",
+})
+export class CDNTableComponent implements OnInit {
+	public cdns: Promise<ResponseCDN[]>;
+
+	/* Definitions of the table's columns according to the ag-grid API */
+	public columnDefs: ColDef[] = [
+		{
+			field: "dnssecEnabled",
+			filter: "tpBooleanFilter",
+			headerName: "DNSSEC Enabled",
+			hide: false
+		},
+		{
+			field: "domainName",
+			filter: "agTextColumnFilter",
+			headerName: "Domain",
+			hide: false,
+		},
+		{
+			field: "id",
+			filter: "agNumberColumnFilter",
+			headerName: "ID",
+			hide: true,
+		},
+		{
+			field: "lastUpdated",
+			filter: "agDateColumnFilter",
+			headerName: "Last Updated",
+			hide: true
+		},
+		{
+			field: "name",
+			filter: "agTextColumnFilter",
+			headerName: "Name",
+			hide: false,
+		},
+	];
+
+	/** Defines what the table should do when a row is double-clicked. */
+	public doubleClickLink: DoubleClickLink<ResponseCDN> = {
+		href: (row: ResponseCDN): string => `/core/cdns/${row.id}`
+	};
+
+	/**
+	 * Definitions for the context menu items (which act on augmented
+	 * CDN data).
+	 */
+	public contextMenuItems: Array<ContextMenuItem<ResponseCDN>> = [
+		{
+			href: (selectedRow): string => `/core/cdns/${selectedRow.id}`,
+			name: "Open in New Tab",
+			newTab: true
+		},
+		{
+			href: (selectedRow): string => `/core/cdns/${selectedRow.id}`,
+			name: "Edit"
+		},
+		{
+			action: "delete",
+			multiRow: false,
+			name: "Delete",
+		},
+		{
+			action: "snapshot-diff",
+			disabled: (): true => true,
+			multiRow: false,
+			name: "Diff Snapshot",
+		},
+		{
+			action: "queue",
+			multiRow: false,
+			name: "Queue Server Updates"
+		},
+		{
+			action: "dequeue",
+			multiRow: false,
+			name: "Clear Queued Updates"
+		},
+		{
+			disabled: (): true => true,
+			href: (selectedRow): string => `/core/cdns/${selectedRow.id}/dnssec-keys`,
+			name: "Manage DNSSEC Keys"
+		},
+		{
+			disabled: (): true => true,
+			href: (selectedRow): string => `/core/cdns/${selectedRow.id}/federations`,
+			name: "Manage Federations"
+		},
+		{
+			disabled: (): true => true,
+			href: (selectedRow): string => `/core/cdns/${selectedRow.id}/delivery-services`,
+			name: "Manage Delivery Services"
+		},
+		{
+			href: "/core/profiles",
+			name: "View Profiles",
+			queryParams: (selectedRow): Params => ({cdnName: selectedRow.name}),
+		},
+		{
+			href: "/core/servers",
+			name: "View Servers",
+			queryParams: (selectedRow): Params => ({cdnName: selectedRow.name}),
+		},
+		{
+			disabled: (): true => true,
+			href: (selectedRow): string => `/core/cdns/${selectedRow.id}/notifications`,
+			name: "Manage Notifications"
+		},
+	];
+
+	/**
+	 * 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: FormControl = new FormControl("");
+
+	constructor(
+		private readonly alerts: AlertService,
+		private readonly api: CDNService,
+		public readonly auth: CurrentUserService,
+		private readonly dialog: MatDialog,
+		private readonly route: ActivatedRoute,
+	) {
+		this.fuzzySubject = new BehaviorSubject<string>("");
+		this.cdns = this.api.getCDNs();
+	}
+
+	/** Initializes table data, loading it from Traffic Ops. */
+	public ngOnInit(): void {
+		this.route.queryParamMap.subscribe(
+			m => {
+				const search = m.get("search");
+				if (search) {
+					this.fuzzControl.setValue(decodeURIComponent(search));
+					this.updateURL();
+				}
+			}
+		);
+	}
+
+	/** Update the URL's 'search' query parameter for the user's search input. */
+	public updateURL(): void {
+		this.fuzzySubject.next(this.fuzzControl.value);
+	}
+
+	/**
+	 * Queues or clears updates on a group of CDNs.
+	 *
+	 * @param cdn The CDN on which to queue updates.
+	 * @param queue Whether updates should be queued (`true`) or cleared
+	 * (`false`).
+	 */
+	private async queueUpdates(cdn: ResponseCDN | number, queue: boolean = true): Promise<void> {
+		if (typeof cdn === "number") {
+			cdn = await this.api.getCDNs(cdn);
+		}
+		const readableAction = queue ? "Queue" : "Clear";
+		const title = `${readableAction} Updates on ${cdn.name}?`;
+		const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(DecisionDialogComponent, {
+			data: {
+				message: `Are you sure you want to ${readableAction.toLowerCase()} server updates for all of the ${cdn.name} servers?`,
+				title,
+			}
+		});
+		if (!await ref.afterClosed().toPromise()) {
+			return;
+		}
+		if (queue) {
+			await this.api.queueServerUpdates(cdn);
+		} else {
+			await this.api.dequeueServerUpdates(cdn);
+		}
+		this.alerts.newAlert(
+			AlertLevel.SUCCESS,
+			`${readableAction.replace(/(?<!e)$/, "e")}d CDN server updates`,
+		);
+	}
+
+	/**
+	 * Asks the user for confirmation before deleting a CDN.
+	 *
+	 * @param cdn The CDN (potentially) being deleted.
+	 */
+	private async delete(cdn: ResponseCDN | number): Promise<void> {
+		if (typeof cdn === "number") {
+			cdn = await this.api.getCDNs(cdn);
+		}
+		const ref = this.dialog.open<DecisionDialogComponent, DecisionDialogData, boolean>(DecisionDialogComponent, {
+			data: {
+				message: `Are you sure you want to delete the ${cdn.name} CDN?`,
+				title: `Delete ${cdn.name}`
+			}
+		});
+		if (await ref.afterClosed().toPromise()) {
+			await this.api.deleteCDN(cdn);
+			this.cdns = this.api.getCDNs();
+		}
+	}
+
+	/**
+	 * Handles a context menu event.
+	 *
+	 * @param a The action selected from the context menu.
+	 */
+	public handleContextMenu(a: ContextMenuActionEvent<ResponseCDN>): void {
+		switch (a.action) {
+			case "queue":
+				if (Array.isArray(a.data)) {
+					console.error("cannot queue multiple cdns at once:", a.data);
+					return;
+				}
+				this.queueUpdates(a.data);
+				break;
+			case "dequeue":
+				if (Array.isArray(a.data)) {
+					console.error("cannot dequeue multiple cdns at once:", a.data);
+					return;
+				}
+				this.queueUpdates(a.data, false);
+				break;
+			case "delete":
+				if (Array.isArray(a.data)) {
+					console.error("cannot delete multiple cdns 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 41e9168af4..fa0757870c 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -35,6 +35,7 @@ import { DivisionsTableComponent } from "./cache-groups/divisions/table/division
 import { RegionDetailComponent } from "./cache-groups/regions/detail/region-detail.component";
 import { RegionsTableComponent } from "./cache-groups/regions/table/regions-table.component";
 import { CDNDetailComponent } from "./cdns/cdn-detail/cdn-detail.component";
+import { CDNTableComponent } from "./cdns/cdn-table/cdn-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";
@@ -81,6 +82,7 @@ export const ROUTES: Routes = [
 	{ component: RegionDetailComponent, path: "regions/:id" },
 	{ component: UsersComponent, path: "users" },
 	{ component: UserDetailsComponent, path: "users/:id"},
+	{ component: CDNTableComponent, path: "cdns" },
 	{ component: CDNDetailComponent, path: "cdns/:id" },
 	{ component: ServersTableComponent, path: "servers" },
 	{ component: ServerDetailsComponent, path: "servers/:id" },
@@ -157,6 +159,7 @@ export const ROUTES: Routes = [
 		StatusesTableComponent,
 		StatusDetailsComponent,
 		ISOGenerationFormComponent,
+		CDNTableComponent,
 		CDNDetailComponent,
 		ParametersTableComponent,
 		ParameterDetailComponent,
diff --git a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
index 28d20490bd..f76aa12b93 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -112,39 +112,46 @@ export class NavigationService {
 			name: "Dashboard"
 		}, {
 			children: [{
-				href: "/core/servers",
-				name: "Servers"
-			}, {
-				href: "/core/phys-locs",
-				name: "Physical Locations"
-			},
-			{
-				href: "/core/statuses",
-				name: "Statuses"
-			},
-			{
-				href: "/core/capabilities",
-				name: "Capabilities",
-			},
-			{
-				children: [{
-					href: "/core/cache-groups",
-					name: "Cache Groups"
-				}, {
-					href: "/core/coordinates",
-					name: "Coordinates"
-				}, {
-					href: "/core/divisions",
-					name: "Divisions"
-				}, {
-					href: "/core/regions",
-					name: "Regions"
+				href: "/core/cdns",
+				name: "CDNs"
+			}],
+			name: "CDNs",
+		}, {
+			children: [
+				{
+					href: "/core/servers",
+					name: "Servers"
 				}, {
-					href: "/core/asns",
-					name: "ASNs"
+					href: "/core/phys-locs",
+					name: "Physical Locations"
+				},
+				{
+					href: "/core/statuses",
+					name: "Statuses"
+				},
+				{
+					href: "/core/capabilities",
+					name: "Capabilities",
+				},
+				{
+					children: [{
+						href: "/core/cache-groups",
+						name: "Cache Groups"
+					}, {
+						href: "/core/coordinates",
+						name: "Coordinates"
+					}, {
+						href: "/core/divisions",
+						name: "Divisions"
+					}, {
+						href: "/core/regions",
+						name: "Regions"
+					}, {
+						href: "/core/asns",
+						name: "ASNs"
+					}],
+					name: "Cache Groups"
 				}],
-				name: "Cache Groups"
-			}],
 			name: "Servers"
 		}, {
 			children: [