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 1193ccbe71 Traffic Portal v2 Topologies details page (#7615)
1193ccbe71 is described below

commit 1193ccbe717d65ab9c8a4bac069168bfc2fe8b0e
Author: Zach Hoffman <[email protected]>
AuthorDate: Tue Aug 1 09:07:34 2023 -0600

    Traffic Portal v2 Topologies details page (#7615)
    
    * Traffic Portal v2 Topologies details page
    
    * Require at least trafficops-types version 4.0.11
    
    * Tests pass
    
    * Fix whitespace
    
    * Code review updates
    
    * Add test coverage
    
    * use .toBeDefined()
    
    * Use tab, not space
    
    * Increase topology.service test coverage to 100%
    
    * topologies-new -> new-topology
    
    * Use typeof, not instanceof
    
    * Match call signature of concrete service
    
    * Make topology name field mutable
    
    * Use ngModel
---
 experimental/traffic-portal/package-lock.json      |  14 +-
 experimental/traffic-portal/package.json           |   2 +-
 experimental/traffic-portal/src/app/api/index.ts   |   3 +
 .../traffic-portal/src/app/api/testing/index.ts    |   3 +
 .../src/app/api/testing/topology.service.ts        | 154 +++++++++++++++++
 .../src/app/api/topology.service.spec.ts           | 174 ++++++++++++++++++++
 .../traffic-portal/src/app/api/topology.service.ts | 182 +++++++++++++++++++++
 .../traffic-portal/src/app/core/core.module.ts     |   4 +
 .../topology-details.component.html                |  44 +++++
 .../topology-details.component.scss                |  16 ++
 .../topology-details.component.spec.ts             |  70 ++++++++
 .../topology-details/topology-details.component.ts | 158 ++++++++++++++++++
 12 files changed, 816 insertions(+), 8 deletions(-)

diff --git a/experimental/traffic-portal/package-lock.json 
b/experimental/traffic-portal/package-lock.json
index b03d8c3a76..3f04e59817 100644
--- a/experimental/traffic-portal/package-lock.json
+++ b/experimental/traffic-portal/package-lock.json
@@ -29,7 +29,7 @@
         "express": "^4.15.2",
         "node-forge": "^1.3.1",
         "rxjs": "~6.6.0",
-        "trafficops-types": "^4.0.10",
+        "trafficops-types": "^4.0.11",
         "tslib": "^2.0.0",
         "zone.js": "~0.13.0"
       },
@@ -19017,9 +19017,9 @@
       }
     },
     "node_modules/trafficops-types": {
-      "version": "4.0.10",
-      "resolved": 
"https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.10.tgz";,
-      "integrity": 
"sha512-/GW+PgT7Rg5WhGtbkJdvTIXLga68wx3dy9crmXmOrrB6sPVcX7vbrQ9TAxGqRbsC52ZbZSxahqsmZgQZv8KK3A=="
+      "version": "4.0.11",
+      "resolved": 
"https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.11.tgz";,
+      "integrity": 
"sha512-qGVUM28xn3rebeNBnFemo+IVCi09TXGH3/pDy/OAMJFWDI6g/jHbR51I2UVdD1cZ41D0GHnyDAVWSgfgSBo9kQ=="
     },
     "node_modules/traverse": {
       "version": "0.6.7",
@@ -34938,9 +34938,9 @@
       }
     },
     "trafficops-types": {
-      "version": "4.0.10",
-      "resolved": 
"https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.10.tgz";,
-      "integrity": 
"sha512-/GW+PgT7Rg5WhGtbkJdvTIXLga68wx3dy9crmXmOrrB6sPVcX7vbrQ9TAxGqRbsC52ZbZSxahqsmZgQZv8KK3A=="
+      "version": "4.0.11",
+      "resolved": 
"https://registry.npmjs.org/trafficops-types/-/trafficops-types-4.0.11.tgz";,
+      "integrity": 
"sha512-qGVUM28xn3rebeNBnFemo+IVCi09TXGH3/pDy/OAMJFWDI6g/jHbR51I2UVdD1cZ41D0GHnyDAVWSgfgSBo9kQ=="
     },
     "traverse": {
       "version": "0.6.7",
diff --git a/experimental/traffic-portal/package.json 
b/experimental/traffic-portal/package.json
index 9dd956b754..ca79646e22 100644
--- a/experimental/traffic-portal/package.json
+++ b/experimental/traffic-portal/package.json
@@ -69,7 +69,7 @@
     "express": "^4.15.2",
     "node-forge": "^1.3.1",
     "rxjs": "~6.6.0",
-    "trafficops-types": "^4.0.10",
+    "trafficops-types": "^4.0.11",
     "tslib": "^2.0.0",
     "zone.js": "~0.13.0"
   },
