diff --git a/.dependency_license b/.dependency_license index 7ca957d3d8..941f286d3d 100644 --- a/.dependency_license +++ b/.dependency_license @@ -115,6 +115,8 @@ traffic_portal/app/src/assets/js/chartjs/angular-chart\..*, BSD traffic_portal/app/src/assets/css/jsonformatter\..*, Apache traffic_portal/app/src/assets/js/jsonformatter\..*, Apache traffic_portal/app/src/assets/js/fast-json-patch\..*, MIT +traffic_portal/app/src/assets/css/angular-ui-tree\..*, MIT +traffic_portal/app/src/assets/js/angular-ui-tree\..*, MIT traffic_portal/app/src/assets/css/colReorder.dataTables\..*, MIT traffic_portal/app/src/assets/js/colReorder.dataTables\..*, MIT traffic_portal/app/src/assets/js/dataTables.buttons\..*, MIT diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e84d14765..2bb3c2b393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added - Traffic Ops API v3 - Added an optional readiness check service to cdn-in-a-box that exits successfully when it is able to get a `200 OK` from all delivery services +- [Flexible Topologies (in progress)](https://github.com/apache/trafficcontrol/blob/master/blueprints/flexible-topologies.md) + - Traffic Ops: Added an API 3.0 endpoint, /api/3.0/topologies, to create, read, update and delete flexible topologies. + - Traffic Portal: Added the ability to create, read, update and delete flexible topologies. ### Changed diff --git a/LICENSE b/LICENSE index 544a666722..0d0f1f96df 100644 --- a/LICENSE +++ b/LICENSE @@ -273,6 +273,11 @@ For the angular-moment-picker component: @traffic_portal/app/src/assets/js/moment-picker/angular-moment-picker.min_0.10.2.js ./licenses/MIT-angular_moment_picker +For the angular-ui-tree component: +@traffic_portal/app/src/assets/css/angular-ui-tree.min_2.22.6.css +@traffic_portal/app/src/assets/js/angular-ui-tree.min_2.22.6.js +./licenses/MIT-angular_ui_tree + For the Chart.js component: @traffic_portal/app/src/assets/js/chartjs/Chart.min_2.7.2.js ./licenses/MIT-chartjs diff --git a/docs/source/api/v3/topologies.rst b/docs/source/api/v3/topologies.rst new file mode 100644 index 0000000000..3648a4a19d --- /dev/null +++ b/docs/source/api/v3/topologies.rst @@ -0,0 +1,593 @@ +.. +.. +.. 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. +.. + +.. _to-api-topologies: + +************** +``topologies`` +************** + +``GET`` +======= +Retrieves :term:`Topologies`. + +:Auth. Required: Yes +:Roles Required: "read-only" +:Response Type: Array + +Request Structure +----------------- +.. table:: Request Query Parameters + + +------+----------+-----------------------------------------------------+ + | Name | Required | Description | + +======+==========+=====================================================+ + | name | no | Return the :term:`Topology` with this name | + +------+----------+-----------------------------------------------------+ + +.. code-block:: http + :caption: Request Example + + GET /api/3.0/topologies HTTP/1.1 + User-Agent: python-requests/2.23.0 + Accept-Encoding: gzip, deflate + Accept: */* + Connection: keep-alive + Cookie: mojolicious=... + +Response Structure +------------------ +:description: A short sentence that describes the :term:`Topology`. +:lastUpdated: The date and time at which this :term:`Topology` was last updated, in ISO-like format +:name: The name of the :term:`Topology`. This can only be letters, numbers, and dashes. +:nodes: An array of nodes in the :term:`Topology` + + :cachegroup: The name of a :term:`Cache Group` + :parents: The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max + +.. code-block:: http + :caption: Response Example + + HTTP/1.1 200 OK + Access-Control-Allow-Credentials: true + Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie + Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE + Access-Control-Allow-Origin: * + Content-Encoding: gzip + Content-Type: application/json + Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:22:32 GMT; Max-Age=3600; HttpOnly + Whole-Content-Sha512: lF4MCJCinuQWz0flLAAZBrzbuPVsHrNn2BtTozRZojEjGpm76IsXBQK5QOwSwBoHac+D0C1Z3p7M8kdjcfgIIg== + X-Server-Name: traffic_ops_golang/ + Date: Mon, 13 Apr 2020 17:22:32 GMT + Content-Length: 205 + + { + "response": [ + { + "description": "This is my topology", + "name": "my-topology", + "nodes": [ + { + "cachegroup": "edge1", + "parents": [ + 7 + ] + }, + { + "cachegroup": "edge2", + "parents": [ + 7, + 8 + ] + }, + { + "cachegroup": "edge3", + "parents": [ + 8, + 9 + ] + }, + { + "cachegroup": "edge4", + "parents": [ + 9 + ] + }, + { + "cachegroup": "mid1", + "parents": [] + }, + { + "cachegroup": "mid2", + "parents": [ + 4 + ] + }, + { + "cachegroup": "mid3", + "parents": [ + 4 + ] + }, + { + "cachegroup": "mid4", + "parents": [ + 5 + ] + }, + { + "cachegroup": "mid5", + "parents": [ + 5, + 6 + ] + }, + { + "cachegroup": "mid6", + "parents": [ + 6 + ] + } + ], + "lastUpdated": "2020-04-13 17:12:34+00" + } + ] + } + +``POST`` +======== +Create a new :term:`Topology`. + +:Auth. Required: Yes +:Roles Required: "admin" or "operations" +:Response Type: Object + +Request Structure +----------------- +:description: A short sentence that describes the topology. +:name: The name of the topology. This can only be letters, numbers, and dashes. +:nodes: An array of nodes in the :term:`Topology` + + :cachegroup: The name of a :term:`Cache Group` + :parents: The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max + +.. code-block:: http + :caption: Request Example + + POST /api/3.0/topologies HTTP/1.1 + User-Agent: python-requests/2.23.0 + Accept-Encoding: gzip, deflate + Accept: */* + Connection: keep-alive + Cookie: mojolicious=... + Content-Length: 924 + Content-Type: application/json + + { + "name": "my-topology", + "description": "This is my topology", + "nodes": [ + { + "cachegroup": "edge1", + "parents": [ + 7 + ] + }, + { + "cachegroup": "edge2", + "parents": [ + 7, + 8 + ] + }, + { + "cachegroup": "edge3", + "parents": [ + 8, + 9 + ] + }, + { + "cachegroup": "edge4", + "parents": [ + 9 + ] + }, + { + "cachegroup": "mid1", + "parents": [] + }, + { + "cachegroup": "mid2", + "parents": [ + 4 + ] + }, + { + "cachegroup": "mid3", + "parents": [ + 4 + ] + }, + { + "cachegroup": "mid4", + "parents": [ + 5 + ] + }, + { + "cachegroup": "mid5", + "parents": [ + 5, + 6 + ] + }, + { + "cachegroup": "mid6", + "parents": [ + 6 + ] + } + ] + } + +Response Structure +------------------ +:description: A short sentence that describes the topology. +:lastUpdated: The date and time at which this :term:`Topology` was last updated, in ISO-like format +:name: The name of the topology. This can only be letters, numbers, and dashes. +:nodes: An array of nodes in the :term:`Topology` + + :cachegroup: The name of a :term:`Cache Group` + :parents: The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max + +.. code-block:: http + :caption: Response Example + + HTTP/1.1 200 OK + Access-Control-Allow-Credentials: true + Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie + Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE + Access-Control-Allow-Origin: * + Content-Encoding: gzip + Content-Type: application/json + Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:12:34 GMT; Max-Age=3600; HttpOnly + Whole-Content-Sha512: ftNcDRjYCDMkQM+o/szayKZriQZHGpcT0vNY0HpKgy88i0pXeEEeLGbUPh6LXtK7TvL76EgGECTzvCkcm+2LVA== + X-Server-Name: traffic_ops_golang/ + Date: Mon, 13 Apr 2020 17:12:34 GMT + Content-Length: 239 + + { + "alerts": [ + { + "text": "topology was created.", + "level": "success" + } + ], + "response": { + "description": "This is my topology", + "name": "my-topology", + "nodes": [ + { + "cachegroup": "edge1", + "parents": [ + 7 + ] + }, + { + "cachegroup": "edge2", + "parents": [ + 7, + 8 + ] + }, + { + "cachegroup": "edge3", + "parents": [ + 8, + 9 + ] + }, + { + "cachegroup": "edge4", + "parents": [ + 9 + ] + }, + { + "cachegroup": "mid1", + "parents": [] + }, + { + "cachegroup": "mid2", + "parents": [ + 4 + ] + }, + { + "cachegroup": "mid3", + "parents": [ + 4 + ] + }, + { + "cachegroup": "mid4", + "parents": [ + 5 + ] + }, + { + "cachegroup": "mid5", + "parents": [ + 5, + 6 + ] + }, + { + "cachegroup": "mid6", + "parents": [ + 6 + ] + } + ], + "lastUpdated": "2020-04-13 17:12:34+00" + } + } + +``PUT`` +======= +Updates a specific :term:`Topology` + +Request Structure +----------------- +.. table:: Request Query Parameters + + +------+----------+---------------------------------------------------------+ + | Name | Required | Description | + +======+==========+=========================================================+ + | name | yes | The name of the :term:`Topology` to be updated | + +------+----------+---------------------------------------------------------+ + +:description: A short sentence that describes the :term:`Topology`. +:name: The name of the :term:`Topology`. This can only be letters, numbers, and dashes. +:nodes: An array of nodes in the :term:`Topology` + + :cachegroup: The name of a :term:`Cache Group` + :parents: The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max + +.. code-block:: http + :caption: Request Example + + PUT /api/3.0/topologies?name=my-topology HTTP/1.1 + User-Agent: python-requests/2.23.0 + Accept-Encoding: gzip, deflate + Accept: */* + Connection: keep-alive + Cookie: mojolicious=... + Content-Length: 853 + Content-Type: application/json + + { + "name": "my-topology", + "description": "The description is updated, too", + "nodes": [ + { + "cachegroup": "edge1", + "parents": [ + 6 + ] + }, + { + "cachegroup": "edge2", + "parents": [ + 6, + 7 + ] + }, + { + "cachegroup": "edge3", + "parents": [ + 7, + 8 + ] + }, + { + "cachegroup": "edge4", + "parents": [ + 8 + ] + }, + { + "cachegroup": "mid2", + "parents": [] + }, + { + "cachegroup": "mid3", + "parents": [] + }, + { + "cachegroup": "mid4", + "parents": [ + 4 + ] + }, + { + "cachegroup": "mid5", + "parents": [ + 4, + 5 + ] + }, + { + "cachegroup": "mid6", + "parents": [ + 5 + ] + } + ] + } + +Response Structure +------------------ +:description: A short sentence that describes the :term:`Topology`. +:lastUpdated: The date and time at which this :term:`Topology` was last updated, in ISO-like format +:name: The name of the :term:`Topology`. This can only be letters, numbers, and dashes. +:nodes: An array of nodes in the :term:`Topology` + + :cachegroup: The name of a :term:`Cache Group` + :parents: The indices of the parents of this node in the nodes array, 0-indexed. 2 parents max + +.. code-block:: http + :caption: Response Example + + HTTP/1.1 200 OK + Access-Control-Allow-Credentials: true + Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie + Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE + Access-Control-Allow-Origin: * + Content-Encoding: gzip + Content-Type: application/json + Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:33:13 GMT; Max-Age=3600; HttpOnly + Whole-Content-Sha512: WVOtsoOVrEWcVjWM2TmT5DXy/a5Q0ygTZEQRhbkHHUmz9dgVLK1F5Joc9jtKA8gZu8/eM5+Tqqguh3mzrhAy/Q== + X-Server-Name: traffic_ops_golang/ + Date: Mon, 13 Apr 2020 17:33:13 GMT + Content-Length: 237 + + { + "alerts": [ + { + "text": "topology was updated.", + "level": "success" + } + ], + "response": { + "description": "The description is updated, too", + "name": "my-topology", + "nodes": [ + { + "cachegroup": "edge1", + "parents": [ + 6 + ] + }, + { + "cachegroup": "edge2", + "parents": [ + 6, + 7 + ] + }, + { + "cachegroup": "edge3", + "parents": [ + 7, + 8 + ] + }, + { + "cachegroup": "edge4", + "parents": [ + 8 + ] + }, + { + "cachegroup": "mid2", + "parents": [] + }, + { + "cachegroup": "mid3", + "parents": [] + }, + { + "cachegroup": "mid4", + "parents": [ + 4 + ] + }, + { + "cachegroup": "mid5", + "parents": [ + 4, + 5 + ] + }, + { + "cachegroup": "mid6", + "parents": [ + 5 + ] + } + ], + "lastUpdated": "2020-04-13 17:33:13+00" + } + } + +``DELETE`` +========== +Deletes a specific :term:`Topology`. + +:Auth. Required: Yes +:Roles Required: "admin" or "operations" +:Response Type: ``undefined`` + + +Request Structure +----------------- +.. table:: Request Query Parameters + + +------+----------+---------------------------------------------------------+ + | Name | Required | Description | + +======+==========+=========================================================+ + | name | yes | The name of the :term:`Topology` to be deleted | + +------+----------+---------------------------------------------------------+ + +.. code-block:: http + :caption: Request Example + + DELETE /api/3.0/topologies?name=my-topology HTTP/1.1 + User-Agent: python-requests/2.23.0 + Accept-Encoding: gzip, deflate + Accept: */* + Connection: keep-alive + Cookie: mojolicious=... + Content-Length: 0 + +Response Structure +------------------ + +.. code-block:: http + :caption: Response Example + + HTTP/1.1 200 OK + Access-Control-Allow-Credentials: true + Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie + Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE + Access-Control-Allow-Origin: * + Content-Encoding: gzip + Content-Type: application/json + Set-Cookie: mojolicious=...; Path=/; Expires=Mon, 13 Apr 2020 18:35:32 GMT; Max-Age=3600; HttpOnly + Whole-Content-Sha512: yErJobzG9IA0khvqZQK+Yi7X4pFVvOqxn6PjrdzN5DnKVm/K8Kka3REul1XmKJnMXVRY8RayoEVGDm16mBFe4Q== + X-Server-Name: traffic_ops_golang/ + Date: Mon, 13 Apr 2020 17:35:32 GMT + Content-Length: 87 + + { + "alerts": [ + { + "text": "topology was deleted.", + "level": "success" + } + ] + } diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index 455dfd52d1..3f90813b22 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -390,6 +390,10 @@ Glossary Tenancies Users are grouped into :dfn:`Tenants` (or :dfn:`Tenancies`) to segregate ownership of and permissions over :term:`Delivery Services` and their resources. To be clear, the notion of :dfn:`Tenancy` **only** applies within the context of :term:`Delivery Services` and does **not** apply permissions restrictions to any other aspect of Traffic Control. + Topology + Topologies + A structure composed of :term:`Cache Groups` and parent relationships, which is assignable to one or more :term:`Delivery Services`. + Type Types A :dfn:`Type` defines a type of some kind of object configured in Traffic Ops. Unfortunately, that is exactly as specific as this definition can be. diff --git a/lib/go-tc/cachegroups.go b/lib/go-tc/cachegroups.go index 7b9d3061db..f073d70cef 100644 --- a/lib/go-tc/cachegroups.go +++ b/lib/go-tc/cachegroups.go @@ -95,3 +95,4 @@ type CachegroupQueueUpdatesRequest struct { CDN *CDNName `json:"cdn"` CDNID *util.JSONIntStr `json:"cdnId"` } + diff --git a/lib/go-tc/enum.go b/lib/go-tc/enum.go index bcc57392b6..0cb8e440b5 100644 --- a/lib/go-tc/enum.go +++ b/lib/go-tc/enum.go @@ -76,7 +76,11 @@ const MidTypePrefix = "MID" const OriginTypeName = "ORG" -const CacheGroupOriginTypeName = "ORG_LOC" +const ( + CacheGroupEdgeTypeName = "EDGE_LOC" + CacheGroupMidTypeName = "MID_LOC" + CacheGroupOriginTypeName = "ORG_LOC" +) const GlobalProfileName = "GLOBAL" diff --git a/lib/go-tc/topologies.go b/lib/go-tc/topologies.go new file mode 100644 index 0000000000..e7c664494c --- /dev/null +++ b/lib/go-tc/topologies.go @@ -0,0 +1,49 @@ +package tc + +/* + * 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. + */ + +// Topology holds the name and set of TopologyNodes that comprise a flexible topology. +type Topology struct { + Description string `json:"description" db:"description"` + Name string `json:"name" db:"name"` + Nodes []TopologyNode `json:"nodes"` + LastUpdated *TimeNoMod `json:"lastUpdated" db:"last_updated"` +} + +// TopologyNode holds a reference to a cachegroup and the indices of up to 2 parent +// nodes in the same topology's array of nodes. +type TopologyNode struct { + Id int `json:"-" db:"id"` + Cachegroup string `json:"cachegroup" db:"cachegroup"` + Parents []int `json:"parents"` + LastUpdated *TimeNoMod `json:"-" db:"last_updated"` +} + +// TopologiesResponse models the JSON object returned for a single topology in a response. +type TopologyResponse struct { + Response Topology `json:"response"` + Alerts +} + +// TopologiesResponse models the JSON object returned for a list of topologies in a response. +type TopologiesResponse struct { + Response []Topology `json:"response"` + Alerts +} diff --git a/licenses/MIT-angular_ui_tree b/licenses/MIT-angular_ui_tree new file mode 100644 index 0000000000..f80a016467 --- /dev/null +++ b/licenses/MIT-angular_ui_tree @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/traffic_control/clients/python/trafficops/tosession.py b/traffic_control/clients/python/trafficops/tosession.py index f6355a96da..3a4e12f869 100644 --- a/traffic_control/clients/python/trafficops/tosession.py +++ b/traffic_control/clients/python/trafficops/tosession.py @@ -21,6 +21,8 @@ # Core Modules import logging import sys +from requests import Response +from typing import Any, Dict, List, Tuple, Union # Third-party Modules import munch @@ -1866,6 +1868,53 @@ def delete_to_extension(self, extension_id=None): :raises: Union[LoginError, OperationError] """ + # + # Topologies + # + @api_request('post', 'topologies', ('3.0',)) + def create_topology(self, data: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]: + """ + :ref:`to-api-topologies` + :param data: The Topology data to use for Topology creation. + :type data: Dict[str, Any] + :rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response] + :raises: Union[LoginError, OperationError] + """ + + @api_request('get', 'topologies', ('3.0',)) + def create_topology(self, data: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]: + """ + :ref:`to-api-topologies` + Get Topologies. + :ref:`to-api-topologies` + :rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response] + :raises: Union[LoginError, OperationError] + """ + + @api_request('put', 'topologies', ('3.0',)) + def update_topologies(self, data: Dict[str, Any]=None, query_params: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]: + """ + Update a Topology + :ref:`to-api-topologies` + :param data: The new values for the Topology + :type data: Dict[str, Any] + :type query: Dict[str, Any] + :param query_params: 'id' is a required parameter, defining the Topology to be replaced. + :rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response] + :raises: Union[LoginError, OperationError] + """ + + @api_request('delete', 'topologies', ('3.0',)) + def delete_topology(self, query_params: Dict[str, Any]=None) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response]: + """ + Delete a Topology + :ref:`to-api-topologies` + :param topology_id: The ID of the Topology to delete + :type topology_id: int + :rtype: Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], Response] + :raises: Union[LoginError, OperationError] + """ + # # Types # diff --git a/traffic_ops/app/db/migrations/20200422101648_create_topology_tables.sql b/traffic_ops/app/db/migrations/20200422101648_create_topology_tables.sql new file mode 100644 index 0000000000..f3c1f60957 --- /dev/null +++ b/traffic_ops/app/db/migrations/20200422101648_create_topology_tables.sql @@ -0,0 +1,68 @@ +/* + * 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. + */ + +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +CREATE TABLE topology ( + name text PRIMARY KEY, + description text NOT NULL, + last_updated timestamp with time zone DEFAULT now() NOT NULL +); +DROP TRIGGER IF EXISTS on_update_current_timestamp ON topology; +CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON topology FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated(); + +CREATE TABLE topology_cachegroup ( + id BIGSERIAL PRIMARY KEY, + topology text NOT NULL, + cachegroup text NOT NULL, + last_updated timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT topology_cachegroup_cachegroup_fkey FOREIGN KEY (cachegroup) REFERENCES cachegroup(name) ON UPDATE CASCADE ON DELETE RESTRICT, + CONSTRAINT topology_cachegroup_topology_fkey FOREIGN KEY (topology) REFERENCES topology(name) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT unique_topology_cachegroup UNIQUE (topology, cachegroup) +); +CREATE INDEX topology_cachegroup_cachegroup_fkey ON topology_cachegroup USING btree (cachegroup); +CREATE INDEX topology_cachegroup_topology_fkey ON topology_cachegroup USING btree (topology); +DROP TRIGGER IF EXISTS on_update_current_timestamp ON topology_cachegroup; +CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON topology_cachegroup FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated(); + +CREATE TABLE topology_cachegroup_parents ( + child bigint NOT NULL, + parent bigint NOT NULL, + rank integer NOT NULL, + last_updated timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT topology_cachegroup_parents_rank_check CHECK (rank = 1 OR rank = 2), + CONSTRAINT topology_cachegroup_parents_child_fkey FOREIGN KEY (child) REFERENCES topology_cachegroup(id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT topology_cachegroup_parents_parent_fkey FOREIGN KEY (parent) REFERENCES topology_cachegroup(id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT unique_child_rank UNIQUE (child, rank), + CONSTRAINT unique_child_parent UNIQUE (child, parent) +); +CREATE INDEX topology_cachegroup_parents_child_fkey ON topology_cachegroup_parents USING btree (child); +CREATE INDEX topology_cachegroup_parents_parents_fkey ON topology_cachegroup_parents USING btree (parent); +DROP TRIGGER IF EXISTS on_update_current_timestamp ON topology_cachegroup_parents; +CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON topology_cachegroup_parents FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated(); + +ALTER TABLE deliveryservice + ADD COLUMN topology text, + ADD CONSTRAINT deliveryservice_topology_fkey FOREIGN KEY (topology) REFERENCES topology (name) ON UPDATE CASCADE ON DELETE RESTRICT; +CREATE INDEX deliveryservice_topology_fkey ON deliveryservice USING btree (topology); + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back +ALTER TABLE deliveryservice DROP COLUMN topology; +DROP TABLE topology_cachegroup_parents; +DROP TABLE topology_cachegroup; +DROP TABLE topology; diff --git a/traffic_ops/client/topology.go b/traffic_ops/client/topology.go new file mode 100644 index 0000000000..bc95dff828 --- /dev/null +++ b/traffic_ops/client/topology.go @@ -0,0 +1,123 @@ +/* + + 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. +*/ + +package client + +import ( + "encoding/json" + "fmt" + "github.com/apache/trafficcontrol/lib/go-log" + "github.com/apache/trafficcontrol/lib/go-tc" + "net" + "net/http" + "net/url" +) + +const ( + ApiTopologies = apiBase + "/topologies" +) + +// CreateTopology creates a topology and returns the response. +func (to *Session) CreateTopology(top tc.Topology) (*tc.TopologyResponse, ReqInf, error) { + var remoteAddr net.Addr + reqBody, err := json.Marshal(top) + reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr} + if err != nil { + return nil, reqInf, err + } + resp, remoteAddr, err := to.request(http.MethodPost, ApiTopologies, reqBody) + if err != nil { + return nil, reqInf, err + } + defer log.Close(resp.Body, "unable to close response") + var topResp tc.TopologyResponse + if err = json.NewDecoder(resp.Body).Decode(&topResp); err != nil { + return nil, reqInf, err + } + return &topResp, reqInf, nil +} + +// GetTopologies returns all topologies. +func (to *Session) GetTopologies() ([]tc.Topology, ReqInf, error) { + resp, remoteAddr, err := to.request(http.MethodGet, ApiTopologies, nil) + reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr} + if err != nil { + return nil, reqInf, err + } + defer log.Close(resp.Body, "unable to close response") + + var data tc.TopologiesResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, reqInf, err + } + + return data.Response, reqInf, nil +} + +// GetTopology returns the given topology by name. +func (to *Session) GetTopology(name string) (*tc.Topology, ReqInf, error) { + reqUrl := fmt.Sprintf("%s?name=%s", ApiTopologies, url.QueryEscape(name)) + resp, remoteAddr, err := to.request(http.MethodGet, reqUrl, nil) + reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr} + if err != nil { + return nil, reqInf, err + } + defer log.Close(resp.Body, "unable to close response") + + var data tc.TopologiesResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, reqInf, err + } + + if len(data.Response) == 1 { + return &data.Response[0], reqInf, nil + } + return nil, reqInf, fmt.Errorf("expected one topology in response, instead got: %+v", data.Response) +} + +// UpdateTopology updates a Topology by name. +func (to *Session) UpdateTopology(name string, t tc.Topology) (*tc.TopologyResponse, ReqInf, error) { + var remoteAddr net.Addr + reqBody, err := json.Marshal(t) + reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr} + if err != nil { + return nil, reqInf, err + } + route := fmt.Sprintf("%s?name=%s", ApiTopologies, name) + resp, remoteAddr, err := to.request(http.MethodPut, route, reqBody) + if err != nil { + return nil, reqInf, err + } + defer log.Close(resp.Body, "unable to close response") + var response = new(tc.TopologyResponse) + err = json.NewDecoder(resp.Body).Decode(response) + return response, reqInf, err +} + +// DeleteTopology deletes the given topology by name. +func (to *Session) DeleteTopology(name string) (tc.Alerts, ReqInf, error) { + reqUrl := fmt.Sprintf("%s?name=%s", ApiTopologies, url.QueryEscape(name)) + resp, remoteAddr, err := to.request(http.MethodDelete, reqUrl, nil) + reqInf := ReqInf{CacheHitStatus: CacheHitStatusMiss, RemoteAddr: remoteAddr} + if err != nil { + return tc.Alerts{}, reqInf, err + } + defer log.Close(resp.Body, "unable to close response") + var alerts tc.Alerts + if err = json.NewDecoder(resp.Body).Decode(&alerts); err != nil { + return tc.Alerts{}, reqInf, err + } + return alerts, reqInf, nil +} diff --git a/traffic_ops/testing/api/v3/tc-fixtures.json b/traffic_ops/testing/api/v3/tc-fixtures.json index 3ed76610e8..9fc1cc2a3c 100644 --- a/traffic_ops/testing/api/v3/tc-fixtures.json +++ b/traffic_ops/testing/api/v3/tc-fixtures.json @@ -31,6 +31,13 @@ "shortName": "pg1", "typeName": "MID_LOC" }, + { + "latitude": 0, + "longitude": 0, + "name": "parentCachegroup2", + "shortName": "pg2", + "typeName": "MID_LOC" + }, { "latitude": 0, "longitude": 0, @@ -2222,6 +2229,48 @@ "parentName": "root" } ], + "topologies": [ + { + "name": "my-topology", + "description": "a topology", + "nodes": [ + { + "cachegroup": "parentCachegroup", + "parents": [] + }, + { + "cachegroup": "secondaryCachegroup", + "parents": [] + }, + { + "cachegroup": "cachegroup1", + "parents": [0, 1] + } + ] + }, + { + "name": "another-topology", + "description": "another topology", + "nodes": [ + { + "cachegroup": "parentCachegroup", + "parents": [] + }, + { + "cachegroup": "cachegroup1", + "parents": [0] + }, + { + "cachegroup": "secondaryCachegroup", + "parents": [] + }, + { + "cachegroup": "cachegroup2", + "parents": [2] + } + ] + } + ], "types": [ { "description": "Host header regular expression", diff --git a/traffic_ops/testing/api/v3/topologies_test.go b/traffic_ops/testing/api/v3/topologies_test.go new file mode 100644 index 0000000000..1577abbf7e --- /dev/null +++ b/traffic_ops/testing/api/v3/topologies_test.go @@ -0,0 +1,141 @@ +package v3 + +/* + * 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 ( + "fmt" + "github.com/apache/trafficcontrol/lib/go-tc" + "reflect" + "testing" +) + +type topologyTestCase struct { + reasonToFail string + tc.Topology +} + +func TestTopologies(t *testing.T) { + WithObjs(t, []TCObj{Types, CacheGroups, Topologies}, func() { + UpdateTestTopologies(t) + ValidationTestTopologies(t) + }) +} + +func CreateTestTopologies(t *testing.T) { + var ( + postResponse *tc.TopologyResponse + err error + ) + for _, topology := range testData.Topologies { + if postResponse, _, err = TOSession.CreateTopology(topology); err != nil { + t.Fatalf("could not CREATE topology: %v", err) + } + postResponse.Response.LastUpdated = nil + if !reflect.DeepEqual(topology, postResponse.Response) { + t.Fatalf("Topology in response should be the same as the one POSTed. expected: %v\nactual: %v", topology, postResponse.Response) + } + t.Log("Response: ", postResponse) + } +} + +func ValidationTestTopologies(t *testing.T) { + invalidTopologyTestCases := []topologyTestCase{ + {reasonToFail: "no nodes", Topology: tc.Topology{Name: "empty-top", Description: "Invalid because there are no nodes", Nodes: []tc.TopologyNode{}}}, + {reasonToFail: "a node listing itself as a parent", Topology: tc.Topology{Name: "self-parent", Description: "Invalid because a node lists itself as a parent", Nodes: []tc.TopologyNode{ + {Cachegroup: "cachegroup1", Parents: []int{1}}, + {Cachegroup: "parentCachegroup", Parents: []int{1}}, + }}}, + {reasonToFail: "duplicate parents", Topology: tc.Topology{}}, + {reasonToFail: "too many parents", Topology: tc.Topology{Name: "duplicate-parents", Description: "Invalid because a node lists the same parent twice", Nodes: []tc.TopologyNode{ + {Cachegroup: "cachegroup1", Parents: []int{1, 1}}, + {Cachegroup: "parentCachegroup", Parents: []int{}}, + }}}, + {reasonToFail: "too many parents", Topology: tc.Topology{Name: "too-many-parents", Description: "Invalid because a node has more than 2 parents", Nodes: []tc.TopologyNode{ + {Cachegroup: "parentCachegroup", Parents: []int{}}, + {Cachegroup: "secondaryCachegroup", Parents: []int{}}, + {Cachegroup: "parentCachegroup2", Parents: []int{}}, + {Cachegroup: "cachegroup1", Parents: []int{0, 1, 2}}, + }}}, + {reasonToFail: "a parent edge", Topology: tc.Topology{Name: "parent-edge", Description: "Invalid because an edge is a parent", Nodes: []tc.TopologyNode{ + {Cachegroup: "cachegroup1", Parents: []int{1}}, + {Cachegroup: "cachegroup2", Parents: []int{}}, + }}}, + {reasonToFail: "a leaf mid", Topology: tc.Topology{Name: "leaf-mid", Description: "Invalid because a mid is a leaf node", Nodes: []tc.TopologyNode{ + {Cachegroup: "parentCachegroup", Parents: []int{1}}, + {Cachegroup: "secondaryCachegroup", Parents: []int{}}, + }}}, + {reasonToFail: "cyclical nodes", Topology: tc.Topology{Name: "cyclical-nodes", Description: "Invalid because it contains cycles", Nodes: []tc.TopologyNode{ + {Cachegroup: "cachegroup1", Parents: []int{1, 2}}, + {Cachegroup: "parentCachegroup", Parents: []int{2}}, + {Cachegroup: "secondaryCachegroup", Parents: []int{1}}, + }}}, + } + for _, testCase := range invalidTopologyTestCases { + if _, _, err := TOSession.CreateTopology(testCase.Topology); err == nil { + t.Fatalf("expected POST with %v to return an error, actual: nil", testCase.reasonToFail) + } + } +} + +func updateSingleTopology(topology tc.Topology) error { + updateResponse, _, err := TOSession.UpdateTopology(topology.Name, topology) + if err != nil { + return fmt.Errorf("cannot PUT topology: %v - %v", err, updateResponse) + } + updateResponse.Response.LastUpdated = nil + if !reflect.DeepEqual(topology, updateResponse.Response) { + return fmt.Errorf("Topologies should be equal after updating. expected: %v\nactual: %v", topology, updateResponse.Response) + } + return nil +} + +func UpdateTestTopologies(t *testing.T) { + topologiesCount := len(testData.Topologies) + for index := range testData.Topologies { + topology := testData.Topologies[(index+1)%topologiesCount] + topology.Name = testData.Topologies[index].Name // We cannot update a topology's name + if err := updateSingleTopology(topology); err != nil { + t.Fatalf(err.Error()) + } + } + // Revert test topologies + for _, topology := range testData.Topologies { + if err := updateSingleTopology(topology); err != nil { + t.Fatalf(err.Error()) + } + } +} + +func DeleteTestTopologies(t *testing.T) { + for _, top := range testData.Topologies { + delResp, _, err := TOSession.DeleteTopology(top.Name) + if err != nil { + t.Fatalf("cannot DELETE topology: %v - %v", err, delResp) + } + + topology, _, err := TOSession.GetTopology(top.Name) + if err == nil { + t.Fatalf("expected error trying to GET deleted topology: %s, actual: nil", top.Name) + } + if topology != nil { + t.Fatalf("expected nil trying to GET deleted topology: %s, actual: non-nil", top.Name) + } + } +} diff --git a/traffic_ops/testing/api/v3/traffic_control_test.go b/traffic_ops/testing/api/v3/traffic_control_test.go index 8fe0a1cec9..b6423fc0de 100644 --- a/traffic_ops/testing/api/v3/traffic_control_test.go +++ b/traffic_ops/testing/api/v3/traffic_control_test.go @@ -50,6 +50,7 @@ type TrafficControl struct { StatsSummaries []tc.StatsSummary `json:"statsSummaries"` Tenants []tc.Tenant `json:"tenants"` ServerCheckExtensions []tc.ServerCheckExtensionNullable `json:"servercheck_extensions"` + Topologies []tc.Topology `json:"topologies"` Types []tc.Type `json:"types"` SteeringTargets []tc.SteeringTargetNullable `json:"steeringTargets"` Serverchecks []tc.ServercheckRequestNullable `json:"serverchecks"` diff --git a/traffic_ops/testing/api/v3/withobjs_test.go b/traffic_ops/testing/api/v3/withobjs_test.go index c0d6a0ea51..0f7996e240 100644 --- a/traffic_ops/testing/api/v3/withobjs_test.go +++ b/traffic_ops/testing/api/v3/withobjs_test.go @@ -66,6 +66,7 @@ const ( SteeringTargets Tenants ServerCheckExtensions + Topologies Types Users ) @@ -106,6 +107,7 @@ var withFuncs = map[TCObj]TCObjFuncs{ SteeringTargets: {SetupSteeringTargets, DeleteTestSteeringTargets}, Tenants: {CreateTestTenants, DeleteTestTenants}, ServerCheckExtensions: {CreateTestServerCheckExtensions, DeleteTestServerCheckExtensions}, + Topologies: {CreateTestTopologies, DeleteTestTopologies}, Types: {CreateTestTypes, DeleteTestTypes}, Users: {CreateTestUsers, ForceDeleteTestUsers}, } diff --git a/traffic_ops/traffic_ops_golang/api/shared_handlers_test.go b/traffic_ops/traffic_ops_golang/api/shared_handlers_test.go index 6cef2fcc1a..010b0a5cc7 100644 --- a/traffic_ops/traffic_ops_golang/api/shared_handlers_test.go +++ b/traffic_ops/traffic_ops_golang/api/shared_handlers_test.go @@ -40,7 +40,7 @@ type tester struct { APIInfoImpl `json:"-"` userErr error //only for testing sysErr error //only for testing - errCode int //only for testing + errCode int //only for testing } var cfg = config.Config{ConfigTrafficOpsGolang: config.ConfigTrafficOpsGolang{DBQueryTimeoutSeconds: 20}} diff --git a/traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go b/traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go index 21f2cba1f5..102560ed70 100644 --- a/traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go +++ b/traffic_ops/traffic_ops_golang/cachegroup/cachegroups.go @@ -379,6 +379,46 @@ func (cg *TOCacheGroup) deleteCoordinate(coordinateID int) error { return nil } +func GetCacheGroupsByName(names []string, Tx *sqlx.Tx) (map[string]tc.CacheGroupNullable, error, error, int) { + query := SelectQuery() + multipleCacheGroupsWhere() + namesPqArray := pq.Array(names) + rows, err := Tx.Query(query, namesPqArray) + if err != nil { + userErr, sysErr, errCode := api.ParseDBError(err) + return nil, userErr, sysErr, errCode + } + defer log.Close(rows, "unable to close DB connection") + cacheGroupMap := map[string]tc.CacheGroupNullable{} + for rows.Next() { + var s tc.CacheGroupNullable + lms := make([]tc.LocalizationMethod, 0) + cgfs := make([]string, 0) + if err = rows.Scan( + &s.ID, + &s.Name, + &s.ShortName, + &s.Latitude, + &s.Longitude, + pq.Array(&lms), + &s.ParentCachegroupID, + &s.ParentName, + &s.SecondaryParentCachegroupID, + &s.SecondaryParentName, + &s.Type, + &s.TypeID, + &s.LastUpdated, + pq.Array(&cgfs), + &s.FallbackToClosest, + ); err != nil { + return nil, nil, errors.New("cachegroup read: scanning: " + err.Error()), http.StatusInternalServerError + } + s.LocalizationMethods = &lms + s.Fallbacks = &cgfs + cacheGroupMap[*s.Name] = s + } + return cacheGroupMap, nil, nil, http.StatusOK +} + func (cg *TOCacheGroup) Read() ([]interface{}, error, error, int) { // Query Parameters to Database Query column mappings // see the fields mapped in the SQL query @@ -621,6 +661,13 @@ LEFT JOIN cachegroup AS cgp ON cachegroup.parent_cachegroup_id = cgp.id LEFT JOIN cachegroup AS cgs ON cachegroup.secondary_parent_cachegroup_id = cgs.id` } +func multipleCacheGroupsWhere() string { + return ` +WHERE +cachegroup.name = ANY ($1) +` +} + func UpdateQuery() string { // to disambiguate struct scans, the named // parameter 'type_id' is an alias to cachegroup.type diff --git a/traffic_ops/traffic_ops_golang/routing/routes.go b/traffic_ops/traffic_ops_golang/routing/routes.go index aa27cc6dd4..5bfd8ae828 100644 --- a/traffic_ops/traffic_ops_golang/routing/routes.go +++ b/traffic_ops/traffic_ops_golang/routing/routes.go @@ -88,6 +88,7 @@ import ( "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/steering" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/steeringtargets" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/systeminfo" + "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/topology" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/trafficstats" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/types" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/urisigning" @@ -282,6 +283,11 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) { {api.Version{3, 0}, http.MethodPost, `regions/?$`, api.CreateHandler(®ion.TORegion{}), auth.PrivLevelOperations, Authenticated, nil, 22883344883, noPerlBypass}, {api.Version{3, 0}, http.MethodDelete, `regions/?$`, api.DeleteHandler(®ion.TORegion{}), auth.PrivLevelOperations, Authenticated, nil, 22326267583, noPerlBypass}, + {api.Version{3, 0}, http.MethodPost, `topologies/?$`, api.CreateHandler(&topology.TOTopology{}), auth.PrivLevelOperations, Authenticated, nil, 3871452221, noPerlBypass}, + {api.Version{3, 0}, http.MethodGet, `topologies/?$`, api.ReadHandler(&topology.TOTopology{}), auth.PrivLevelReadOnly, Authenticated, nil, 3871452222, noPerlBypass}, + {api.Version{3, 0}, http.MethodPut, `topologies/?$`, api.UpdateHandler(&topology.TOTopology{}), auth.PrivLevelOperations, Authenticated, nil, 3871452223, noPerlBypass}, + {api.Version{3, 0}, http.MethodDelete, `topologies/?$`, api.DeleteHandler(&topology.TOTopology{}), auth.PrivLevelOperations, Authenticated, nil, 3871452224, noPerlBypass}, + // get all edge servers associated with a delivery service (from deliveryservice_server table) {api.Version{3, 0}, http.MethodGet, `deliveryserviceserver/?$`, dsserver.ReadDSSHandlerV14, auth.PrivLevelReadOnly, Authenticated, nil, 29461450333, noPerlBypass}, diff --git a/traffic_ops/traffic_ops_golang/topology/cycle_detection.go b/traffic_ops/traffic_ops_golang/topology/cycle_detection.go new file mode 100644 index 0000000000..2344bf0ae6 --- /dev/null +++ b/traffic_ops/traffic_ops_golang/topology/cycle_detection.go @@ -0,0 +1,124 @@ +package topology + +/* + * 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 ( + "github.com/apache/trafficcontrol/lib/go-tc" + "math" +) + +type TarjanNode struct { + tc.TopologyNode + Index *int + LowLink *int + OnStack *bool +} + +type NodeStack []*TarjanNode +type Graph []*TarjanNode +type Component []tc.TopologyNode + +type Tarjan struct { + Graph *Graph + Stack *NodeStack + Components []Component + Index int +} + +func (stack *NodeStack) push(node *TarjanNode) { + *stack = append(append([]*TarjanNode{}, *stack...), node) +} + +func (stack *NodeStack) pop() *TarjanNode { + length := len(*stack) + node := (*stack)[length-1] + *stack = (*stack)[:length-1] + return node +} + +func tarjan(nodes []tc.TopologyNode) [][]tc.TopologyNode { + structs := Tarjan{ + Graph: &Graph{}, + Stack: &NodeStack{}, + Components: []Component{}, + Index: 0, + } + for _, node := range nodes { + tarjanNode := TarjanNode{TopologyNode: node, LowLink: new(int)} + *tarjanNode.LowLink = 500 + *structs.Graph = append(*structs.Graph, &tarjanNode) + } + structs.Stack = &NodeStack{} + structs.Index = 0 + for _, vertex := range *structs.Graph { + if vertex.Index == nil { + structs.strongConnect(vertex) + } + } + var components [][]tc.TopologyNode + for _, component := range structs.Components { + var componentArray []tc.TopologyNode + for _, node := range component { + componentArray = append(componentArray, node) + } + components = append(components, componentArray) + } + return components +} + +func (structs *Tarjan) nextComponent() (Component, int) { + var component = Component{} + index := len(structs.Components) + structs.Components = append(structs.Components, component) + return component, index +} + +func (structs *Tarjan) strongConnect(node *TarjanNode) { + stack := structs.Stack + node.Index = new(int) + *node.Index = structs.Index + node.LowLink = new(int) + *node.LowLink = structs.Index + structs.Index++ + stack.push(node) + node.OnStack = new(bool) + *node.OnStack = true + + for _, parentIndex := range node.Parents { + parent := (*structs.Graph)[parentIndex] + if parent.Index == nil { + structs.strongConnect(parent) + *(*parent).LowLink = int(math.Min(float64(*node.LowLink), float64(*parent.LowLink))) + } else if *parent.OnStack { + *node.LowLink = int(math.Min(float64(*node.LowLink), float64(*parent.Index))) + } + } + + if *node.LowLink == *node.Index { + component, index := structs.nextComponent() + var otherNode *TarjanNode + for node != otherNode { + otherNode = stack.pop() + *otherNode.OnStack = false + component = append(component, otherNode.TopologyNode) + } + structs.Components[index] = component + } +} diff --git a/traffic_ops/traffic_ops_golang/topology/topologies.go b/traffic_ops/traffic_ops_golang/topology/topologies.go new file mode 100644 index 0000000000..595d5da226 --- /dev/null +++ b/traffic_ops/traffic_ops_golang/topology/topologies.go @@ -0,0 +1,473 @@ +package topology + +/* + * 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 ( + "errors" + "fmt" + "github.com/apache/trafficcontrol/lib/go-log" + "github.com/apache/trafficcontrol/lib/go-tc" + "github.com/apache/trafficcontrol/lib/go-tc/tovalidate" + "github.com/apache/trafficcontrol/lib/go-util" + "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api" + "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/cachegroup" + "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers" + validation "github.com/go-ozzo/ozzo-validation" + "github.com/lib/pq" + "net/http" +) + +// TOTopology is a type alias on which we can define functions. +type TOTopology struct { + api.APIInfoImpl `json:"-"` + tc.Topology +} + +// DeleteQueryBase holds a delete query with no WHERE clause and is a +// requirement of the api.GenericOptionsDeleter interface. +func (topology *TOTopology) DeleteQueryBase() string { + return deleteQueryBase() +} + +// ParamColumns maps query parameters to their respective database columns. +func (topology *TOTopology) ParamColumns() map[string]dbhelpers.WhereColumnInfo { + return map[string]dbhelpers.WhereColumnInfo{ + "name": {Column: "t.name"}, + "description": {Column: "t.description"}, + "lastUpdated": {Column: "t.last_updated"}, + } +} + +// GenericOptionsDeleter is required by the api.GenericOptionsDeleter interface +// and is called by api.GenericOptionsDelete(). +func (topology *TOTopology) DeleteKeyOptions() map[string]dbhelpers.WhereColumnInfo { + return topology.ParamColumns() +} + +func (topology *TOTopology) SetLastUpdated(time tc.TimeNoMod) { topology.LastUpdated = &time } + +// GetKeyFieldsInfo is a requirement of the api.Updater interface. +func (topology TOTopology) GetKeyFieldsInfo() []api.KeyFieldInfo { + return []api.KeyFieldInfo{{"name", api.GetStringKey}} +} + +// GetType returns the human-readable type of TOTopology as a string. +func (topology *TOTopology) GetType() string { + return "topology" +} + +// Validate is a requirement of the api.Validator interface. +func (topology *TOTopology) Validate() error { + nameRule := validation.NewStringRule(tovalidate.IsAlphanumericUnderscoreDash, "must consist of only alphanumeric, dash, or underscore characters.") + rules := validation.Errors{} + rules["name"] = validation.Validate(topology.Name, validation.Required, nameRule) + + nodeCount := len(topology.Nodes) + if nodeCount < 1 { + rules["length"] = fmt.Errorf("must provide 1 or more node, %v found", nodeCount) + } + var ( + cacheGroups = make([]tc.CacheGroupNullable, nodeCount) + cacheGroupsExist = true + err error + userErr error + sysErr error + cacheGroupMap map[string]tc.CacheGroupNullable + exists bool + ) + _ = err + cacheGroupNames := make([]string, len(topology.Nodes)) + for index, node := range topology.Nodes { + rules[fmt.Sprintf("node %v parents size", index)] = validation.Validate(node.Parents, validation.Length(0, 2)) + rules[fmt.Sprintf("node %v duplicate parents", index)] = checkForDuplicateParents(topology.Nodes, index) + rules[fmt.Sprintf("node %v self parent", index)] = checkForSelfParents(topology.Nodes, index) + cacheGroupNames[index] = node.Cachegroup + } + if cacheGroupMap, userErr, sysErr, _ = cachegroup.GetCacheGroupsByName(cacheGroupNames, topology.APIInfoImpl.ReqInfo.Tx); userErr != nil || sysErr != nil { + var err error + message := "could not get cachegroups" + if userErr != nil { + err = fmt.Errorf("%s: %s", message, userErr.Error()) + } + return err + } + cacheGroups = make([]tc.CacheGroupNullable, len(topology.Nodes)) + for index, node := range topology.Nodes { + if cacheGroups[index], exists = cacheGroupMap[node.Cachegroup]; !exists { + rules[fmt.Sprintf("cachegroup %s not found", node.Cachegroup)] = fmt.Errorf("node %d references nonexistent cachegroup %s", index, node.Cachegroup) + cacheGroupsExist = false + } + } + rules["duplicate cachegroup name"] = checkUniqueCacheGroupNames(topology.Nodes) + + if cacheGroupsExist { + for index, node := range topology.Nodes { + rules[fmt.Sprintf("parent '%v' edge type", node.Cachegroup)] = checkForEdgeParents(topology.Nodes, cacheGroups, index) + } + + for _, leafMid := range checkForLeafMids(topology.Nodes, cacheGroups) { + rules[fmt.Sprintf("node %v leaf mid", leafMid.Cachegroup)] = fmt.Errorf("cachegroup %v's type is %v; it cannot be a leaf (it must have at least 1 child)", leafMid.Cachegroup, tc.CacheGroupMidTypeName) + } + } + rules["topology cycles"] = checkForCycles(topology.Nodes) + + errs := tovalidate.ToErrors(rules) + return util.JoinErrs(errs) +} + +// Implementation of the Identifier, Validator interface functions +func (topology TOTopology) GetKeys() (map[string]interface{}, bool) { + return map[string]interface{}{"name": topology.Name}, true +} + +// SetKeys is a requirement of the api.Updater interface and is called by +// api.UpdateHandler(). +func (topology *TOTopology) SetKeys(keys map[string]interface{}) { + topology.Name, _ = keys["name"].(string) +} + +// GetAuditName is a requirement of the api.Identifier interface. +func (topology *TOTopology) GetAuditName() string { + return topology.Name +} + +// Create is a requirement of the api.Creator interface. +func (topology *TOTopology) Create() (error, error, int) { + tx := topology.APIInfo().Tx.Tx + err := tx.QueryRow(insertQuery(), topology.Name, topology.Description).Scan(&topology.Name, &topology.Description, &topology.LastUpdated) + if err != nil { + return api.ParseDBError(err) + } + + if userErr, sysErr, errCode := topology.addNodes(); userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + + if userErr, sysErr, errCode := topology.addParents(); userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + + return nil, nil, 0 +} + +// Read is a requirement of the api.Reader interface and is called by api.ReadHandler(). +func (topology *TOTopology) Read() ([]interface{}, error, error, int) { + where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(topology.ReqInfo.Params, topology.ParamColumns()) + if len(errs) > 0 { + return nil, util.JoinErrs(errs), nil, http.StatusBadRequest + } + query := selectQuery() + where + orderBy + pagination + rows, err := topology.ReqInfo.Tx.NamedQuery(query, queryValues) + if err != nil { + return nil, nil, errors.New("topology read: querying: " + err.Error()), http.StatusInternalServerError + } + defer log.Close(rows, "unable to close DB connection") + + var interfaces []interface{} + topologies := map[string]*tc.Topology{} + indices := map[int]int{} + for index := 0; rows.Next(); index++ { + var ( + name, description string + lastUpdated tc.TimeNoMod + ) + topologyNode := tc.TopologyNode{} + topologyNode.Parents = []int{} + var parents pq.Int64Array + if err = rows.Scan( + &name, + &description, + &lastUpdated, + &topologyNode.Id, + &topologyNode.Cachegroup, + &parents, + ); err != nil { + return nil, nil, errors.New("topology read: scanning: " + err.Error()), http.StatusInternalServerError + } + for _, id := range parents { + topologyNode.Parents = append(topologyNode.Parents, int(id)) + } + indices[topologyNode.Id] = index + if _, exists := topologies[name]; !exists { + topology := tc.Topology{Nodes: []tc.TopologyNode{}} + topologies[name] = &topology + topology.Name = name + topology.Description = description + topology.LastUpdated = &lastUpdated + } + topologies[name].Nodes = append(topologies[name].Nodes, topologyNode) + } + + for _, topology := range topologies { + nodeMap := map[int]int{} + for index, node := range topology.Nodes { + nodeMap[node.Id] = index + } + for _, node := range topology.Nodes { + for parentIndex := 0; parentIndex < len(node.Parents); parentIndex++ { + node.Parents[parentIndex] = nodeMap[node.Parents[parentIndex]] + } + } + interfaces = append(interfaces, *topology) + } + return interfaces, nil, nil, http.StatusOK +} + +func (topology *TOTopology) removeParents() error { + _, err := topology.ReqInfo.Tx.Exec(deleteParentsQuery(), topology.Name) + if err != nil { + return errors.New("topology update: error deleting old parents: " + err.Error()) + } + return nil +} + +func (topology *TOTopology) removeNodes(cachegroups *[]string) error { + _, err := topology.ReqInfo.Tx.Exec(deleteNodesQuery(), topology.Name, pq.Array(*cachegroups)) + if err != nil { + return errors.New("topology update: error removing old unused nodes: " + err.Error()) + } + return nil +} + +func (topology *TOTopology) addNodes() (error, error, int) { + var cachegroupsToInsert []string + var indices = make([]int, 0) + for index, node := range topology.Nodes { + if node.Id == 0 { + cachegroupsToInsert = append(cachegroupsToInsert, node.Cachegroup) + indices = append(indices, index) + } + } + if len(cachegroupsToInsert) == 0 { + return nil, nil, http.StatusOK + } + rows, err := topology.ReqInfo.Tx.Query(nodeInsertQuery(), topology.Name, pq.Array(cachegroupsToInsert)) + if err != nil { + return nil, errors.New("error adding nodes: " + err.Error()), http.StatusInternalServerError + } + defer log.Close(rows, "unable to close DB connection") + for _, index := range indices { + rows.Next() + err = rows.Scan(&topology.Nodes[index].Id, &topology.Name, &topology.Nodes[index].Cachegroup) + if err != nil { + return api.ParseDBError(err) + } + } + return nil, nil, http.StatusOK +} + +func (topology *TOTopology) addParents() (error, error, int) { + var ( + children []int + parents []int + ranks []int + ) + for _, node := range topology.Nodes { + for rank := 1; rank <= len(node.Parents); rank++ { + parent := topology.Nodes[node.Parents[rank-1]] + children = append(children, node.Id) + parents = append(parents, parent.Id) + ranks = append(ranks, rank) + } + } + rows, err := topology.ReqInfo.Tx.Query(nodeParentInsertQuery(), pq.Array(children), pq.Array(parents), pq.Array(ranks)) + if err != nil { + return api.ParseDBError(err) + } + defer log.Close(rows, "unable to close DB connection") + for _, node := range topology.Nodes { + for rank := 1; rank <= len(node.Parents); rank++ { + rows.Next() + parent := topology.Nodes[node.Parents[rank-1]] + err = rows.Scan(&node.Id, &parent.Id, &rank) + if err != nil { + return api.ParseDBError(err) + } + } + } + return nil, nil, http.StatusOK +} + +func (topology *TOTopology) setDescription() (error, error, int) { + rows, err := topology.ReqInfo.Tx.Query(updateQuery(), topology.Description, topology.Name) + if err != nil { + return nil, fmt.Errorf("topology update: error setting the description for topology %v: %v", topology.Name, err.Error()), http.StatusInternalServerError + } + defer log.Close(rows, "unable to close DB connection") + for rows.Next() { + err = rows.Scan(&topology.Name, &topology.Description, &topology.LastUpdated) + if err != nil { + return api.ParseDBError(err) + } + } + return nil, nil, http.StatusOK +} + +// Update is a requirement of the api.Updater interface. +func (topology *TOTopology) Update() (error, error, int) { + topologies, userErr, sysErr, errCode := topology.Read() + if userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + if len(topologies) != 1 { + return fmt.Errorf("cannot find exactly 1 topology with the query string provided"), nil, http.StatusBadRequest + } + oldTopology := TOTopology{APIInfoImpl: topology.APIInfoImpl, Topology: topologies[0].(tc.Topology)} + if userErr, sysErr, errCode := topology.setDescription(); userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + + if err := oldTopology.removeParents(); err != nil { + return nil, err, http.StatusInternalServerError + } + var oldNodes, newNodes = map[string]int{}, map[string]int{} + for index, node := range oldTopology.Nodes { + oldNodes[node.Cachegroup] = index + } + for index, node := range topology.Nodes { + newNodes[node.Cachegroup] = index + } + var toRemove []string + for cachegroupName := range oldNodes { + if _, exists := newNodes[cachegroupName]; !exists { + toRemove = append(toRemove, cachegroupName) + } else { + topology.Nodes[newNodes[cachegroupName]].Id = oldTopology.Nodes[oldNodes[cachegroupName]].Id + } + } + if len(toRemove) > 0 { + if err := oldTopology.removeNodes(&toRemove); err != nil { + return nil, err, http.StatusInternalServerError + } + } + if userErr, sysErr, errCode := topology.addNodes(); userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + if userErr, sysErr, errCode := topology.addParents(); userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + + return nil, nil, http.StatusOK +} + +// Delete is unused and simply satisfies the Deleter interface +// (although TOTOpology is used as an OptionsDeleter) +func (topology *TOTopology) Delete() (error, error, int) { + return nil, nil, 0 +} + +// OptionsDelete is a requirement of the OptionsDeleter interface. +func (topology *TOTopology) OptionsDelete() (error, error, int) { + topologies, userErr, sysErr, errCode := topology.Read() + if userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + if len(topologies) != 1 { + return fmt.Errorf("cannot find exactly 1 topology with the query string provided"), nil, http.StatusBadRequest + } + topology.Topology = topologies[0].(tc.Topology) + + var cachegroups []string + for _, node := range topology.Nodes { + cachegroups = append(cachegroups, node.Cachegroup) + } + return api.GenericOptionsDelete(topology) +} + +func insertQuery() string { + query := ` +INSERT INTO topology (name, description) +VALUES ($1, $2) +RETURNING name, description, last_updated +` + return query +} + +func nodeInsertQuery() string { + query := ` +INSERT INTO topology_cachegroup (topology, cachegroup) +VALUES ($1, unnest($2::text[])) +RETURNING id, topology, cachegroup +` + return query +} + +func nodeParentInsertQuery() string { + query := ` +INSERT INTO topology_cachegroup_parents (child, parent, rank) +VALUES (unnest($1::int[]), unnest($2::int[]), unnest($3::int[])) +RETURNING child, parent, rank +` + return query +} + +func selectQuery() string { + query := ` +SELECT t.name, t.description, t.last_updated, +tc.id, tc.cachegroup, + (SELECT COALESCE (ARRAY_AGG (CAST (tcp.parent as INT) ORDER BY tcp.rank ASC)) AS parents + FROM topology_cachegroup tc2 + INNER JOIN topology_cachegroup_parents tcp ON tc2.id = tcp.child + WHERE tc2.topology = tc.topology + AND tc2.cachegroup = tc.cachegroup + ) +FROM topology t +JOIN topology_cachegroup tc on t.name = tc.topology +` + return query +} + +func deleteQueryBase() string { + query := ` +DELETE FROM topology t +` + return query +} + +func deleteNodesQuery() string { + query := ` +DELETE FROM topology_cachegroup tc +WHERE tc.topology = $1 +AND tc.cachegroup = ANY ($2::text[]) +` + return query +} + +func deleteParentsQuery() string { + query := ` +DELETE FROM topology_cachegroup_parents tcp +WHERE tcp.child IN + (SELECT tc.id + FROM topology t + JOIN topology_cachegroup tc on t.name = tc.topology + WHERE t.name = $1) +` + return query +} + +func updateQuery() string { + query := ` +UPDATE topology t SET +description = $1 +WHERE t.name = $2 +RETURNING t.name, t.description, t.last_updated +` + return query +} diff --git a/traffic_ops/traffic_ops_golang/topology/validation.go b/traffic_ops/traffic_ops_golang/topology/validation.go new file mode 100644 index 0000000000..c4270ac8a2 --- /dev/null +++ b/traffic_ops/traffic_ops_golang/topology/validation.go @@ -0,0 +1,115 @@ +package topology + +/* + * 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 ( + "fmt" + "github.com/apache/trafficcontrol/lib/go-tc" + "github.com/apache/trafficcontrol/lib/go-util" +) + +func checkUniqueCacheGroupNames(nodes []tc.TopologyNode) error { + cacheGroupNames := map[string]bool{} + for _, node := range nodes { + if _, exists := cacheGroupNames[node.Cachegroup]; exists { + return fmt.Errorf("cachegroup %v cannot be used more than once in the topology", node.Cachegroup) + } + cacheGroupNames[node.Cachegroup] = true + } + return nil +} + +func checkForDuplicateParents(nodes []tc.TopologyNode, index int) error { + parents := nodes[index].Parents + if len(parents) != 2 || parents[0] != parents[1] { + return nil + } + return fmt.Errorf("cachegroup %v cannot be both a primary and secondary parent of cachegroup %v", nodes[parents[0]].Cachegroup, nodes[index].Cachegroup) +} + +func checkForSelfParents(nodes []tc.TopologyNode, index int) error { + for _, parentIndex := range nodes[index].Parents { + if index == parentIndex { + return fmt.Errorf("cachegroup %v cannot be a parent of itself", index) + } + } + return nil +} + +func checkForEdgeParents(nodes []tc.TopologyNode, cachegroups []tc.CacheGroupNullable, nodeIndex int) error { + node := nodes[nodeIndex] + errs := make([]error, len(node.Parents)) + for parentIndex := range node.Parents { + cacheGroupType := cachegroups[node.Parents[parentIndex]].Type + if *cacheGroupType == tc.CacheGroupEdgeTypeName { + errs[parentIndex] = fmt.Errorf("cachegroup %v's type is %v; it cannot be a parent of %v", nodes[parentIndex].Cachegroup, tc.CacheGroupEdgeTypeName, node.Cachegroup) + } + } + return util.JoinErrs(errs) +} + +func checkForLeafMids(nodes []tc.TopologyNode, cacheGroups []tc.CacheGroupNullable) []tc.TopologyNode { + isLeafMid := make([]bool, len(nodes)) + for index := range isLeafMid { + isLeafMid[index] = true + } + for index, node := range nodes { + if *cacheGroups[index].Type == tc.CacheGroupEdgeTypeName { + isLeafMid[index] = false + } + for _, parentIndex := range node.Parents { + if !isLeafMid[parentIndex] { + continue + } + isLeafMid[parentIndex] = false + } + } + + var leafMids []tc.TopologyNode + for index, node := range nodes { + if isLeafMid[index] { + leafMids = append(leafMids, node) + } + } + return leafMids +} + +func checkForCycles(nodes []tc.TopologyNode) error { + components := tarjan(nodes) + var errs []error + for _, component := range components { + if len(component) > 1 { + errString := "cycle detected between cachegroups " + var node tc.TopologyNode + for _, node = range component { + errString += node.Cachegroup + ", " + } + length := len(errString) + cachegroupNameLength := len(node.Cachegroup) + errString = errString[0:length-2-cachegroupNameLength-2] + " and " + errString[length-2-cachegroupNameLength:length-2] + errs = append(errs, fmt.Errorf(errString)) + } + } + if len(errs) == 0 { + return nil + } + errs = append([]error{fmt.Errorf("topology cannot have cycles")}, errs...) + return util.JoinErrs(errs) +} diff --git a/traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go b/traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go index 066b505bff..3a05e5d200 100644 --- a/traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go +++ b/traffic_ops/traffic_ops_golang/trafficstats/deliveryservice.go @@ -210,8 +210,8 @@ func handleRequest(w http.ResponseWriter, r *http.Request, client *influx.Client if summary != nil { resp.Summary = &tc.TrafficDSStatsSummary{ TrafficStatsSummary: *summary, - TotalKiloBytes: kBs, - TotalTransactions: txns, + TotalKiloBytes: kBs, + TotalTransactions: txns, } } else { resp.Summary = &tc.TrafficDSStatsSummary{} @@ -278,8 +278,8 @@ func handleLegacyRequest(w http.ResponseWriter, r *http.Request, client *influx. if summary != nil { resp.Summary = &tc.LegacyTrafficDSStatsSummary{ TrafficStatsSummary: *summary, - TotalBytes: kBs, - TotalTransactions: txns, + TotalBytes: kBs, + TotalTransactions: txns, } } else { resp.Summary = &tc.LegacyTrafficDSStatsSummary{} diff --git a/traffic_portal/app/src/app.js b/traffic_portal/app/src/app.js index 3e1d0fd703..1b2e477378 100644 --- a/traffic_portal/app/src/app.js +++ b/traffic_portal/app/src/app.js @@ -33,6 +33,7 @@ var trafficPortal = angular.module('trafficPortal', [ 'ngSanitize', 'ngRoute', 'ui.router', + 'ui.tree', 'ui.bootstrap', 'ui.bootstrap.contextMenu', 'app.templates', @@ -201,6 +202,10 @@ var trafficPortal = angular.module('trafficPortal', [ require('./modules/private/tenants/new').name, require('./modules/private/tenants/users').name, require('./modules/private/types').name, + require('./modules/private/topologies').name, + require('./modules/private/topologies/edit').name, + require('./modules/private/topologies/list').name, + require('./modules/private/topologies/new').name, require('./modules/private/types/edit').name, require('./modules/private/types/list').name, require('./modules/private/types/new').name, @@ -317,6 +322,9 @@ var trafficPortal = angular.module('trafficPortal', [ require('./common/modules/form/tenant').name, require('./common/modules/form/tenant/edit').name, require('./common/modules/form/tenant/new').name, + require('./common/modules/form/topology').name, + require('./common/modules/form/topology/edit').name, + require('./common/modules/form/topology/new').name, require('./common/modules/form/type').name, require('./common/modules/form/type/edit').name, require('./common/modules/form/type/new').name, @@ -387,6 +395,9 @@ var trafficPortal = angular.module('trafficPortal', [ require('./common/modules/table/tenants').name, require('./common/modules/table/tenantDeliveryServices').name, require('./common/modules/table/tenantUsers').name, + require('./common/modules/table/topologies').name, + require('./common/modules/table/topologyCacheGroups').name, + require('./common/modules/table/topologyCacheGroupServers').name, require('./common/modules/table/types').name, require('./common/modules/table/typeCacheGroups').name, require('./common/modules/table/typeDeliveryServices').name, diff --git a/traffic_portal/app/src/assets/css/angular-ui-tree.min_2.22.6.css b/traffic_portal/app/src/assets/css/angular-ui-tree.min_2.22.6.css new file mode 100644 index 0000000000..dbf6650e43 --- /dev/null +++ b/traffic_portal/app/src/assets/css/angular-ui-tree.min_2.22.6.css @@ -0,0 +1,2 @@ +/* Angular UI Tree v2.22.6 - (c) 2010-2017. https://github.com/angular-ui-tree/angular-ui-tree - MIT */ +.angular-ui-tree-empty{border:1px dashed #bbb;min-height:100px;background-color:#e5e5e5;background-image:-webkit-linear-gradient(45deg,#fff 25%,transparent 0,transparent 75%,#fff 0,#fff),-webkit-linear-gradient(45deg,#fff 25%,transparent 0,transparent 75%,#fff 0,#fff);background-image:linear-gradient(45deg,#fff 25%,transparent 0,transparent 75%,#fff 0,#fff),linear-gradient(45deg,#fff 25%,transparent 0,transparent 75%,#fff 0,#fff);background-size:60px 60px;background-position:0 0,30px 30px;pointer-events:none}.angular-ui-tree-nodes{position:relative;margin:0;padding:0;list-style:none}.angular-ui-tree-nodes .angular-ui-tree-nodes{padding-left:30px}.angular-ui-tree-node,.angular-ui-tree-placeholder{position:relative;margin:0;padding:0;min-height:20px;line-height:20px}.angular-ui-tree-hidden{display:none}.angular-ui-tree-placeholder{margin:5px 0;padding:0;min-height:30px}.angular-ui-tree-handle{cursor:move;text-decoration:none;font-weight:700;box-sizing:border-box;min-height:20px;line-height:20px}.angular-ui-tree-drag{position:absolute;pointer-events:none;z-index:999;opacity:.8}.btn{margin-right:8px;margin-bottom:0}.angular-ui-tree-handle{background:#fff0f0;border:1px solid #dae2ea;color:#7c9eb2;padding:10px 10px}.angular-ui-tree-handle:hover{color:#155724!important;background-color:#d4edd7!important;border-color:#c3e6cb!important}.angular-ui-tree-placeholder{background:#f0f9ff;border:2px dashed #bed2db;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}tr.angular-ui-tree-empty{height:100px}.group-title{background-color:#687074!important;color:#fff!important}.tree-node{border:1px solid #dae2ea;background:#f8faff;color:#7c9eb2}.nodrop{background-color:#f2dede}.tree-node-content{height:45px;text-align:center;font-size:14px;margin-bottom:10px}.tree-handle{padding:10px;background:#428bca;color:#fff;margin-right:10px}.angular-ui-tree-placeholder{background:#f0f9ff;border:2px dashed #bed2db;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.node circle{fill:#999}.node text{font:10px sans-serif}.node--internal circle{fill:#555}.node--internal text{text-shadow:0 1px 0 #fff,0 -1px 0 #fff,1px 0 0 #fff,-1px 0 0 #fff}.link{fill:none;stroke:#555;stroke-opacity:.4;stroke-width:1.5px} \ No newline at end of file diff --git a/traffic_portal/app/src/assets/js/angular-ui-tree.min_2.22.6.js b/traffic_portal/app/src/assets/js/angular-ui-tree.min_2.22.6.js new file mode 100644 index 0000000000..86a7741150 --- /dev/null +++ b/traffic_portal/app/src/assets/js/angular-ui-tree.min_2.22.6.js @@ -0,0 +1,2 @@ +/* Angular UI Tree v2.22.6 - (c) 2010-2017. https://github.com/angular-ui-tree/angular-ui-tree - MIT */ +!function(){"use strict";angular.module("ui.tree",[]).constant("treeConfig",{treeClass:"angular-ui-tree",emptyTreeClass:"angular-ui-tree-empty",dropzoneClass:"angular-ui-tree-dropzone",hiddenClass:"angular-ui-tree-hidden",nodesClass:"angular-ui-tree-nodes",nodeClass:"angular-ui-tree-node",handleClass:"angular-ui-tree-handle",placeholderClass:"angular-ui-tree-placeholder",dragClass:"angular-ui-tree-drag",dragThreshold:3,defaultCollapsed:!1,appendChildOnHover:!0})}(),function(){"use strict";angular.module("ui.tree").controller("TreeHandleController",["$scope","$element",function(e,n){this.scope=e,e.$element=n,e.$nodeScope=null,e.$type="uiTreeHandle"}])}(),function(){"use strict";angular.module("ui.tree").controller("TreeNodeController",["$scope","$element",function(e,n){function t(e){if(!e)return 0;var n,o,l,r=0,a=e.childNodes();if(!a||0===a.length)return 0;for(l=a.length-1;l>=0;l--)n=a[l],o=1+t(n),r=Math.max(r,o);return r}this.scope=e,e.$element=n,e.$modelValue=null,e.$parentNodeScope=null,e.$childNodesScope=null,e.$parentNodesScope=null,e.$treeScope=null,e.$handleScope=null,e.$type="uiTreeNode",e.$$allowNodeDrop=!1,e.collapsed=!1,e.expandOnHover=!1,e.init=function(t){var o=t[0];e.$treeScope=t[1]?t[1].scope:null,e.$parentNodeScope=o.scope.$nodeScope,e.$modelValue=o.scope.$modelValue[e.$index],e.$parentNodesScope=o.scope,o.scope.initSubNode(e),n.on("$destroy",function(){o.scope.destroySubNode(e)})},e.index=function(){return e.$parentNodesScope.$modelValue.indexOf(e.$modelValue)},e.dragEnabled=function(){return!(e.$treeScope&&!e.$treeScope.dragEnabled)},e.isSibling=function(n){return e.$parentNodesScope==n.$parentNodesScope},e.isChild=function(n){var t=e.childNodes();return t&&t.indexOf(n)>-1},e.prev=function(){var n=e.index();return n>0?e.siblings()[n-1]:null},e.siblings=function(){return e.$parentNodesScope.childNodes()},e.childNodesCount=function(){return e.childNodes()?e.childNodes().length:0},e.hasChild=function(){return e.childNodesCount()>0},e.childNodes=function(){return e.$childNodesScope&&e.$childNodesScope.$modelValue?e.$childNodesScope.childNodes():null},e.accept=function(n,t){return e.$childNodesScope&&e.$childNodesScope.$modelValue&&e.$childNodesScope.accept(n,t)},e.remove=function(){return e.$parentNodesScope.removeNode(e)},e.toggle=function(){e.collapsed=!e.collapsed,e.$treeScope.$callbacks.toggle(e.collapsed,e)},e.collapse=function(){e.collapsed=!0},e.expand=function(){e.collapsed=!1},e.depth=function(){var n=e.$parentNodeScope;return n?n.depth()+1:1},e.maxSubDepth=function(){return e.$childNodesScope?t(e.$childNodesScope):0}}])}(),function(){"use strict";angular.module("ui.tree").controller("TreeNodesController",["$scope","$element","$timeout",function(e,n,t){this.scope=e,e.$element=n,e.$modelValue=null,e.$nodeScope=null,e.$treeScope=null,e.$type="uiTreeNodes",e.$nodesMap={},e.nodropEnabled=!1,e.maxDepth=0,e.cloneEnabled=!1,e.initSubNode=function(n){if(!n.$modelValue)return null;e.$nodesMap[n.$modelValue.$$hashKey]=n},e.destroySubNode=function(n){if(!n.$modelValue)return null;e.$nodesMap[n.$modelValue.$$hashKey]=null},e.accept=function(n,t){return e.$treeScope.$callbacks.accept(n,e,t)},e.beforeDrag=function(n){return e.$treeScope.$callbacks.beforeDrag(n)},e.isParent=function(n){return n.$parentNodesScope==e},e.hasChild=function(){return e.$modelValue.length>0},e.removeNode=function(n){var o=e.$modelValue.indexOf(n.$modelValue);return o>-1?(t(function(){e.$modelValue.splice(o,1)[0]}),e.$treeScope.$callbacks.removed(n)):null},e.insertNode=function(n,o){t(function(){e.$modelValue.splice(n,0,o)})},e.childNodes=function(){var n,t=[];if(e.$modelValue)for(n=0;n0&&e.depth()+n.maxSubDepth()+1>t}}])}(),function(){"use strict";angular.module("ui.tree").controller("TreeController",["$scope","$element",function(e,n){this.scope=e,e.$element=n,e.$nodesScope=null,e.$type="uiTree",e.$emptyElm=null,e.$dropzoneElm=null,e.$callbacks=null,e.dragEnabled=!0,e.emptyPlaceholderEnabled=!0,e.maxDepth=0,e.dragDelay=0,e.cloneEnabled=!1,e.nodropEnabled=!1,e.dropzoneEnabled=!1,e.isEmpty=function(){return e.$nodesScope&&e.$nodesScope.$modelValue&&0===e.$nodesScope.$modelValue.length},e.place=function(n){e.$nodesScope.$element.append(n),e.$emptyElm.remove()},this.resetEmptyElement=function(){e.$nodesScope.$modelValue&&0!==e.$nodesScope.$modelValue.length||!e.emptyPlaceholderEnabled?e.$emptyElm.remove():n.append(e.$emptyElm)},this.resetDropzoneElement=function(){e.$nodesScope.$modelValue&&0===e.$nodesScope.$modelValue.length||!e.dropzoneEnabled?e.$dropzoneElm.remove():n.append(e.$dropzoneElm)},e.resetEmptyElement=this.resetEmptyElement,e.resetDropzoneElement=this.resetDropzoneElement}])}(),function(){"use strict";angular.module("ui.tree").directive("uiTree",["treeConfig","$window",function(e,n){return{restrict:"A",scope:!0,controller:"TreeController",link:function(t,o,l,r){var a,i,d,c={accept:null,beforeDrag:null},s={};angular.extend(s,e),s.treeClass&&o.addClass(s.treeClass),"table"===o.prop("tagName").toLowerCase()?(t.$emptyElm=angular.element(n.document.createElement("tr")),i=o.find("tr"),d=i.length>0?angular.element(i).children().length:1e6,a=angular.element(n.document.createElement("td")).attr("colspan",d),t.$emptyElm.append(a)):(t.$emptyElm=angular.element(n.document.createElement("div")),t.$dropzoneElm=angular.element(n.document.createElement("div"))),s.emptyTreeClass&&t.$emptyElm.addClass(s.emptyTreeClass),s.dropzoneClass&&t.$dropzoneElm.addClass(s.dropzoneClass),t.$watch("$nodesScope.$modelValue.length",function(e){angular.isNumber(e)&&(r.resetEmptyElement(),r.resetDropzoneElement())},!0),t.$watch(l.dragEnabled,function(e){"boolean"==typeof e&&(t.dragEnabled=e)}),t.$watch(l.emptyPlaceholderEnabled,function(e){"boolean"==typeof e&&(t.emptyPlaceholderEnabled=e,r.resetEmptyElement())}),t.$watch(l.nodropEnabled,function(e){"boolean"==typeof e&&(t.nodropEnabled=e)}),t.$watch(l.dropzoneEnabled,function(e){"boolean"==typeof e&&(t.dropzoneEnabled=e,r.resetDropzoneElement())}),t.$watch(l.cloneEnabled,function(e){"boolean"==typeof e&&(t.cloneEnabled=e)}),t.$watch(l.maxDepth,function(e){"number"==typeof e&&(t.maxDepth=e)}),t.$watch(l.dragDelay,function(e){"number"==typeof e&&(t.dragDelay=e)}),c.accept=function(e,n,t){return!(n.nodropEnabled||n.$treeScope.nodropEnabled||n.outOfDepth(e))},c.beforeDrag=function(e){return!0},c.expandTimeoutStart=function(){},c.expandTimeoutCancel=function(){},c.expandTimeoutEnd=function(){},c.removed=function(e){},c.dropped=function(e){},c.dragStart=function(e){},c.dragMove=function(e){},c.dragStop=function(e){},c.beforeDrop=function(e){},c.toggle=function(e,n){},t.$watch(l.uiTree,function(e,n){angular.forEach(e,function(e,n){c[n]&&"function"==typeof e&&(c[n]=e)}),t.$callbacks=c},!0)}}}])}(),function(){"use strict";angular.module("ui.tree").directive("uiTreeHandle",["treeConfig",function(e){return{require:"^uiTreeNode",restrict:"A",scope:!0,controller:"TreeHandleController",link:function(n,t,o,l){var r={};angular.extend(r,e),r.handleClass&&t.addClass(r.handleClass),n!=l.scope&&(n.$nodeScope=l.scope,l.scope.$handleScope=n)}}}])}(),function(){"use strict";angular.module("ui.tree").directive("uiTreeNode",["treeConfig","UiTreeHelper","$window","$document","$timeout","$q",function(e,n,t,o,l,r){return{require:["^uiTreeNodes","^uiTree"],restrict:"A",controller:"TreeNodeController",link:function(a,i,d,c){var s,u,p,m,f,h,$,g,b,v,N,S,E,y,x,C,T,w,D,H,O,Y,X,A,V,k,z,M={},I="ontouchstart"in window,P=null,L=document.body,W=document.documentElement;angular.extend(M,e),M.nodeClass&&i.addClass(M.nodeClass),a.init(c),a.collapsed=!!n.getNodeAttribute(a,"collapsed")||e.defaultCollapsed,a.expandOnHover=!!n.getNodeAttribute(a,"expandOnHover"),a.scrollContainer=n.getNodeAttribute(a,"scrollContainer")||d.scrollContainer||null,a.sourceOnly=a.nodropEnabled||a.$treeScope.nodropEnabled,a.$watch(d.collapsed,function(e){"boolean"==typeof e&&(a.collapsed=e)}),a.$watch("collapsed",function(e){n.setNodeAttribute(a,"collapsed",e),d.$set("collapsed",e)}),a.$watch(d.expandOnHover,function(e){"boolean"!=typeof e&&"number"!=typeof e||(a.expandOnHover=e)}),a.$watch("expandOnHover",function(e){n.setNodeAttribute(a,"expandOnHover",e),d.$set("expandOnHover",e)}),d.$observe("scrollContainer",function(e){"string"==typeof e&&(a.scrollContainer=e)}),a.$watch("scrollContainer",function(e){n.setNodeAttribute(a,"scrollContainer",e),d.$set("scrollContainer",e),$=document.querySelector(e)}),a.$on("angular-ui-tree:collapse-all",function(){a.collapsed=!0}),a.$on("angular-ui-tree:expand-all",function(){a.collapsed=!1}),S=function(e){if((I||2!==e.button&&3!==e.which)&&!(e.uiTreeDragging||e.originalEvent&&e.originalEvent.uiTreeDragging)){var l,r,d,c,$,g,S,E,y,x=angular.element(e.target);if(l=n.treeNodeHandlerContainerOfElement(x),l&&(x=angular.element(l)),r=i.clone(),E=n.elementIsTreeNode(x),y=n.elementIsTreeNodeHandle(x),(E||y)&&!(E&&n.elementContainsTreeNodeHandler(x)||"input"==(d=x.prop("tagName").toLowerCase())||"textarea"==d||"button"==d||"select"==d)){for(V=angular.element(e.target),k=V[0].attributes["ui-tree"];V&&V[0]&&V[0]!==i&&!k;){if(V[0].attributes&&(k=V[0].attributes["ui-tree"]),n.nodrag(V))return;V=V.parent()}a.beforeDrag(a)&&(e.uiTreeDragging=!0,e.originalEvent&&(e.originalEvent.uiTreeDragging=!0),e.preventDefault(),$=n.eventObj(e),s=!0,u=n.dragInfo(a),z=u.source.$treeScope.$id,c=i.prop("tagName"),"tr"===c.toLowerCase()?(m=angular.element(t.document.createElement(c)),g=angular.element(t.document.createElement("td")).addClass(M.placeholderClass).attr("colspan",i[0].children.length),m.append(g)):m=angular.element(t.document.createElement(c)).addClass(M.placeholderClass),f=angular.element(t.document.createElement(c)),M.hiddenClass&&f.addClass(M.hiddenClass),p=n.positionStarted($,i),m.css("height",i.prop("offsetHeight")+"px"),h=angular.element(t.document.createElement(a.$parentNodesScope.$element.prop("tagName"))).addClass(a.$parentNodesScope.$element.attr("class")).addClass(M.dragClass),h.css("width",n.width(i)+"px"),h.css("z-index",9999),S=(i[0].querySelector(".angular-ui-tree-handle")||i[0]).currentStyle,S&&(document.body.setAttribute("ui-tree-cursor",o.find("body").css("cursor")||""),o.find("body").css({cursor:S.cursor+"!important"})),a.sourceOnly&&m.css("display","none"),i.after(m),i.after(f),u.isClone()&&a.sourceOnly?h.append(r):h.append(i),o.find("body").append(h),h.css({left:$.pageX-p.offsetX+"px",top:$.pageY-p.offsetY+"px"}),b={placeholder:m,dragging:h},O(),a.$apply(function(){a.$treeScope.$callbacks.dragStart(u.eventArgs(b,p))}),v=Math.max(L.scrollHeight,L.offsetHeight,W.clientHeight,W.scrollHeight,W.offsetHeight),N=Math.max(L.scrollWidth,L.offsetWidth,W.clientWidth,W.scrollWidth,W.offsetWidth))}}},E=function(e){var o,r,i,d,c,f,S,E,y,x,C,T,w,D,H,O,Y,X,V,k,I,L,W=n.eventObj(e);if(h){if(e.preventDefault(),t.getSelection?t.getSelection().removeAllRanges():t.document.selection&&t.document.selection.empty(),r=W.pageX-p.offsetX,i=W.pageY-p.offsetY,r<0&&(r=0),i<0&&(i=0),i+10>v&&(i=v-10),r+10>N&&(r=N-10),h.css({left:r+"px",top:i+"px"}),$?(f=$.getBoundingClientRect(),d=$.scrollTop,c=d+$.clientHeight,f.bottomW.clientY&&d>0&&(H=Math.min(d,10),$.scrollTop-=H)):(d=window.pageYOffset||t.document.documentElement.scrollTop,c=d+(window.innerHeight||t.document.clientHeight||t.document.clientHeight),cW.pageY&&(H=Math.min(d,10),window.scrollBy(0,-H))),n.positionMoved(e,p,s),s)return void(s=!1);if(E=W.pageX-(t.pageXOffset||t.document.body.scrollLeft||t.document.documentElement.scrollLeft)-(t.document.documentElement.clientLeft||0),y=W.pageY-(t.pageYOffset||t.document.body.scrollTop||t.document.documentElement.scrollTop)-(t.document.documentElement.clientTop||0),angular.isFunction(h.hide)?h.hide():(x=h[0].style.display,h[0].style.display="none"),t.document.elementFromPoint(E,y),T=angular.element(t.document.elementFromPoint(E,y)),A=n.treeNodeHandlerContainerOfElement(T),A&&(T=angular.element(A)),angular.isFunction(h.show)?h.show():h[0].style.display=x,n.elementIsTree(T)?C=T.controller("uiTree").scope:n.elementIsTreeNodeHandle(T)?C=T.controller("uiTreeHandle").scope:n.elementIsTreeNode(T)?C=T.controller("uiTreeNode").scope:n.elementIsTreeNodes(T)?C=T.controller("uiTreeNodes").scope:n.elementIsPlaceholder(T)?C=T.controller("uiTreeNodes").scope:n.elementIsDropzone(T)?(C=T.controller("uiTree").scope,L=!0):T.controller("uiTreeNode")&&(C=T.controller("uiTreeNode").scope),C&&C.$treeScope&&C.$treeScope.$id&&C.$treeScope.$id===z&&p.dirAx)p.distX>0&&(o=u.prev())&&!o.collapsed&&o.accept(a,o.childNodesCount())&&(o.$childNodesScope.$element.append(m),u.moveTo(o.$childNodesScope,o.childNodes(),o.childNodesCount())),p.distX<0&&(u.next()||(S=u.parentNode())&&S.$parentNodesScope.accept(a,S.index()+1)&&(S.$element.after(m),u.moveTo(S.$parentNodesScope,S.siblings(),S.index()+1)));else{if(w=!1,!C)return;if(!C.$treeScope||C.$parent.nodropEnabled||C.$treeScope.nodropEnabled||m.css("display",""),"uiTree"===C.$type&&C.dragEnabled&&(w=C.isEmpty()),"uiTreeHandle"===C.$type&&(C=C.$nodeScope),"uiTreeNode"!==C.$type&&!w&&!L)return void(M.appendChildOnHover&&!u.next()&&g&&(S=u.parentNode(),S.$element.after(m),u.moveTo(S.$parentNodesScope,S.siblings(),S.index()+1),g=!1));P&&m.parent()[0]!=P.$element[0]&&(P.resetEmptyElement(),P.resetDropzoneElement(),P=null),w?(P=C,C.$nodesScope.accept(a,0)&&u.moveTo(C.$nodesScope,C.$nodesScope.childNodes(),0)):L?(P=C,C.$nodesScope.accept(a,C.$nodesScope.childNodes().length)&&u.moveTo(C.$nodesScope,C.$nodesScope.childNodes(),C.$nodesScope.childNodes().length)):C.dragEnabled()&&(angular.isDefined(a.expandTimeoutOn)&&a.expandTimeoutOn!==C.id&&(l.cancel(a.expandTimeout),delete a.expandTimeout,delete a.expandTimeoutOn,a.$callbacks.expandTimeoutCancel()),C.collapsed&&(!0===a.expandOnHover||angular.isNumber(a.expandOnHover)&&0===a.expandOnHover?(C.collapsed=!1,C.$treeScope.$callbacks.toggle(!1,C)):!1!==a.expandOnHover&&angular.isNumber(a.expandOnHover)&&a.expandOnHover>0&&angular.isUndefined(a.expandTimeoutOn)&&(a.expandTimeoutOn=C.$id,a.$callbacks.expandTimeoutStart(),a.expandTimeout=l(function(){a.$callbacks.expandTimeoutEnd(),C.collapsed=!1,C.$treeScope.$callbacks.toggle(!1,C)},a.expandOnHover))),T=C.$element,O=n.offset(T),V=n.height(T),k=C.$childNodesScope?C.$childNodesScope.$element:null,I=k?n.height(k):0,V-=I,X=M.appendChildOnHover?.25*V:n.height(T)/2,Y=W.pageY0?D.exec(function(){x(e)},a.dragDelay):x(e)}),i.bind("touchend touchcancel mouseup",function(){a.dragDelay>0&&D.cancel()})},H(),O=function(){angular.element(o).bind("touchend",T),angular.element(o).bind("touchcancel",T),angular.element(o).bind("touchmove",C),angular.element(o).bind("mouseup",T),angular.element(o).bind("mousemove",C),angular.element(o).bind("mouseleave",w),angular.element(o).bind("keydown",X)},Y=function(){angular.element(o).unbind("touchend",T),angular.element(o).unbind("touchcancel",T),angular.element(o).unbind("touchmove",C),angular.element(o).unbind("mouseup",T),angular.element(o).unbind("mousemove",C),angular.element(o).unbind("mouseleave",w),angular.element(o).unbind("keydown",X)}}}}])}(),function(){"use strict";angular.module("ui.tree").directive("uiTreeNodes",["treeConfig","$window",function(e){return{require:["ngModel","?^uiTreeNode","^uiTree"],restrict:"A",scope:!0,controller:"TreeNodesController",link:function(n,t,o,l){var r={},a=l[0],i=l[1],d=l[2];angular.extend(r,e),r.nodesClass&&t.addClass(r.nodesClass),i?(i.scope.$childNodesScope=n,n.$nodeScope=i.scope):d.scope.$nodesScope=n,n.$treeScope=d.scope,a&&(a.$render=function(){n.$modelValue=a.$modelValue}),n.$watch(function(){return o.maxDepth},function(e){"number"==typeof e&&(n.maxDepth=e)}),n.$watch(function(){return o.nodropEnabled},function(e){void 0!==e&&(n.nodropEnabled=!0)},!0)}}}])}(),function(){"use strict";function e(e,n){if(void 0===n)return null;for(var t=n.parentNode,o=1,l="function"==typeof t.setAttribute&&t.hasAttribute(e)?t:null;t&&"function"==typeof t.setAttribute&&!t.hasAttribute(e);){if(t=t.parentNode,l=t,t===document.documentElement){l=null;break}o++}return l}angular.module("ui.tree").factory("UiTreeHelper",["$document","$window","treeConfig",function(n,t,o){return{nodesData:{},setNodeAttribute:function(e,n,t){if(!e.$modelValue)return null;var o=this.nodesData[e.$modelValue.$$hashKey];o||(o={},this.nodesData[e.$modelValue.$$hashKey]=o),o[n]=t},getNodeAttribute:function(e,n){if(!e.$modelValue)return null;var t=this.nodesData[e.$modelValue.$$hashKey];return t?t[n]:null},nodrag:function(e){return void 0!==e.attr("data-nodrag")&&"false"!==e.attr("data-nodrag")},eventObj:function(e){var n=e;return void 0!==e.targetTouches?n=e.targetTouches.item(0):void 0!==e.originalEvent&&void 0!==e.originalEvent.targetTouches&&(n=e.originalEvent.targetTouches.item(0)),n},dragInfo:function(e){return{source:e,sourceInfo:{cloneModel:!0===e.$treeScope.cloneEnabled?angular.copy(e.$modelValue):void 0,nodeScope:e,index:e.index(),nodesScope:e.$parentNodesScope},index:e.index(),siblings:e.siblings().slice(0),parent:e.$parentNodesScope,resetParent:function(){this.parent=e.$parentNodesScope},moveTo:function(e,n,t){this.parent=e,this.siblings=n.slice(0);var o=this.siblings.indexOf(this.source);o>-1&&(this.siblings.splice(o,1),this.source.index()0?this.siblings[this.index-1]:null},next:function(){return this.index0&&(o=e.originalEvent.touches[0].pageX,l=e.originalEvent.touches[0].pageY),t.offsetX=o-this.offset(n).left,t.offsetY=l-this.offset(n).top,t.startX=t.lastX=o,t.startY=t.lastY=l,t.nowX=t.nowY=t.distX=t.distY=t.dirAx=0,t.dirX=t.dirY=t.lastDirX=t.lastDirY=t.distAxX=t.distAxY=0,t},positionMoved:function(e,n,t){var o,l=e.pageX,r=e.pageY;if(e.originalEvent&&e.originalEvent.touches&&e.originalEvent.touches.length>0&&(l=e.originalEvent.touches[0].pageX,r=e.originalEvent.touches[0].pageY),n.lastX=n.nowX,n.lastY=n.nowY,n.nowX=l,n.nowY=r,n.distX=n.nowX-n.lastX,n.distY=n.nowY-n.lastY,n.lastDirX=n.dirX,n.lastDirY=n.dirY,n.dirX=0===n.distX?0:n.distX>0?1:-1,n.dirY=0===n.distY?0:n.distY>0?1:-1,o=Math.abs(n.distX)>Math.abs(n.distY)?1:0,t)return n.dirAx=o,void(n.moving=!0);n.dirAx!==o?(n.distAxX=0,n.distAxY=0):(n.distAxX+=Math.abs(n.distX),0!==n.dirX&&n.dirX!==n.lastDirX&&(n.distAxX=0),n.distAxY+=Math.abs(n.distY),0!==n.dirY&&n.dirY!==n.lastDirY&&(n.distAxY=0)),n.dirAx=o},elementIsTreeNode:function(e){return void 0!==e.attr("ui-tree-node")},elementIsTreeNodeHandle:function(e){return void 0!==e.attr("ui-tree-handle")},elementIsTree:function(e){return void 0!==e.attr("ui-tree")},elementIsTreeNodes:function(e){return void 0!==e.attr("ui-tree-nodes")},elementIsPlaceholder:function(e){return e.hasClass(o.placeholderClass)},elementIsDropzone:function(e){return e.hasClass(o.dropzoneClass)},elementContainsTreeNodeHandler:function(e){return e[0].querySelectorAll("[ui-tree-handle]").length>=1},treeNodeHandlerContainerOfElement:function(n){return e("ui-tree-handle",n[0])}}}])}(); \ No newline at end of file diff --git a/traffic_portal/app/src/common/api/TopologyService.js b/traffic_portal/app/src/common/api/TopologyService.js new file mode 100644 index 0000000000..87a7f6d3d1 --- /dev/null +++ b/traffic_portal/app/src/common/api/TopologyService.js @@ -0,0 +1,72 @@ +/* + * 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. + */ + +var TopologyService = function($http, ENV, locationUtils, messageModel) { + + this.getTopologies = function(queryParams) { + return $http.get(ENV.api['latest'] + 'topologies', { params: queryParams }).then( + function(result) { + return result.data.response; + }, + function(err) { + throw err; + } + ); + }; + + this.createTopology = function(topology) { + return $http.post(ENV.api['latest'] + 'topologies', topology).then( + function(result) { + return result; + }, + function(err) { + messageModel.setMessages(err.data.alerts, false); + throw err; + } + ); + }; + + this.updateTopology = function(topology) { + return $http.put(ENV.api['latest'] + 'topologies', topology, { params: { name: topology.name } }).then( + function(result) { + return result; + }, + function(err) { + messageModel.setMessages(err.data.alerts, false); + throw err; + } + ); + }; + + this.deleteTopology = function(topology) { + return $http.delete(ENV.api['latest'] + "topologies", { params: { name: topology.name } }).then( + function(result) { + return result; + }, + function(err) { + messageModel.setMessages(err.data.alerts, true); + throw err; + } + ); + }; + +}; + +TopologyService.$inject = ['$http', 'ENV', 'locationUtils', 'messageModel']; +module.exports = TopologyService; diff --git a/traffic_portal/app/src/common/api/index.js b/traffic_portal/app/src/common/api/index.js index 7ee157a6b4..079b6d9001 100644 --- a/traffic_portal/app/src/common/api/index.js +++ b/traffic_portal/app/src/common/api/index.js @@ -53,6 +53,7 @@ module.exports = angular.module('trafficPortal.api', []) .service('statusService', require('./StatusService')) .service('tenantService', require('./TenantService')) .service('toolsService', require('./ToolsService')) + .service('topologyService', require('./TopologyService')) .service('typeService', require('./TypeService')) .service('trafficPortalService', require('./TrafficPortalService')) .service('userService', require('./UserService')) diff --git a/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js b/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js index d2fccc9d23..2d3fac7022 100644 --- a/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js +++ b/traffic_portal/app/src/common/modules/dialog/select/DialogSelectController.js @@ -46,6 +46,10 @@ var DialogSelectController = function(params, collection, $scope, $uibModalInsta } $scope.key = $scope.params.key || 'id'; + + $scope.required = ($scope.params.required !== undefined) ? $scope.params.required : true; + + $scope.selectedItemKeyValue = $scope.params.selectedItemKeyValue || null; }; init(); diff --git a/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html b/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html index 881a32d83e..0d3522ef64 100644 --- a/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html +++ b/traffic_portal/app/src/common/modules/dialog/select/dialog.select.tpl.html @@ -24,7 +24,7 @@