diff --git a/experimental/traffic-portal/src/app/api/index.ts 
b/experimental/traffic-portal/src/app/api/index.ts
index 82da8fd2df..039b8abe1e 100644
--- a/experimental/traffic-portal/src/app/api/index.ts
+++ b/experimental/traffic-portal/src/app/api/index.ts
@@ -26,6 +26,7 @@ import { MiscAPIsService } from "./misc-apis.service";
 import { PhysicalLocationService } from "./physical-location.service";
 import { ProfileService } from "./profile.service";
 import { ServerService } from "./server.service";
+import { TopologyService } from "./topology.service";
 import { TypeService } from "./type.service";
 import { UserService } from "./user.service";
 
@@ -38,6 +39,7 @@ export * from "./misc-apis.service";
 export * from "./physical-location.service";
 export * from "./profile.service";
 export * from "./server.service";
+export * from "./topology.service";
 export * from "./type.service";
 export * from "./user.service";
 
@@ -59,6 +61,7 @@ export * from "./user.service";
                PhysicalLocationService,
                ProfileService,
                ServerService,
+               TopologyService,
                TypeService,
                UserService,
        ]
diff --git a/experimental/traffic-portal/src/app/api/testing/index.ts 
b/experimental/traffic-portal/src/app/api/testing/index.ts
index 4048726732..b03a1471e1 100644
--- a/experimental/traffic-portal/src/app/api/testing/index.ts
+++ b/experimental/traffic-portal/src/app/api/testing/index.ts
@@ -25,6 +25,7 @@ import {
        PhysicalLocationService,
        ProfileService,
        ServerService,
+       TopologyService,
        TypeService,
        UserService
 } from "..";
@@ -38,6 +39,7 @@ import { MiscAPIsService as TestingMiscAPIsService } from 
"./misc-apis.service";
 import { PhysicalLocationService as TestingPhysicalLocationService } from 
"./physical-location.service";
 import { ProfileService as TestingProfileService } from "./profile.service";
 import { ServerService as TestingServerService } from "./server.service";
+import { TopologyService as TestingTopologyService } from "./topology.service";
 import { TypeService as TestingTypeService } from "./type.service";
 import { UserService as TestingUserService } from "./user.service";
 
@@ -60,6 +62,7 @@ import { UserService as TestingUserService } from 
"./user.service";
                {provide: PhysicalLocationService, useClass: 
TestingPhysicalLocationService},
                {provide: ProfileService, useClass: TestingProfileService},
                {provide: ServerService, useClass: TestingServerService},
+               {provide: TopologyService, useClass: TestingTopologyService},
                {provide: TypeService, useClass: TestingTypeService},
                {provide: UserService, useClass: TestingUserService},
                TestingServerService,
diff --git 
a/experimental/traffic-portal/src/app/api/testing/topology.service.ts 
b/experimental/traffic-portal/src/app/api/testing/topology.service.ts
new file mode 100644
index 0000000000..ae63029f6e
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/testing/topology.service.ts
@@ -0,0 +1,154 @@
+/*
+* 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 { Injectable } from "@angular/core";
+import {
+       RequestTopology,
+       ResponseTopology,
+       ResponseTopologyNode
+} from "trafficops-types";
+
+import { TopologyService as ConcreteService, TopTreeNode } from "src/app/api";
+
+/**
+ * TopologyService expose API functionality relating to Topologies.
+ */
+@Injectable()
+export class TopologyService {
+       private readonly topologies: ResponseTopology[] = [
+               {
+                       description: "",
+                       lastUpdated: new Date(),
+                       name: "test",
+                       nodes: [
+                               {
+                                       cachegroup: "Edge",
+                                       parents: [1],
+                               },
+                               {
+                                       cachegroup: "Mid",
+                                       parents: [2],
+                               },
+                               {
+                                       cachegroup: "Origin",
+                                       parents: [],
+                               },
+                       ],
+               },
+       ];
+
+       /**
+        * Gets one or all Topologies from Traffic Ops
+        *
+        * @param name The unique name of a single Topology to be returned
+        * @returns An Array of Topologies
+        */
+       public async getTopologies(name?: string): 
Promise<Array<ResponseTopology>> {
+               if (name !== undefined) {
+                       const topology = this.topologies.find(t => t.name === 
name);
+                       if (!topology) {
+                               throw new Error(`no such Topology ${name}`);
+                       }
+                       return [topology];
+               }
+               return this.topologies;
+       }
+
+       /**
+        * Deletes a Topology.
+        *
+        * @param topology The Topology to be deleted, or just its name.
+        */
+       public async deleteTopology(topology: ResponseTopology | string): 
Promise<void> {
+               const name = typeof topology === "string" ? topology : 
topology.name;
+               const idx = this.topologies.findIndex(t => t.name === name);
+               if (idx < 0) {
+                       throw new Error(`no such Topology: ${name}`);
+               }
+               this.topologies.splice(idx, 1);
+       }
+
+       /**
+        * Creates a new Topology.
+        *
+        * @param topology The Topology to create.
+        */
+       public async createTopology(topology: RequestTopology): 
Promise<ResponseTopology> {
+               const nodes: ResponseTopologyNode[] = topology.nodes.map(node 
=> {
+                       if (!Array.isArray(node.parents)) {
+                               node.parents = [];
+                       }
+                       const responseNode: ResponseTopologyNode = {
+                               cachegroup: node.cachegroup,
+                               parents: node.parents || [],
+                       };
+                       return responseNode;
+               });
+               const t: ResponseTopology = {
+                       description: topology.description || "",
+                       lastUpdated: new Date(),
+                       name: topology.name,
+                       nodes,
+               };
+               this.topologies.push(t);
+               return t;
+       }
+
+       /**
+        * Replaces an existing Topology with the provided new definition of a
+        * Topology.
+        *
+        * @param topology The full new definition of the Topology being updated
+        * @param name What the topology was named before it was updated
+        */
+       public async updateTopology(topology: ResponseTopology, name?: string): 
Promise<ResponseTopology> {
+               if (typeof name === "undefined") {
+                       name = topology.name;
+               }
+               const idx = this.topologies.findIndex(t => t.name === name);
+               topology = {
+                       ...topology,
+                       lastUpdated: new Date()
+               };
+
+               if (idx < 0) {
+                       throw new Error(`no such Topology: ${topology}`);
+               }
+
+               this.topologies[idx] = topology;
+               return topology;
+       }
+
+       /**
+        * Generates a material tree from a topology.
+        *
+        * @param topology The topology to generate a material tree from.
+        * @returns a material tree.
+        */
+       public static topologyToTree(topology: ResponseTopology): 
Array<TopTreeNode> {
+               return ConcreteService.topologyToTree(topology);
+       }
+
+       /**
+        * Generates a topology from a material tree.
+        *
+        * @param name The topology name
+        * @param description The topology description
+        * @param treeNodes The data for a material tree
+        * @returns a topology.
+        */
+       public static treeToTopology(name: string, description: string, 
treeNodes: Array<TopTreeNode>): ResponseTopology {
+               return ConcreteService.treeToTopology(name, description, 
treeNodes);
+       }
+}
diff --git a/experimental/traffic-portal/src/app/api/topology.service.spec.ts 
b/experimental/traffic-portal/src/app/api/topology.service.spec.ts
new file mode 100644
index 0000000000..9dcd718e66
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/topology.service.spec.ts
@@ -0,0 +1,174 @@
+/**
+ * @license Apache-2.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.
+ */
+import {
+       HttpClientTestingModule,
+       HttpTestingController
+} from "@angular/common/http/testing";
+import { TestBed } from "@angular/core/testing";
+
+import { TopologyService, TopTreeNode } from "./topology.service";
+
+describe("TopologyService", () => {
+       let service: TopologyService;
+       let httpTestingController: HttpTestingController;
+       const topology = {
+               description: "",
+               lastUpdated: new Date(),
+               name: "my-topology",
+               nodes: [
+                       {
+                               cachegroup: "Origin",
+                               parents: [],
+                       },
+                       {
+                               cachegroup: "Mid",
+                               parents: [0],
+                       },
+                       {
+                               cachegroup: "Edge",
+                               parents: [1],
+                       },
+               ],
+       };
+
+       const tree: Array<TopTreeNode> = [{
+               cachegroup: "Origin",
+               children: [{
+                       cachegroup: "Mid",
+                       children: [{
+                               cachegroup: "Edge",
+                               children: [],
+                               name: "Edge",
+                               parents: []
+                       }],
+                       name: "Mid",
+                       parents: []
+               }],
+               name: "Origin",
+               parents: []
+       }];
+
+       beforeEach(() => {
+               TestBed.configureTestingModule({
+                       imports: [HttpClientTestingModule],
+                       providers: [
+                               TopologyService,
+                       ]
+               });
+               service = TestBed.inject(TopologyService);
+               httpTestingController = TestBed.inject(HttpTestingController);
+       });
+
+       it("should be created", () => {
+               expect(service).toBeTruthy();
+       });
+
+       it("gets a topology by name", async () => {
+               const name = "my-topology";
+               const responseP = service.getTopologies(name);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/topologies?name=${name}`);
+               expect(req.request.method).toBe("GET");
+               expect(req.request.params.keys().length).toBe(1);
+               req.flush({response: [topology]});
+               await expectAsync(responseP).toBeResolvedTo([topology]);
+       });
+
+       it("throws an error when no Topology has the given name", async () => {
+               const name = "nonexistent";
+               const responseP = service.getTopologies(name);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/topologies?name=${name}`);
+               expect(req.request.method).toBe("GET");
+               expect(req.request.params.keys().length).toBe(1);
+               req.flush({response: [topology]});
+               await expectAsync(responseP).toBeRejected();
+       });
+
+       it("gets multiple Topologies", async () => {
+               const responseP = service.getTopologies();
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/topologies`);
+               expect(req.request.method).toBe("GET");
+               expect(req.request.params.keys().length).toBe(0);
+               req.flush({response: [topology]});
+               await expectAsync(responseP).toBeResolvedTo([topology]);
+       });
+
+       it("creates a new Topology", async () => {
+               const responseP = service.createTopology(topology);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/topologies`);
+               expect(req.request.method).toBe("POST");
+               expect(req.request.params.keys().length).toBe(0);
+               expect(req.request.body).toBe(topology);
+               req.flush({response: topology});
+               await expectAsync(responseP).toBeResolved();
+       });
+
+       it("updates an existing Topology", async () => {
+               const responseP = service.updateTopology(topology);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/topologies?name=${topology.name}`);
+               expect(req.request.method).toBe("PUT");
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.body).toBe(topology);
+               req.flush({response: topology});
+               await expectAsync(responseP).toBeResolved();
+       });
+
+       it("updates an existing Topology by name", async () => {
+               const responseP = service.updateTopology(topology, 
topology.name);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/topologies?name=${topology.name}`);
+               expect(req.request.method).toBe("PUT");
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.body).toBe(topology);
+               req.flush({response: topology});
+               await expectAsync(responseP).toBeResolved();
+       });
+
+       it("deletes an existing Topology by name", async () => {
+               const name = "my-topology";
+               const responseP = service.deleteTopology(name);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/topologies?name=${name}`);
+               expect(req.request.method).toBe("DELETE");
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.body).toBeNull();
+               req.flush({response: topology});
+               await expectAsync(responseP).toBeResolved();
+       });
+
+       it("deletes an existing Topology", async () => {
+               const responseP = service.deleteTopology(topology);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/topologies?name=${topology.name}`);
+               expect(req.request.method).toBe("DELETE");
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.body).toBeNull();
+               req.flush({response: topology});
+               await expectAsync(responseP).toBeResolved();
+       });
+
+       it("converts from a material tree to a topology", () => {
+               const result = TopologyService.treeToTopology(topology.name, 
topology.description, tree);
+               topology.lastUpdated = result.lastUpdated;
+               expect(result).toEqual(topology);
+       });
+
+       it("converts from a topology to a material tree", () => {
+               const result = TopologyService.topologyToTree(topology);
+               result[0].children[0].parents = [];
+               result[0].children[0].children[0].parents = [];
+               expect(result).toEqual(tree);
+       });
+
+       afterEach(() => {
+               httpTestingController.verify();
+       });
+});
diff --git a/experimental/traffic-portal/src/app/api/topology.service.ts 
b/experimental/traffic-portal/src/app/api/topology.service.ts
new file mode 100644
index 0000000000..a71c72f743
--- /dev/null
+++ b/experimental/traffic-portal/src/app/api/topology.service.ts
@@ -0,0 +1,182 @@
+/*
+* 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 { HttpClient } from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import type {
+       RequestTopology,
+       ResponseTopology,
+       ResponseTopologyNode,
+} from "trafficops-types";
+
+import { APIService } from "./base-api.service";
+
+/**
+ * TopTreeNode is used to represent a topology in a format usable as a material
+ * nested tree data source.
+ */
+export interface TopTreeNode {
+       name: string;
+       cachegroup: string;
+       children: Array<TopTreeNode>;
+       parents: Array<this>;
+}
+
+/**
+ * TopologyService exposes API functionality relating to Topologies.
+ */
+@Injectable()
+export class TopologyService extends APIService {
+
+       constructor(http: HttpClient) {
+               super(http);
+       }
+
+       /**
+        * Gets one or all Topologies from Traffic Ops
+        *
+        * @param name The name of a single Topology to be returned.
+        * @returns An Array of Topologies
+        * whether `name` was passed.
+        */
+       public async getTopologies(name?: string): 
Promise<Array<ResponseTopology>> {
+               const path = "topologies";
+               if (name) {
+                       const topology = await 
this.get<[ResponseTopology]>(path, undefined, {name}).toPromise();
+                       if (topology.length !== 1 || topology[0].name !== name) 
{
+                               throw new Error(`${topology.length} Topologies 
found by name ${name}`);
+                       }
+                       return topology;
+               }
+               return this.get<Array<ResponseTopology>>(path).toPromise();
+       }
+
+       /**
+        * Deletes a Topology.
+        *
+        * @param topology The Topology to be deleted, or just its name.
+        */
+       public async deleteTopology(topology: ResponseTopology | string): 
Promise<void> {
+               const name = typeof topology === "string" ? topology : 
topology.name;
+               return this.delete("topologies", undefined, {name}).toPromise();
+       }
+
+       /**
+        * Creates a new Topology.
+        *
+        * @param topology The Topology to create.
+        */
+       public async createTopology(topology: RequestTopology): 
Promise<ResponseTopology> {
+               return this.post<ResponseTopology>("topologies", 
topology).toPromise();
+       }
+
+       /**
+        * Replaces an existing Topology with the provided new definition of a
+        * Topology.
+        *
+        * @param topology The full new definition of the Topology being updated
+        * @param name What the topology was named before it was updated
+        */
+       public async updateTopology(topology: ResponseTopology, name?: string): 
Promise<ResponseTopology> {
+               if (typeof name === "undefined") {
+                       name = topology.name;
+               }
+               return this.put<ResponseTopology>("topologies", topology, 
{name}).toPromise();
+       }
+
+       /**
+        * Generates a material tree from a topology.
+        *
+        * @param topology The topology to generate a material tree from.
+        * @returns a material tree.
+        */
+       public static topologyToTree(topology: ResponseTopology): 
Array<TopTreeNode> {
+               const treeNodes: Array<TopTreeNode> = [];
+               const topLevel: Array<TopTreeNode> = [];
+               for (const node of topology.nodes) {
+                       treeNodes.push({
+                               cachegroup: node.cachegroup,
+                               children: [],
+                               name: node.cachegroup,
+                               parents: [],
+                       });
+               }
+               for (let index = 0; index < topology.nodes.length; index++) {
+                       const node = topology.nodes[index];
+                       const treeNode = treeNodes[index];
+                       if (!Array.isArray(node.parents) || node.parents.length 
< 1) {
+                               topLevel.push(treeNode);
+                               continue;
+                       }
+                       for (const parent of node.parents) {
+                               treeNodes[parent].children.push(treeNode);
+                               treeNode.parents.push(treeNodes[parent]);
+                       }
+               }
+               return topLevel;
+       }
+
+       /**
+        * Generates a topology from a material tree.
+        *
+        * @param name The topology name
+        * @param description The topology description
+        * @param treeNodes The data for a material tree
+        * @returns a topology.
+        */
+       public static treeToTopology(name: string, description: string, 
treeNodes: Array<TopTreeNode>): ResponseTopology {
+               const topologyNodeIndicesByCacheGroup: Map<string, number> = 
new Map();
+               const nodes: Array<ResponseTopologyNode> = new 
Array<ResponseTopologyNode>();
+               this.treeToTopologyInner(topologyNodeIndicesByCacheGroup, 
nodes, undefined, treeNodes);
+               return {
+                       description,
+                       lastUpdated: new Date(),
+                       name,
+                       nodes,
+               };
+       }
+
+       /**
+        * Inner recursive function for generating a Topology from a material 
tree.
+        *
+        * @param topologyNodeIndicesByCacheGroup A map of Topology node indices
+        * using cache group names as the key
+        * @param topologyNodes The mutable array of Topology nodes
+        * @param parent The parent, if it exists
+        * @param treeNodes The data for a material tree
+        */
+       protected static treeToTopologyInner(
+               topologyNodeIndicesByCacheGroup: Map<string, number>,
+               topologyNodes: Array<ResponseTopologyNode>,
+               parent: ResponseTopologyNode | undefined,
+               treeNodes: Array<TopTreeNode>): void {
+
+               for (const treeNode of treeNodes) {
+                       const cachegroup = treeNode.cachegroup;
+                       const parents: number[] = [];
+                       if (typeof parent !== "undefined") {
+                               const index = 
topologyNodeIndicesByCacheGroup.get(parent.cachegroup) as number;
+                               parents.push(index);
+                       }
+                       const topologyNode: ResponseTopologyNode = {
+                               cachegroup,
+                               parents,
+                       };
+                       topologyNodes.push(topologyNode);
+                       topologyNodeIndicesByCacheGroup.set(cachegroup, 
topologyNodes.length - 1);
+                       if (treeNode.children.length > 0) {
+                               
this.treeToTopologyInner(topologyNodeIndicesByCacheGroup, topologyNodes, 
topologyNode, treeNode.children);
+                       }
+               }
+       }
+}
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts 
b/experimental/traffic-portal/src/app/core/core.module.ts
index db9257a8c5..acc912ae87 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -64,6 +64,7 @@ import { ServersTableComponent } from 
"./servers/servers-table/servers-table.com
 import { UpdateStatusComponent } from 
"./servers/update-status/update-status.component";
 import { StatusDetailsComponent } from 
"./statuses/status-details/status-details.component";
 import { StatusesTableComponent } from 
"./statuses/statuses-table/statuses-table.component";
+import { TopologyDetailsComponent } from 
"./topologies/topology-details/topology-details.component";
 import { TypeDetailComponent } from "./types/detail/type-detail.component";
 import { TypesTableComponent } from "./types/table/types-table.component";
 import { RoleDetailComponent } from 
"./users/roles/detail/role-detail.component";
@@ -124,6 +125,8 @@ export const ROUTES: Routes = [
        { component: ISOGenerationFormComponent, path: "iso-gen"},
        { component: ProfileDetailComponent, path: "profiles/:id"},
        { component: ProfileTableComponent, path: "profiles"},
+       { component: TopologyDetailsComponent, path: "topologies/:name"},
+       { component: TopologyDetailsComponent, path: "new-topology"},
 ].map(r => ({...r, canActivate: [AuthenticatedGuard]}));
 
 /**
@@ -177,6 +180,7 @@ export const ROUTES: Routes = [
                ProfileDetailComponent,
                CapabilitiesComponent,
                CapabilityDetailsComponent,
+               TopologyDetailsComponent,
        ],
        exports: [],
        imports: [
diff --git 
a/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.html
 
b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.html
new file mode 100644
index 0000000000..056d2c1f91
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.html
@@ -0,0 +1,44 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<mat-card appearance="outlined" class="page-content single-column">
+       <tp-loading *ngIf="loading"></tp-loading>
+       <form ngNativeValidate (ngSubmit)="submit($event)">
+               <mat-card-content class="container">
+                       <mat-form-field>
+                               <mat-label>Name</mat-label>
+                               <input matInput type="text" name="name" 
required [(ngModel)]="topology.name">
+                       </mat-form-field>
+                       <mat-form-field>
+                               <mat-label>Description</mat-label>
+                               <textarea matInput name="description" 
[(ngModel)]="topology.description"></textarea>
+                       </mat-form-field>
+               </mat-card-content>
+               <mat-card-content class="container">
+                       <mat-tree [dataSource]="topologySource" 
[treeControl]="topologyControl">
+                               <mat-nested-tree-node *matTreeNodeDef="let 
node; when: hasChild">
+                                       <div class="mat-tree-node" 
matTreeNodeToggle mat-menu-item [attr.aria-label]="'Toggle ' + node.name">
+                                               {{node.name}}
+                                       </div>
+                                       <div class="expand-node" role="group">
+                                               <ng-container 
matTreeNodeOutlet></ng-container>
+                                       </div>
+                               </mat-nested-tree-node>
+                       </mat-tree>
+               </mat-card-content>
+               <mat-card-actions align="end" class="actions-container">
+                       <button mat-raised-button type="button" *ngIf="!new" 
color="warn" (click)="delete()">Delete</button>
+                       <button mat-raised-button color="primary" 
type="submit">Save</button>
+               </mat-card-actions>
+       </form>
+</mat-card>
diff --git 
a/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.scss
 
b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.scss
new file mode 100644
index 0000000000..312162befb
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.scss
@@ -0,0 +1,16 @@
+/*
+* 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.
+*/
+.expand-node {
+       padding-left: 40px;
+}
diff --git 
a/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.spec.ts
new file mode 100644
index 0000000000..16bebed199
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.spec.ts
@@ -0,0 +1,70 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { MatDialogModule } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { ReplaySubject } from "rxjs";
+
+import { APITestingModule } from "src/app/api/testing";
+import {
+       NavigationService
+} from "src/app/shared/navigation/navigation.service";
+
+import { TopologyDetailsComponent } from "./topology-details.component";
+
+describe("TopologyDetailsComponent", () => {
+       let component: TopologyDetailsComponent;
+       let fixture: ComponentFixture<TopologyDetailsComponent>;
+       let route: ActivatedRoute;
+       let paramMap: jasmine.Spy;
+
+       const navSvc = jasmine.createSpyObj([], {
+               headerHidden: new ReplaySubject<boolean>(),
+               headerTitle: new ReplaySubject<string>(),
+       });
+
+       beforeEach(async () => {
+               TestBed.configureTestingModule({
+                       declarations: [TopologyDetailsComponent],
+                       imports: [APITestingModule, RouterTestingModule, 
MatDialogModule],
+                       providers: [{provide: NavigationService, useValue: 
navSvc}],
+               });
+               route = TestBed.inject(ActivatedRoute);
+               paramMap = spyOn(route.snapshot.paramMap, "get");
+               paramMap.and.returnValue(null);
+               fixture = TestBed.createComponent(TopologyDetailsComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+               await fixture.whenStable();
+       });
+
+       it("should create", () => {
+               expect(component).toBeTruthy();
+               expect(paramMap).toHaveBeenCalled();
+       });
+
+       it("existing topology", async () => {
+               paramMap.and.returnValue("test");
+
+               fixture = TestBed.createComponent(TopologyDetailsComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+               await fixture.whenStable();
+               expect(paramMap).toHaveBeenCalled();
+               expect(component.topology).toBeDefined();
+               expect(component.topology.name).toBe("test");
+               expect(component.new).toBeFalse();
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.ts
 
b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.ts
new file mode 100644
index 0000000000..81089fedd1
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/topologies/topology-details/topology-details.component.ts
@@ -0,0 +1,158 @@
+/*
+* 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 { NestedTreeControl } from "@angular/cdk/tree";
+import { Location } from "@angular/common";
+import { Component, OnInit } from "@angular/core";
+import { MatDialog } from "@angular/material/dialog";
+import { MatTreeNestedDataSource } from "@angular/material/tree";
+import { ActivatedRoute } from "@angular/router";
+import { ResponseTopology } from "trafficops-types";
+
+import { TopologyService, TopTreeNode } from "src/app/api";
+import {
+       DecisionDialogComponent,
+       DecisionDialogData,
+} from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import {
+       NavigationService
+} from "src/app/shared/navigation/navigation.service";
+
+/**
+ * TopologyDetailComponent is the controller for a Topology's "detail" page.
+ */
+@Component({
+       selector: "tp-topology-details",
+       styleUrls: ["./topology-details.component.scss"],
+       templateUrl: "./topology-details.component.html",
+})
+export class TopologyDetailsComponent implements OnInit {
+       public new = false;
+
+       /** Loader status for the actions */
+       public loading = true;
+
+       public oldName: string | undefined = undefined;
+
+       public topology: ResponseTopology = {
+               description: "",
+               lastUpdated: new Date(),
+               name: "",
+               nodes: [],
+       };
+       public showErrors = false;
+       public topologies: Array<ResponseTopology> = [];
+       public topologySource = new MatTreeNestedDataSource<TopTreeNode>();
+       public topologyControl = new NestedTreeControl<TopTreeNode>(node => 
node.children);
+
+       constructor(
+               private readonly route: ActivatedRoute,
+               private readonly api: TopologyService,
+               private readonly location: Location,
+               private readonly dialog: MatDialog,
+               private readonly navSvc: NavigationService,
+       ) {
+       }
+
+       /**
+        * Angular lifecycle hook where data is initialized.
+        */
+       public async ngOnInit(): Promise<void> {
+               const name = this.route.snapshot.paramMap.get("name");
+
+               const topologiesPromise = 
this.api.getTopologies().then(topologies => this.topologies = topologies);
+               if (name === null) {
+                       this.new = true;
+                       this.setTitle();
+                       await topologiesPromise;
+                       this.loading = false;
+                       return;
+               }
+               await topologiesPromise;
+               const index = this.topologies.findIndex(c => c.name === name);
+               if (index < 0) {
+                       console.error(`no such Topology: ${name}`);
+                       this.loading = false;
+                       return;
+               }
+               this.oldName = name;
+               this.topology = this.topologies.splice(index, 1)[0];
+               this.topologySource.data = 
TopologyService.topologyToTree(this.topology);
+               this.loading = false;
+       }
+
+       /**
+        * Used by angular to determine if this node should be a nested tree 
node.
+        *
+        * @param _ Index of the current node.
+        * @param node Node to test.
+        * @returns If the node has children.
+        */
+       public hasChild(_: number, node: TopTreeNode): boolean {
+               return Array.isArray(node.children) && node.children.length > 0;
+       }
+
+       /**
+        * Sets the title of the page to either "new" or the name of the 
displayed
+        * Topology, depending on the value of TopologyDetailComponent.new.
+        */
+       private setTitle(): void {
+               const title = this.new ? "New Topology" : `Topology: 
${this.topology.name}`;
+               this.navSvc.headerTitle.next(title);
+       }
+
+       /**
+        * Deletes the Topology.
+        */
+       public async delete(): Promise<void> {
+               if (this.new) {
+                       console.error("Unable to delete new Topology");
+                       return;
+               }
+               const ref = this.dialog.open<DecisionDialogComponent, 
DecisionDialogData, boolean>(
+                       DecisionDialogComponent,
+                       {
+                               data: {
+                                       message: `Are you sure you want to 
delete Topology ${this.topology.name}?`,
+                                       title: "Confirm Delete"
+                               }
+                       }
+               );
+               ref.afterClosed().subscribe(result => {
+                       if (result) {
+                               this.api.deleteTopology(this.topology);
+                               this.location.replaceState("core/topologies");
+                       }
+               });
+       }
+
+       /**
+        * Submits new/updated Topology.
+        *
+        * @param e HTML form submission event.
+        */
+       public async submit(e: Event): Promise<void> {
+               this.topology = 
TopologyService.treeToTopology(this.topology.name, this.topology.description, 
this.topologySource.data);
+
+               e.preventDefault();
+               e.stopPropagation();
+               this.showErrors = true;
+               if (this.new) {
+                       this.topology = await 
this.api.createTopology(this.topology);
+                       this.new = false;
+               } else {
+                       this.topology = await 
this.api.updateTopology(this.topology, this.oldName);
+               }
+               this.setTitle();
+       }
+}


Reply via email to