From 9964f092a28c388213949f4d21bae18e27139e92 Mon Sep 17 00:00:00 2001 From: Pavel Nesterovkiy Date: Fri, 5 Jul 2024 07:25:16 +0000 Subject: [PATCH 01/50] bugfix/ADCM-5752 autoscroll for single jobLog https://tracker.yandex.ru/ADCM-5752 --- .../pages/JobsPage/JobPage/JobPage.tsx | 4 +- .../pages/JobsPage/JobPage/SingleJob.tsx | 30 +++++++++++ .../JobsPage/JobPage/useJobLogAutoScroll.ts | 52 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 adcm-web/app/src/components/pages/JobsPage/JobPage/SingleJob.tsx create mode 100644 adcm-web/app/src/components/pages/JobsPage/JobPage/useJobLogAutoScroll.ts diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx index d322ab83b6..dd6bf797b2 100644 --- a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx @@ -5,8 +5,8 @@ import JobsSubPageStopHeader from './JobPageHeader/JobPageHeader'; import { useEffect } from 'react'; import { setBreadcrumbs } from '@store/adcm/breadcrumbs/breadcrumbsSlice'; import JobPageChildJobsTable from './JobPageChildJobsTable/JobPageChildJobsTable'; -import JobPageLog from './JobPageLog/JobPageLog'; import JobPageStopJobDialog from './Dialogs/JobPageStopJobDialog'; +import SingleJob from '@pages/JobsPage/JobPage/SingleJob'; const JobPage: React.FC = () => { const { task, dispatch } = useRequestJobPage(); @@ -24,7 +24,7 @@ const JobPage: React.FC = () => { - {task.childJobs?.length === 1 && } + {task.childJobs?.length === 1 && } {task.childJobs && task.childJobs.length > 1 && ( diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/SingleJob.tsx b/adcm-web/app/src/components/pages/JobsPage/JobPage/SingleJob.tsx new file mode 100644 index 0000000000..a0f9ed38d3 --- /dev/null +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/SingleJob.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import s from '@pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.module.scss'; +import ExpandableSwitch from '@uikit/Switch/ExpandableSwitch'; +import JobPageLog from '@pages/JobsPage/JobPage/JobPageLog/JobPageLog'; +import { useJobLogAutoScroll } from '@pages/JobsPage/JobPage/useJobLogAutoScroll'; +import { AdcmTask } from '@models/adcm'; + +interface SingleJobProps { + task: AdcmTask; +} + +const SingleJob = ({ task }: SingleJobProps) => { + const { isUserScrollRef, isAutoScrollState, setIsAutoScroll, toggleIsAutoScroll } = useJobLogAutoScroll(task); + + return ( +
+ +
+ +
+
+ ); +}; + +export default SingleJob; diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/useJobLogAutoScroll.ts b/adcm-web/app/src/components/pages/JobsPage/JobPage/useJobLogAutoScroll.ts new file mode 100644 index 0000000000..1dcf46b5b4 --- /dev/null +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/useJobLogAutoScroll.ts @@ -0,0 +1,52 @@ +import { AdcmJobStatus, AdcmTask } from '@models/adcm'; +import { useParams } from 'react-router-dom'; +import { useEffect, useRef, useState } from 'react'; + +export const useJobLogAutoScroll = (task: AdcmTask) => { + const isUserScrollRef = useRef(false); + const isAutoScrollRef = useRef(true); + const [isAutoScrollState, setIsAutoScrollState] = useState(true); + const props = useParams(); + const { withAutoStop } = props; + + const setIsAutoScroll = (state: boolean) => { + if (!withAutoStop) return; + setIsAutoScrollState(state); + isAutoScrollRef.current = state; + }; + + const toggleIsAutoScroll = () => { + isAutoScrollRef.current = !isAutoScrollRef.current; + setIsAutoScrollState((prev) => !prev); + }; + + useEffect(() => { + const onUserScrollHandler = () => { + if (isUserScrollRef.current) { + setIsAutoScroll(false); + } + }; + + const onEndHandler = () => { + isUserScrollRef.current = true; + }; + + if (task.status === AdcmJobStatus.Running && isAutoScrollState) { + window.addEventListener('scroll', onUserScrollHandler); + window.addEventListener('scrollend', onEndHandler); + } + + return () => { + window.removeEventListener('scroll', onUserScrollHandler); + window.removeEventListener('scrollend', onEndHandler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.status, isAutoScrollRef, isAutoScrollState]); + + return { + isAutoScrollState, + toggleIsAutoScroll, + isUserScrollRef, + setIsAutoScroll, + }; +}; From 3857e27c9610d2b0647524ad87e47b91dbd6519c Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 8 Jul 2024 07:22:33 +0000 Subject: [PATCH 02/50] ADCM-5755 Optimize requests for `/mapping/components/` Notes: - Optimization of `depend_on` left out of scope - Looks like serialization takes a lot of time, can be optimized further by directly requesting services info (e.g. as dicts instead of ORM objects) --- python/api_v2/cluster/views.py | 25 ++++++++++++++++++++++++- python/api_v2/component/serializers.py | 12 +++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/python/api_v2/cluster/views.py b/python/api_v2/cluster/views.py index 21292a85bd..534ecfe340 100644 --- a/python/api_v2/cluster/views.py +++ b/python/api_v2/cluster/views.py @@ -32,6 +32,9 @@ Prototype, ServiceComponent, ) +from cm.services.cluster import retrieve_clusters_objects_maintenance_mode, retrieve_clusters_topology +from core.cluster.operations import calculate_maintenance_mode_for_cluster_objects +from core.cluster.types import MaintenanceModeOfObjects from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from guardian.mixins import PermissionListMixin @@ -415,8 +418,28 @@ def mapping_hosts(self, request: Request, *args, **kwargs) -> Response: # noqa: ) def mapping_components(self, request: Request, *args, **kwargs): # noqa: ARG002 cluster = self.get_object() + + is_mm_available = Prototype.objects.values_list("allow_maintenance_mode", flat=True).get( + id=cluster.prototype_id + ) + + objects_mm = ( + calculate_maintenance_mode_for_cluster_objects( + topology=next(retrieve_clusters_topology(cluster_ids=(cluster.id,))), + own_maintenance_mode=retrieve_clusters_objects_maintenance_mode(cluster_ids=(cluster.id,)), + ) + if is_mm_available + else MaintenanceModeOfObjects(services={}, components={}, hosts={}) + ) + serializer = self.get_serializer( - instance=ServiceComponent.objects.filter(cluster=cluster).order_by("pk"), many=True + instance=( + ServiceComponent.objects.filter(cluster=cluster) + .select_related("prototype", "service__prototype") + .order_by("pk") + ), + many=True, + context={"mm": objects_mm, "is_mm_available": is_mm_available}, ) return Response(status=HTTP_200_OK, data=serializer.data) diff --git a/python/api_v2/component/serializers.py b/python/api_v2/component/serializers.py index 5fcdfef6f3..1958880a91 100644 --- a/python/api_v2/component/serializers.py +++ b/python/api_v2/component/serializers.py @@ -13,6 +13,7 @@ from cm.adcm_config.config import get_main_info from cm.models import Host, HostComponent, MaintenanceMode, ServiceComponent from drf_spectacular.utils import extend_schema_field +from rest_framework.fields import BooleanField from rest_framework.serializers import ( CharField, ChoiceField, @@ -36,7 +37,8 @@ class ComponentMappingSerializer(ModelSerializer): depend_on = SerializerMethodField() constraints = ListField(source="constraint") prototype = PrototypeRelatedSerializer(read_only=True) - maintenance_mode = ChoiceField(choices=(MaintenanceMode.ON.value, MaintenanceMode.OFF.value)) + maintenance_mode = SerializerMethodField() + is_maintenance_mode_available = SerializerMethodField() class Meta: model = ServiceComponent @@ -60,6 +62,14 @@ def get_depend_on(instance: ServiceComponent) -> list[dict] | None: return None + @extend_schema_field(field=ChoiceField(choices=(MaintenanceMode.ON.value, MaintenanceMode.OFF.value))) + def get_maintenance_mode(self, instance: ServiceComponent): + return self.context["mm"].components.get(instance.id, MaintenanceMode.OFF).value + + @extend_schema_field(field=BooleanField()) + def get_is_maintenance_mode_available(self, _instance: ServiceComponent): + return self.context["is_mm_available"] + class ComponentSerializer(WithStatusSerializer): hosts = SerializerMethodField() From c0e398bb6751ca804906699094aafafc4e7034e5 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 8 Jul 2024 07:22:58 +0000 Subject: [PATCH 03/50] ADCM-5756 Fix non-required json fields handling with value None --- python/api_v2/config/utils.py | 3 +- .../tests/bundles/cluster_one/config.yaml | 29 +++++++++++++++++++ python/api_v2/tests/test_cluster.py | 6 ++-- python/api_v2/tests/test_config.py | 25 ++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/python/api_v2/config/utils.py b/python/api_v2/config/utils.py index 3b4e602e45..f4d428adc5 100644 --- a/python/api_v2/config/utils.py +++ b/python/api_v2/config/utils.py @@ -834,7 +834,8 @@ def represent_string_as_json_type( name = prototype_config.name sub_name = prototype_config.subname - if name not in value or sub_name not in value[name]: + # json may be `null`/`None` if it's not required, so we patch it as () to skip this field + if name not in value or sub_name not in (value[name] or ()): continue try: diff --git a/python/api_v2/tests/bundles/cluster_one/config.yaml b/python/api_v2/tests/bundles/cluster_one/config.yaml index 3206809ed2..574dcf9fe9 100644 --- a/python/api_v2/tests/bundles/cluster_one/config.yaml +++ b/python/api_v2/tests/bundles/cluster_one/config.yaml @@ -229,3 +229,32 @@ - <<: *service_1 name: service_1_clone + +- name: adcm_5756 + type: service + version: "bug" + config: + - type: boolean + name: boolean + required: false + - type: map + name: map + required: false + - type: secretmap + name: secretmap + required: false + - type: json + name: json + required: false + - type: list + name: list + required: false + - name: plain_group + type: group + subs: + - name: map + type: map + required: false + - name: listofstuff + type: list + required: false diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index bd2a4f620f..a58a30dd02 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -305,10 +305,11 @@ def test_service_prototypes_success(self): response = (self.client.v2[self.cluster_1] / "service-prototypes").get() self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.json()), 7) + self.assertEqual(len(response.json()), 8) self.assertListEqual( [prototype["displayName"] for prototype in response.json()], [ + "adcm_5756", "service_1", "service_1_clone", "service_2", @@ -325,10 +326,11 @@ def test_service_candidates_success(self): response = (self.client.v2[self.cluster_1] / "service-candidates").get() self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.json()), 6) + self.assertEqual(len(response.json()), 7) self.assertListEqual( [prototype["displayName"] for prototype in response.json()], [ + "adcm_5756", "service_1", "service_1_clone", "service_2", diff --git a/python/api_v2/tests/test_config.py b/python/api_v2/tests/test_config.py index e57a1b0845..2188501f03 100644 --- a/python/api_v2/tests/test_config.py +++ b/python/api_v2/tests/test_config.py @@ -787,6 +787,31 @@ def test_permissions_model_role_list_denied(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + def test_adcm_5756_500_on_non_required_field(self): + service: ClusterObject = self.add_services_to_cluster(["adcm_5756"], cluster=self.cluster_1).get() + + config = self.client.v2[service, "configs", service.config.current].get().json() + + # change only boolean field + new_data = {"adcmMeta": config["adcmMeta"], "config": config["config"] | {"boolean": True}} + + with self.subTest("json=None"): + response = self.client.v2[service, "configs"].post(data=new_data) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + service.refresh_from_db(fields=["config"]) + record = ConfigLog.objects.get(id=service.config.current) + self.assertEqual(record.config["json"], None) + + with self.subTest("json='{}'"): + new_data = {"adcmMeta": config["adcmMeta"], "config": config["config"] | {"json": "{}"}} + response = self.client.v2[service, "configs"].post(data=new_data) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + service.refresh_from_db(fields=["config"]) + record = ConfigLog.objects.get(id=service.config.current) + self.assertEqual(record.config["json"], {}) + class TestServiceGroupConfig(BaseAPITestCase): def setUp(self) -> None: From 2af07ef61afb5ffc4883d52901464df745280212 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Mon, 8 Jul 2024 07:39:32 +0000 Subject: [PATCH 04/50] ADCM-5707 ADCM-5702 enrich mm tests --- .../api_v2/tests/test_audit/test_component.py | 219 ++++++------------ .../api_v2/tests/test_audit/test_service.py | 209 ++++++----------- python/cm/services/maintenance_mode.py | 21 +- 3 files changed, 158 insertions(+), 291 deletions(-) diff --git a/python/api_v2/tests/test_audit/test_component.py b/python/api_v2/tests/test_audit/test_component.py index 7805587a6b..b3dbdeb3ba 100644 --- a/python/api_v2/tests/test_audit/test_component.py +++ b/python/api_v2/tests/test_audit/test_component.py @@ -11,14 +11,14 @@ # limitations under the License. -from cm.models import Action, ClusterObject, ServiceComponent -from rest_framework.reverse import reverse +from cm.models import Action, Cluster, ClusterObject, MaintenanceMode, ServiceComponent from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, ) from api_v2.tests.base import BaseAPITestCase @@ -46,17 +46,7 @@ def setUp(self) -> None: } def test_run_action_success(self): - response = self.client.post( - path=reverse( - viewname="v2:component-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.component_action.pk, - }, - ), - ) + response = self.client.v2[self.component_1, "actions", self.component_action.pk, "run"].post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -71,17 +61,7 @@ def test_run_action_no_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.component_1, role_name="View component configurations"): - response = self.client.post( - path=reverse( - viewname="v2:component-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.component_action.pk, - }, - ), - ) + response = self.client.v2[self.component_1, "actions", self.component_action, "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -93,17 +73,7 @@ def test_run_action_no_perms_denied(self): ) def test_run_action_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.get_non_existent_pk(model=Action), - }, - ), - ) + response = self.client.v2[self.component_1, "actions", self.get_non_existent_pk(model=Action), "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -115,17 +85,7 @@ def test_run_action_fail(self): ) def test_update_config_success(self): - response = self.client.post( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), - data=self.config_post_data, - ) + response = self.client.v2[self.component_1, "configs"].post(data=self.config_post_data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.check_last_audit_record( @@ -139,17 +99,7 @@ def test_update_config_success(self): def test_update_config_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), - data=self.config_post_data, - ) + response = self.client.v2[self.component_1, "configs"].post(data=self.config_post_data) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -168,17 +118,7 @@ def test_update_config_only_view_denied(self): self.grant_permissions(to=self.test_user, on=self.service_1, role_name="View service configurations"), self.grant_permissions(to=self.test_user, on=self.component_1, role_name="View component configurations"), ): - response = self.client.post( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), - data=self.config_post_data, - ) + response = self.client.v2[self.component_1, "configs"].post(data=self.config_post_data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -190,17 +130,7 @@ def test_update_config_only_view_denied(self): ) def test_update_config_wrong_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), - data={"wrong": ["d", "a", "t", "a"]}, - ) + response = self.client.v2[self.component_1, "configs"].post(data={"wrong": ["d", "a", "t", "a"]}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.check_last_audit_record( @@ -212,15 +142,9 @@ def test_update_config_wrong_data_fail(self): ) def test_update_config_not_exists_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.get_non_existent_pk(model=ServiceComponent), - }, - ), + response = self.client.v2[ + self.service_1, "components", self.get_non_existent_pk(model=ServiceComponent), "configs" + ].post( data=self.config_post_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -234,13 +158,7 @@ def test_update_config_not_exists_fail(self): ) def test_change_mm_success(self): - response = self.client.post( - path=reverse( - viewname="v2:component-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk, "pk": self.component_1.pk}, - ), - data={"maintenanceMode": "on"}, - ) + response = self.client.v2[self.component_1, "maintenance-mode"].post(data={"maintenanceMode": "on"}) self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -255,17 +173,7 @@ def test_change_mm_success(self): def test_change_mm_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:component-maintenance-mode", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_1.pk, - }, - ), - data={"maintenanceMode": "on"}, - ) + response = self.client.v2[self.component_1, "maintenance-mode"].post(data={"maintenanceMode": "on"}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -284,17 +192,7 @@ def test_change_mm_denied(self): self.grant_permissions(to=self.test_user, on=self.service_1, role_name="View service configurations"), self.grant_permissions(to=self.test_user, on=self.component_1, role_name="View component configurations"), ): - response = self.client.post( - path=reverse( - viewname="v2:component-maintenance-mode", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_1.pk, - }, - ), - data={"maintenanceMode": "on"}, - ) + response = self.client.v2[self.component_1, "maintenance-mode"].post(data={"maintenanceMode": "on"}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -306,17 +204,7 @@ def test_change_mm_denied(self): ) def test_change_mm_incorrect_body_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-maintenance-mode", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_1.pk, - }, - ), - data={"maintenanceMode": "cough"}, - ) + response = self.client.v2[self.component_1, "maintenance-mode"].post(data={"maintenanceMode": "cough"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.check_last_audit_record( @@ -327,24 +215,71 @@ def test_change_mm_incorrect_body_fail(self): user__username="admin", ) - def test_change_mm_not_found_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-maintenance-mode", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.get_non_existent_pk(model=ServiceComponent), - }, - ), - data={"maintenanceMode": "on"}, - ) - self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + def test_change_mm_from_changing_state_fail(self): + self.component_1.maintenance_mode = MaintenanceMode.CHANGING + self.component_1.save(update_fields=["_maintenance_mode"]) + + response = self.client.v2[self.component_1, "maintenance-mode"].post(data={"maintenanceMode": "on"}) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.check_last_audit_record( operation_name="Component updated", operation_type="update", operation_result="fail", - **self.prepare_audit_object_arguments(expected_object=None), + **self.prepare_audit_object_arguments(self.component_1), user__username="admin", ) + + def test_change_mm_not_found_fail(self): + audit_object_kwargs = self.prepare_audit_object_arguments(expected_object=self.component_1) + audit_object_none_kwargs = self.prepare_audit_object_arguments(expected_object=None) + endpoints = { + "absent_cluster": ( + self.client.v2 + / "clusters" + / self.get_non_existent_pk(model=Cluster) + / "services" + / self.service_1.pk + / "components" + / self.component_1.pk + / "maintenance-mode", + audit_object_kwargs, + ), + "absent_service": ( + self.client.v2 + / "clusters" + / self.cluster_1.pk + / "services" + / self.get_non_existent_pk(model=ClusterObject) + / "components" + / self.component_1.pk + / "maintenance-mode", + audit_object_kwargs, + ), + "absent_component": ( + self.client.v2 + / "clusters" + / self.cluster_1.pk + / "services" + / self.service_1.pk + / "components" + / self.get_non_existent_pk(model=ServiceComponent) + / "maintenance-mode", + audit_object_none_kwargs, + ), + } + + for name, data in endpoints.items(): + endpoint, object_kwargs = data + + with self.subTest(name): + response = endpoint.post(data={"maintenanceMode": "on"}) + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + self.check_last_audit_record( + operation_name="Component updated", + operation_type="update", + operation_result="fail", + **object_kwargs, + user__username="admin", + ) diff --git a/python/api_v2/tests/test_audit/test_service.py b/python/api_v2/tests/test_audit/test_service.py index 19dee612c8..431c3de5a8 100644 --- a/python/api_v2/tests/test_audit/test_service.py +++ b/python/api_v2/tests/test_audit/test_service.py @@ -10,8 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from cm.models import Action, ClusterObject, ObjectType -from rest_framework.reverse import reverse +from cm.models import Action, Cluster, ClusterObject, MaintenanceMode, ObjectType from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -19,6 +18,7 @@ HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, ) from api_v2.tests.base import BaseAPITestCase @@ -48,13 +48,7 @@ def setUp(self) -> None: self.service_action = Action.objects.get(name="action", prototype=self.service_1.prototype) def test_update_config_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), - data=self.config_post_data, - ) + response = self.client.v2[self.service_1, "configs"].post(data=self.config_post_data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.check_last_audit_record( @@ -68,13 +62,7 @@ def test_update_config_success(self): def test_update_config_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), - data=self.config_post_data, - ) + response = self.client.v2[self.service_1, "configs"].post(data=self.config_post_data) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -89,13 +77,7 @@ def test_update_config_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="View service configurations"): - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), - data=self.config_post_data, - ) + response = self.client.v2[self.service_1, "configs"].post(data=self.config_post_data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -107,13 +89,7 @@ def test_update_config_view_perms_denied(self): ) def test_update_config_wrong_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), - data={"wrong": ["d", "a", "t", "a"]}, - ) + response = self.client.v2[self.service_1, "configs"].post(data={"wrong": ["d", "a", "t", "a"]}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.check_last_audit_record( @@ -125,11 +101,9 @@ def test_update_config_wrong_data_fail(self): ) def test_update_config_not_exists_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.get_non_existent_pk(model=ClusterObject)}, - ), + response = self.client.v2[ + self.cluster_1, "services", self.get_non_existent_pk(model=ClusterObject), "configs" + ].post( data=self.config_post_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -144,13 +118,7 @@ def test_update_config_not_exists_fail(self): ) def test_change_mm_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk}, - ), - data={"maintenanceMode": "on"}, - ) + response = self.client.v2[self.service_1, "maintenance-mode"].post(data={"maintenanceMode": "on"}) self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -165,13 +133,7 @@ def test_change_mm_success(self): def test_change_mm_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk}, - ), - data={"maintenanceMode": "on"}, - ) + response = self.client.v2[self.service_1, "maintenance-mode"].post(data={"maintenanceMode": "on"}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -186,13 +148,7 @@ def test_change_mm_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="View service configurations"): - response = self.client.post( - path=reverse( - viewname="v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk}, - ), - data={"maintenanceMode": "on"}, - ) + response = self.client.v2[self.service_1, "maintenance-mode"].post(data={"maintenanceMode": "on"}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -204,13 +160,7 @@ def test_change_mm_view_perms_denied(self): ) def test_change_mm_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk}, - ), - data={}, - ) + response = self.client.v2[self.service_1, "maintenance-mode"].post(data={}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.check_last_audit_record( @@ -221,31 +171,60 @@ def test_change_mm_incorrect_data_fail(self): user__username="admin", ) - def test_change_mm_not_exist_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.get_non_existent_pk(model=ClusterObject)}, - ), - data={"maintenanceMode": "on"}, - ) - self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + def test_change_mm_from_changing_state_fail(self): + self.service_1.maintenance_mode = MaintenanceMode.CHANGING + self.service_1.save(update_fields=["_maintenance_mode"]) + + response = self.client.v2[self.service_1, "maintenance-mode"].post(data={"maintenanceMode": "on"}) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.check_last_audit_record( operation_name="Service updated", operation_type="update", operation_result="fail", - audit_object__isnull=True, + **self.prepare_audit_object_arguments(self.service_1), user__username="admin", ) - def test_run_action_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-action-run", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk, "pk": self.service_action.pk}, + def test_change_mm_not_exist_fail(self): + endpoints = { + "absent_cluster": ( + self.client.v2 + / "clusters" + / self.get_non_existent_pk(model=Cluster) + / "services" + / self.service_1.pk + / "maintenance-mode", + self.prepare_audit_object_arguments(expected_object=self.service_1), ), - ) + "absent_service": ( + self.client.v2 + / "clusters" + / self.cluster_1.pk + / "services" + / self.get_non_existent_pk(model=ClusterObject) + / "maintenance-mode", + self.prepare_audit_object_arguments(expected_object=None), + ), + } + + for name, data in endpoints.items(): + endpoint, object_kwargs = data + + with self.subTest(name): + response = endpoint.post(data={"maintenanceMode": "on"}) + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + self.check_last_audit_record( + operation_name="Service updated", + operation_type="update", + operation_result="fail", + **object_kwargs, + user__username="admin", + ) + + def test_run_action_success(self): + response = self.client.v2[self.service_1, "actions", self.service_action, "run"].post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -260,16 +239,7 @@ def test_run_action_no_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="View service configurations"): - response = self.client.post( - path=reverse( - viewname="v2:service-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.service_action.pk, - }, - ), - ) + response = self.client.v2[self.service_1, "actions", self.service_action, "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -281,16 +251,7 @@ def test_run_action_no_perms_denied(self): ) def test_run_action_not_found_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.get_non_existent_pk(model=Action), - }, - ), - ) + response = self.client.v2[self.service_1, "actions", self.get_non_existent_pk(model=Action), "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -302,11 +263,7 @@ def test_run_action_not_found_fail(self): ) def test_create_import_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), + response = self.client.v2[self.service_1, "imports"].post( data=[{"source": {"id": self.export_service.pk, "type": ObjectType.SERVICE}}], ) @@ -323,11 +280,7 @@ def test_create_import_success(self): def test_create_import_no_perm_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), + response = self.client.v2[self.service_1, "imports"].post( data=[{"source": {"id": self.export_service.pk, "type": ObjectType.SERVICE}}], ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -346,11 +299,7 @@ def test_create_import_view_perm_denied(self): with self.grant_permissions( to=self.test_user, on=self.service_1, role_name="View service configurations" ), self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View cluster configurations"): - response = self.client.post( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), + response = self.client.v2[self.service_1, "imports"].post( data=[{"source": {"id": self.export_service.pk, "type": ObjectType.SERVICE}}], ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -364,11 +313,7 @@ def test_create_import_view_perm_denied(self): ) def test_create_import_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), + response = self.client.v2[self.service_1, "imports"].post( data=[{"source": {"id": self.export_service.pk, "type": "cool"}}], ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -382,11 +327,9 @@ def test_create_import_incorrect_data_fail(self): ) def test_create_import_not_found_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.get_non_existent_pk(model=ClusterObject)}, - ), + response = self.client.v2[ + self.cluster_1, "services", self.get_non_existent_pk(model=ClusterObject), "imports" + ].post( data=[{"source": {"id": self.export_service.pk, "type": ObjectType.SERVICE}}], ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -404,11 +347,7 @@ def test_remove_service_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Remove service"): - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk} - ) - ) + response = self.client.v2[self.service_1].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) @@ -423,11 +362,7 @@ def test_remove_service_success(self): def test_remove_service_not_found_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk} - ) - ) + response = self.client.v2[self.service_1].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -443,11 +378,7 @@ def test_remove_service_view_perm_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="View service configurations"): - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk} - ) - ) + response = self.client.v2[self.service_1].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) diff --git a/python/cm/services/maintenance_mode.py b/python/cm/services/maintenance_mode.py index 9f044da54f..82b776b165 100644 --- a/python/cm/services/maintenance_mode.py +++ b/python/cm/services/maintenance_mode.py @@ -74,9 +74,20 @@ def get_maintenance_mode_response( obj: Host | ClusterObject | ServiceComponent, serializer: Serializer, ) -> Response: + if obj.maintenance_mode_attr == MaintenanceMode.CHANGING: + return Response( + data={ + "code": "MAINTENANCE_MODE", + "level": "error", + "desc": "Maintenance mode is changing now", + }, + status=HTTP_409_CONFLICT, + ) + turn_on_action_name = settings.ADCM_TURN_ON_MM_ACTION_NAME turn_off_action_name = settings.ADCM_TURN_OFF_MM_ACTION_NAME prototype = obj.prototype + if isinstance(obj, Host): obj_name = "host" turn_on_action_name = settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME @@ -108,16 +119,6 @@ def get_maintenance_mode_response( if obj_name == "component": component_has_hc = HostComponent.objects.filter(component=obj).exists() - if obj.maintenance_mode_attr == MaintenanceMode.CHANGING: - return Response( - data={ - "code": "MAINTENANCE_MODE", - "level": "error", - "desc": "Maintenance mode is changing now", - }, - status=HTTP_409_CONFLICT, - ) - if obj.maintenance_mode_attr == MaintenanceMode.OFF: if serializer.validated_data["maintenance_mode"] == MaintenanceMode.OFF: return Response( From 15284e985b013ca7749abf7a50852c9d74212a81 Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Mon, 8 Jul 2024 11:36:28 +0300 Subject: [PATCH 05/50] Bump version for next release --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8f07f82272..50c4136250 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ APP_IMAGE ?= hub.adsw.io/adcm/adcm APP_TAG ?= $(subst /,_,$(BRANCH_NAME)) SELENOID_HOST ?= 10.92.2.65 SELENOID_PORT ?= 4444 -ADCM_VERSION = "2.2.0" +ADCM_VERSION = "2.3.0-dev" PY_FILES = python dev/linters conf/adcm/python_scripts .PHONY: help From adff34de8c47670ec5e1204bbdaa8caf1a6a2be7 Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Mon, 8 Jul 2024 10:26:21 +0000 Subject: [PATCH 06/50] ADCM-5710 Add audit tests for run actions and terminate tasks and jobs --- .../tests/bundles/cluster_one/config.yaml | 1 + python/api_v2/tests/test_audit/test_task.py | 335 ++++++++++++++---- 2 files changed, 262 insertions(+), 74 deletions(-) diff --git a/python/api_v2/tests/bundles/cluster_one/config.yaml b/python/api_v2/tests/bundles/cluster_one/config.yaml index 574dcf9fe9..4f78bc5969 100644 --- a/python/api_v2/tests/bundles/cluster_one/config.yaml +++ b/python/api_v2/tests/bundles/cluster_one/config.yaml @@ -46,6 +46,7 @@ type: job script: ./playbook.yaml script_type: ansible + allow_to_terminate: true states: available: any diff --git a/python/api_v2/tests/test_audit/test_task.py b/python/api_v2/tests/test_audit/test_task.py index 2b5f72da55..8f670783b5 100644 --- a/python/api_v2/tests/test_audit/test_task.py +++ b/python/api_v2/tests/test_audit/test_task.py @@ -10,14 +10,21 @@ # See the License for the specific language governing permissions and # limitations under the License. -from datetime import timedelta from unittest.mock import patch -from cm.models import ADCM, Action, ActionType, JobLog, JobStatus, TaskLog -from django.contrib.contenttypes.models import ContentType -from django.utils import timezone -from rest_framework.reverse import reverse -from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND +from cm.models import ( + Action, + Cluster, + ClusterObject, + JobLog, + JobStatus, + ObjectType, + Prototype, + ServiceComponent, + TaskLog, +) +from cm.tests.mocks.task_runner import ExecutionTargetFactoryDummyMock, FailedJobInfo, RunTaskMock +from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT from api_v2.tests.base import BaseAPITestCase @@ -28,124 +35,304 @@ def setUp(self) -> None: self.test_user_credentials = {"username": "test_user_username", "password": "test_user_password"} self.test_user = self.create_user(**self.test_user_credentials) + self.cluster_action = Action.objects.get(prototype=self.cluster_1.prototype, name="action") + self.service = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1)[0] + self.service_action = Action.objects.get(prototype=self.service.prototype, name="action") + host = self.add_host(provider=self.provider, fqdn="host-1", cluster=self.cluster_1) + component_prototype = Prototype.objects.get( + bundle=self.bundle_1, type=ObjectType.COMPONENT, name="component_1", parent=self.service.prototype + ) + self.component = ServiceComponent.objects.get( + cluster=self.cluster_1, service=self.service, prototype=component_prototype + ) + self.set_hostcomponent(cluster=self.cluster_1, entries=[(host, self.component)]) + self.component_action = Action.objects.get(prototype=self.component.prototype, name="action_1_comp_1") + + def simulate_finished_task( + self, object_: Cluster | ClusterObject | ServiceComponent, action: Action + ) -> (TaskLog, JobLog): + with RunTaskMock() as run_task: + (self.client.v2[object_] / "actions" / action / "run").post( + data={"configuration": None, "isVerbose": True, "hostComponentMap": []} + ) + + run_task.run() + run_task.target_task.refresh_from_db() + + return run_task.target_task, run_task.target_task.joblog_set.last() - self.adcm = ADCM.objects.first() - self.action = Action.objects.create( - display_name="test_adcm_action", - prototype=self.adcm.prototype, - type=ActionType.JOB, - state_available="any", + def simulate_running_task( + self, object_: Cluster | ClusterObject | ServiceComponent, action: Action + ) -> (TaskLog, JobLog): + with RunTaskMock() as run_task: + (self.client.v2[object_] / "actions" / action / "run").post( + data={"configuration": None, "isVerbose": True, "hostComponentMap": []} + ) + + run_task.run() + run_task.target_task.refresh_from_db() + task = run_task.target_task + job = task.joblog_set.last() + task.status = JobStatus.RUNNING + task.save(update_fields=["status"]) + job.status = JobStatus.RUNNING + job.pid = 5_000_000 + job.save(update_fields=["status", "pid"]) + + return task, job + + def test_run_action_success(self): + with RunTaskMock() as run_task: + response = (self.client.v2[self.cluster_1] / "actions" / self.cluster_action / "run").post( + data={"configuration": None, "isVerbose": True, "hostComponentMap": []} + ) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.check_last_audit_record( + operation_name=f"{self.cluster_action.display_name} action launched", + operation_type="update", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=self.cluster_1), + user__username="admin", ) - self.task_for_job = TaskLog.objects.create( - object_id=self.adcm.pk, - object_type=ContentType.objects.get(app_label="cm", model="adcm"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.action, + + run_task.run() + + self.check_last_audit_record( + operation_name=f"{self.cluster_action.display_name} action completed", + operation_type="update", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=self.cluster_1), + user__username=None, ) - self.job = JobLog.objects.create( - status=JobStatus.RUNNING, - start_date=timezone.now() + timedelta(days=1), - finish_date=timezone.now() + timedelta(days=2), - task=self.task_for_job, - pid=9999, - allow_to_terminate=True, + + def test_run_action_fail(self): + with RunTaskMock( + execution_target_factory=ExecutionTargetFactoryDummyMock( + failed_job=FailedJobInfo(position=0, return_code=1) + ) + ) as run_task: + response = (self.client.v2[self.service] / "actions" / self.service_action / "run").post( + data={"configuration": None, "isVerbose": True, "hostComponentMap": []} + ) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.check_last_audit_record( + operation_name=f"{self.service_action.display_name} action launched", + operation_type="update", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=self.service), + user__username="admin", ) - self.task = TaskLog.objects.create( - object_id=self.adcm.pk, - object_type=ContentType.objects.get(app_label="cm", model="adcm"), - start_date=timezone.now(), - finish_date=timezone.now(), + + run_task.run() + + self.check_last_audit_record( + operation_name=f"{self.service_action.display_name} action completed", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=self.service), + user__username=None, ) - def test_job_terminate_success(self): - with patch("cm.models.os.kill"): - response = self.client.post( - path=reverse(viewname="v2:joblog-terminate", kwargs={"pk": self.job.pk}), data={} + def test_run_not_exists_action_fail(self): + with RunTaskMock() as run_task: + response = (self.client.v2[self.component] / "actions" / self.get_non_existent_pk(Action) / "run").post( + data={"configuration": None, "isVerbose": True, "hostComponentMap": []} ) - self.assertEqual(response.status_code, HTTP_200_OK) - self.check_last_audit_record( - operation_name="Job terminated", - operation_type="update", - operation_result="success", - audit_object__object_id=1, - audit_object__object_name="ADCM", - audit_object__object_type="adcm", - audit_object__is_deleted=False, - object_changes={}, - user__username="admin", + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + self.assertIsNone(run_task.target_task) + self.check_last_audit_record( + operation_name="action launched", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=self.component), + user__username="admin", + ) + + def test_run_action_denied(self): + self.client.login(**self.test_user_credentials) + with RunTaskMock() as run_task: + response = (self.client.v2[self.cluster_1] / "actions" / self.cluster_action / "run").post( + data={"configuration": None, "isVerbose": True, "hostComponentMap": []} ) - def test_job_terminate_not_found_fail(self): + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + self.assertIsNone(run_task.target_task) + self.check_last_audit_record( + operation_name=f"{self.cluster_action.display_name} action launched", + operation_type="update", + operation_result="denied", + **self.prepare_audit_object_arguments(expected_object=self.cluster_1), + user__username="test_user_username", + ) + + def test_terminate_job_success(self): + task, job = self.simulate_running_task(object_=self.cluster_1, action=self.cluster_action) + + with patch("cm.models.os.kill"): + response = self.client.v2[job, "terminate"].post(data={}) + self.assertEqual(response.status_code, HTTP_200_OK) + + self.check_last_audit_record( + operation_name=f"{self.cluster_action.display_name} terminated", + operation_type="update", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=self.cluster_1), + user__username="admin", + ) + + def test_terminate_job_not_found_fail(self): + self.simulate_running_task(object_=self.service, action=self.service_action) + with patch("cm.models.os.kill"): - response = self.client.post(path=reverse(viewname="v2:joblog-terminate", kwargs={"pk": 100}), data={}) + response = (self.client.v2 / "jobs" / self.get_non_existent_pk(JobLog) / "terminate").post(data={}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( operation_name="Job terminated", operation_type="update", operation_result="fail", - object_changes={}, + **self.prepare_audit_object_arguments(expected_object=None), user__username="admin", ) - def test_job_terminate_denied(self): + def test_terminate_job_denied(self): + # TODO: This test discovered an issue with creating a new audit object, this needs to be fixed + _, job = self.simulate_running_task(object_=self.component, action=self.component_action) self.client.login(**self.test_user_credentials) + with patch("cm.models.os.kill"): - response = self.client.post( - path=reverse(viewname="v2:joblog-terminate", kwargs={"pk": self.job.pk}), data={} - ) + response = self.client.v2[job, "terminate"].post(data={}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( - operation_name="Job terminated", + operation_name=f"{self.component_action.display_name} terminated", operation_type="update", operation_result="denied", - object_changes={}, - user__username=self.test_user.username, + audit_object__object_id=self.component.id, + audit_object__object_name="component_1", # TODO: should be "cluster_1/service_1/component_1" + audit_object__object_type="service component", # TODO: should be "component" + audit_object__is_deleted=False, + user__username="test_user_username", ) - def test_task_cancel_success(self): - with patch("cm.models.TaskLog.cancel"): - response = self.client.post(path=reverse(viewname="v2:tasklog-terminate", kwargs={"pk": self.task.pk})) + def test_terminate_task_success(self): + task, _ = self.simulate_running_task(object_=self.cluster_1, action=self.cluster_action) + + with patch("cm.models.os.kill"): + response = self.client.v2[task, "terminate"].post(data={}) self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( - operation_name="Task cancelled", + operation_name=f"{self.cluster_action.display_name} cancelled", operation_type="update", operation_result="success", - audit_object__object_id=1, - audit_object__object_name="ADCM", - audit_object__object_type="adcm", - audit_object__is_deleted=False, - object_changes={}, + **self.prepare_audit_object_arguments(expected_object=self.cluster_1), user__username="admin", ) - def test_task_cancel_not_found_fail(self): - with patch("cm.models.TaskLog.cancel"): - response = self.client.post(path=reverse(viewname="v2:tasklog-terminate", kwargs={"pk": 1000})) + def test_terminate_task_not_found_fail(self): + self.simulate_running_task(object_=self.service, action=self.service_action) + + with patch("cm.models.os.kill"): + response = (self.client.v2 / "tasks" / self.get_non_existent_pk(TaskLog) / "terminate").post(data={}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( operation_name="Task cancelled", operation_type="update", operation_result="fail", - object_changes={}, + audit_object__isnull=True, user__username="admin", ) - def test_task_cancel_denied(self): + def test_terminate_task_denied(self): + # TODO: This test discovered an issue with creating a new audit object, this needs to be fixed + task, _ = self.simulate_running_task(object_=self.component, action=self.component_action) self.client.login(**self.test_user_credentials) - with patch("cm.models.TaskLog.cancel"): - response = self.client.post(path=reverse(viewname="v2:tasklog-terminate", kwargs={"pk": self.task.pk})) + + with patch("cm.models.os.kill"): + response = self.client.v2[task, "terminate"].post(data={}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( - operation_name="Task cancelled", + operation_name=f"{self.component_action.display_name} cancelled", operation_type="update", operation_result="denied", - object_changes={}, - user__username=self.test_user.username, + audit_object__object_id=self.component.id, + audit_object__object_name="component_1", # TODO: should be "cluster_1/service_1/component_1" + audit_object__object_type="service component", # TODO should be "component" + user__username="test_user_username", + ) + + def test_terminate_finished_job_fail(self): + task, job = self.simulate_finished_task(object_=self.cluster_1, action=self.cluster_action) + + with patch("cm.models.os.kill"): + response = self.client.v2[job, "terminate"].post(data={}) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + + self.check_last_audit_record( + operation_name=f"{self.cluster_action.display_name} terminated", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=self.cluster_1), + user__username="admin", + ) + + def test_terminate_finished_task_fail(self): + # TODO: This test discovered an issue with creating a new audit object, this needs to be fixed + task, _ = self.simulate_finished_task(object_=self.service, action=self.service_action) + + with patch("cm.models.os.kill"): + response = self.client.v2[task, "terminate"].post(data={}) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + + self.check_last_audit_record( + operation_name=f"{self.service_action.display_name} cancelled", + operation_type="update", + operation_result="fail", + audit_object__object_id=self.service.id, + audit_object__object_name="service_1", # TODO: should be "cluster_1/service_1" + audit_object__object_type="cluster object", # TODO: should be "service" + audit_object__is_deleted=False, + user__username="admin", + ) + + def test_terminate_finished_job_after_delete_object_fail(self): + _, job = self.simulate_finished_task(object_=self.component, action=self.component_action) + + response = self.client.v2[self.cluster_1].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + with patch("cm.models.os.kill"): + response = self.client.v2[job, "terminate"].post(data={}) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + + self.check_last_audit_record( + operation_name=f"{self.component_action.display_name} terminated", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=None), + user__username="admin", + ) + + def test_terminate_finished_task_after_delete_object_fail(self): + task, _ = self.simulate_finished_task(object_=self.component, action=self.component_action) + + response = self.client.v2[self.cluster_1].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + with patch("cm.models.os.kill"): + response = self.client.v2[task, "terminate"].post(data={}) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + + self.check_last_audit_record( + operation_name=f"{self.component_action.display_name} cancelled", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=None), + user__username="admin", ) From e537a7f2fd7fa60ce55cac645b8dccf6c0e58800 Mon Sep 17 00:00:00 2001 From: Dmitriy Bardin Date: Mon, 8 Jul 2024 12:31:40 +0000 Subject: [PATCH 07/50] ADCM-5729 - [UI] Rework Components tab https://tracker.yandex.ru/ADCM-5729 --- .../ExpandDetailsCell.module.scss | 6 +++ .../ExpandDetailsCell/ExpandDetailsCell.tsx | 21 +++++++++++ .../ServiceComponentsTable.tsx | 32 ++++++++++++---- ...ComponentsTableExpandedContent.module.scss | 34 +++++++++++++++++ .../ServiceComponentsTableExpandedContent.tsx | 37 +++++++++++++++++++ 5 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 adcm-web/app/src/components/common/ExpandDetailsCell/ExpandDetailsCell.module.scss create mode 100644 adcm-web/app/src/components/common/ExpandDetailsCell/ExpandDetailsCell.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent.module.scss create mode 100644 adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent.tsx diff --git a/adcm-web/app/src/components/common/ExpandDetailsCell/ExpandDetailsCell.module.scss b/adcm-web/app/src/components/common/ExpandDetailsCell/ExpandDetailsCell.module.scss new file mode 100644 index 0000000000..453cc47ecb --- /dev/null +++ b/adcm-web/app/src/components/common/ExpandDetailsCell/ExpandDetailsCell.module.scss @@ -0,0 +1,6 @@ +.expandDetailsCell { + display: flex; + align-items: center; + width: 40%; + justify-content: space-between; +} diff --git a/adcm-web/app/src/components/common/ExpandDetailsCell/ExpandDetailsCell.tsx b/adcm-web/app/src/components/common/ExpandDetailsCell/ExpandDetailsCell.tsx new file mode 100644 index 0000000000..ec475eb860 --- /dev/null +++ b/adcm-web/app/src/components/common/ExpandDetailsCell/ExpandDetailsCell.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Button, TableCell } from '@uikit'; +import s from './ExpandDetailsCell.module.scss'; + +interface ExpandDetailsCellProps { + children: React.ReactNode; + handleExpandRow: () => void; +} + +const ExpandDetailsCell: React.FC = ({ handleExpandRow, children }) => { + return ( + +
+ {children} +
+
+ ); +}; + +export default ExpandDetailsCell; diff --git a/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTable.tsx b/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTable.tsx index 86336a166c..73fe348847 100644 --- a/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTable.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTable.tsx @@ -1,17 +1,20 @@ +import React, { useState } from 'react'; import { Link } from 'react-router-dom'; -import { Table, TableRow, TableCell } from '@uikit'; +import { Table, TableCell, ExpandableRowComponent } from '@uikit'; import Concern from '@commonComponents/Concern/Concern'; import StatusableCell from '@commonComponents/Table/Cells/StatusableCell'; import { useDispatch, useStore } from '@hooks'; import { columns, serviceComponentsStatusMap } from './ServiceComponentsTable.constants'; import { setSortParams } from '@store/adcm/cluster/services/serviceComponents/serviceComponentsTableSlice'; -import { SortParams } from '@uikit/types/list.types'; +import type { SortParams } from '@uikit/types/list.types'; import MaintenanceModeButton from '@commonComponents/MaintenanceModeButton/MaintenanceModeButton'; import { openMaintenanceModeDialog } from '@store/adcm/cluster/services/serviceComponents/serviceComponentsActionsSlice'; -import { AdcmServiceComponent } from '@models/adcm'; +import type { AdcmServiceComponent } from '@models/adcm'; import ClusterServiceComponentsDynamicActionsIcon from '../ServiceComponentsDynamicActionsIcon/ServiceComponentsDynamicActionsIcon'; import { usePersistServiceComponentsTableSettings } from '../usePersistServiceComponentsTableSettings'; import { isShowSpinner } from '@uikit/Table/Table.utils'; +import ServiceComponentsTableExpandedContent from './ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent'; +import ExpandDetailsCell from '@commonComponents/ExpandDetailsCell/ExpandDetailsCell.tsx'; const ServiceComponentsTable = () => { const dispatch = useDispatch(); @@ -19,6 +22,8 @@ const ServiceComponentsTable = () => { const isLoading = useStore((s) => isShowSpinner(s.adcm.serviceComponents.loadState)); const sortParams = useStore((s) => s.adcm.serviceComponentsTable.sortParams); + const [expandableRows, setExpandableRows] = useState>({}); + usePersistServiceComponentsTableSettings(); const handleSorting = (sortParams: SortParams) => { @@ -31,6 +36,13 @@ const ServiceComponentsTable = () => { } }; + const handleExpandClick = (id: number) => { + setExpandableRows({ + ...expandableRows, + [id]: expandableRows[id] === undefined ? true : !expandableRows[id], + }); + }; + return ( { > {components.map((component) => { return ( - + } + > { {component.displayName} - + handleExpandClick(component.id)}> {component.hosts.length} {component.hosts.length === 1 ? 'host' : 'hosts'} - + @@ -67,7 +85,7 @@ const ServiceComponentsTable = () => { onClick={handleClickMaintenanceMode(component)} /> - + ); })}
diff --git a/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent.module.scss b/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent.module.scss new file mode 100644 index 0000000000..d6e464ba51 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent.module.scss @@ -0,0 +1,34 @@ +:global { + body.theme-dark { + --service-components-expanded-block-title-color: var(--color-xgreen-saturated); + } + + body.theme-light { + --service-components-expanded-block-title-color: var(--color-xgreen); + } +} + +.serviceComponentsTableExpandedContent { + margin-top: 20px; + + &__title { + color: var(--service-components-expanded-block-title-color); + margin: 16px 0; + font-weight: 500; + } + + &__checkboxes label { + margin-right: 30px; + } + + &__tags { + flex-direction: row; + width: fit-content; + margin: 16px 16px 0 0; + + div { + width: fit-content; + padding: 6px 12px; + } + } +} diff --git a/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent.tsx b/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent.tsx new file mode 100644 index 0000000000..3972511e05 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/service/ServiceComponents/ServiceComponentsTable/ServiceComponentsTableExpandedContent/ServiceComponentsTableExpandedContent.tsx @@ -0,0 +1,37 @@ +import React, { useMemo, useState } from 'react'; +import { SearchInput, Tag, Tags } from '@uikit'; +import s from './ServiceComponentsTableExpandedContent.module.scss'; +import type { AdcmServiceComponentHost } from '@models/adcm'; + +export interface ServiceComponentsTableExpandedContentProps { + children: AdcmServiceComponentHost[]; +} + +const ServiceComponentsTableExpandedContent = ({ children }: ServiceComponentsTableExpandedContentProps) => { + const [textEntered, setTextEntered] = useState(''); + + const childrenFiltered = useMemo(() => { + return children.filter((child) => child.name.toLowerCase().includes(textEntered.toLowerCase())); + }, [children, textEntered]); + + if (!children.length) return null; + + const handleHostsNameFilter = (event: React.ChangeEvent) => { + setTextEntered(event.target.value); + }; + + return ( +
+ + {childrenFiltered.length > 0 && ( + + {childrenFiltered.map((child) => ( + + ))} + + )} +
+ ); +}; + +export default ServiceComponentsTableExpandedContent; From 6d086ec1bc88ce3768682269a544a9980c484ada Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Wed, 10 Jul 2024 11:56:45 +0000 Subject: [PATCH 08/50] ADCM-5751: Review tests in /api_v2/tests/test_audit --- python/adcm/tests/client.py | 2 + .../tests/test_audit/test_authorization.py | 32 +- python/api_v2/tests/test_audit/test_bundle.py | 37 +- .../api_v2/tests/test_audit/test_cluster.py | 362 +++------ python/api_v2/tests/test_audit/test_group.py | 44 +- .../tests/test_audit/test_group_config.py | 729 ++++-------------- python/api_v2/tests/test_audit/test_host.py | 149 +--- .../tests/test_audit/test_host_provider.py | 110 +-- python/api_v2/tests/test_audit/test_login.py | 3 +- .../tests/test_audit/test_object_renaming.py | 26 +- python/api_v2/tests/test_audit/test_policy.py | 38 +- python/api_v2/tests/test_audit/test_role.py | 40 +- python/api_v2/tests/test_audit/test_user.py | 63 +- python/api_v2/tests/test_user.py | 14 - 14 files changed, 405 insertions(+), 1244 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index dac33026c6..c12fd41a56 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -113,6 +113,8 @@ class V2RootNode(RootNode): "profile": "profile", "adcm": "adcm", "schema": "schema", + "token": "token", + "audit-login": "audit/logins", } def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, ...]) -> APINode: diff --git a/python/api_v2/tests/test_audit/test_authorization.py b/python/api_v2/tests/test_audit/test_authorization.py index f6a0d24fb8..79feb663ca 100644 --- a/python/api_v2/tests/test_audit/test_authorization.py +++ b/python/api_v2/tests/test_audit/test_authorization.py @@ -14,7 +14,6 @@ from audit.models import AuditSession from rbac.models import User -from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND from api_v2.tests.base import BaseAPITestCase @@ -33,60 +32,51 @@ def setUp(self) -> None: self.time_to = (current_datetime + timedelta(minutes=1)).isoformat() def login_for_audit(self, username="admin", password="admin"): - response = self.client.post( - path=reverse(viewname="v1:rbac:token"), + response = self.client.v2["token"].post( data={"username": username, "password": password}, ) self.client.defaults["Authorization"] = f"Token {response.data['token']}" def test_logins_success(self): - response = self.client.get( - path=reverse(viewname="v2:audit:auditsession-list"), - ) + response = self.client.v2["audit-login"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["results"][0]["user"], {"name": self.username}) self.assertDictEqual(response.json()["results"][0]["details"], {"username": self.username}) def test_logins_time_filtering_success(self): - response = self.client.get( - path=reverse(viewname="v2:audit:auditsession-list"), - data={"time_to": self.time_to, "time_from": self.time_from}, + response = self.client.v2["audit-login"].get( + query={"time_from": self.time_from, "time_to": self.time_to}, ) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["results"][0]["user"], {"name": self.username}) def test_logins_time_filtering_empty_list_success(self): - response = self.client.get( - path=reverse(viewname="v2:audit:auditsession-list"), - data={"timeTo": self.time_from, "timeFrom": self.time_to}, + response = self.client.v2["audit-login"].get( + query={"time_from": self.time_to, "time_to": self.time_from}, ) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["results"]), 0) def test_logins_retrieve_success(self): - response = self.client.get( - path=reverse(viewname="v2:audit:auditsession-detail", kwargs={"pk": self.last_login_id}) - ) + response = self.client.v2["audit-login", self.last_login_id].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["user"]["name"], self.username) self.assertDictEqual(response.json()["details"], {"username": self.username}) def test_logins_retrieve_not_found_fail(self): - response = self.client.get( - path=reverse(viewname="v2:audit:auditsession-detail", kwargs={"pk": self.last_login_id + 1}) - ) + response = self.client.v2["audit-login", self.last_login_id + 1].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_logins_not_authorized_fail(self): self.client.logout() - response = self.client.get(path=reverse(viewname="v2:audit:auditsession-list")) + response = self.client.v2["audit-login"].get() self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_operations_not_authorized_fail(self): self.client.logout() - response = self.client.get(path=reverse(viewname="v2:audit:auditlog-list")) + response = self.client.v2["audit-login"].get() self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_operations_list_success(self): - response = self.client.get(path=reverse(viewname="v2:audit:auditlog-list")) + response = self.client.v2["audit-login"].get() self.assertEqual(response.status_code, HTTP_200_OK) diff --git a/python/api_v2/tests/test_audit/test_bundle.py b/python/api_v2/tests/test_audit/test_bundle.py index 87c3f63370..624fa280a0 100644 --- a/python/api_v2/tests/test_audit/test_bundle.py +++ b/python/api_v2/tests/test_audit/test_bundle.py @@ -14,7 +14,6 @@ from audit.models import AuditLogOperationType, AuditObject from cm.models import Bundle, Prototype from django.conf import settings -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -37,11 +36,7 @@ def test_audit_upload_success(self): new_bundle_file = self.prepare_bundle_file(source_dir=self.test_bundles_dir / "cluster_one") with open(settings.DOWNLOAD_DIR / new_bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(response.status_code, HTTP_201_CREATED) self.check_last_audit_record( @@ -57,11 +52,7 @@ def test_audit_upload_fail(self): new_bundle_file = self.prepare_bundle_file(source_dir=self.test_bundles_dir / "cluster_one") with open(settings.DOWNLOAD_DIR / new_bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.check_last_audit_record( @@ -77,11 +68,7 @@ def test_audit_upload_denied(self): self.client.login(**self.test_user_credentials) with open(settings.DOWNLOAD_DIR / new_bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -105,7 +92,7 @@ def test_audit_delete_success(self): is_deleted=False, ) - response = self.client.delete(path=reverse(viewname="v2:bundle-detail", kwargs={"pk": bundle.pk})) + response = self.client.v2[bundle].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -117,9 +104,7 @@ def test_audit_delete_success(self): ) def test_audit_delete_non_existent_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:bundle-detail", kwargs={"pk": self.get_non_existent_pk(Bundle)}) - ) + response = (self.client.v2 / "bundles" / self.get_non_existent_pk(Bundle)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -134,7 +119,7 @@ def test_audit_delete_denied(self): bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_one") self.client.login(**self.test_user_credentials) - response = self.client.delete(path=reverse(viewname="v2:bundle-detail", kwargs={"pk": bundle.pk})) + response = self.client.v2[bundle].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -149,9 +134,7 @@ def test_audit_accept_license_success(self): bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_one") bundle_prototype = Prototype.objects.get(bundle=bundle, type="cluster") - response = self.client.post( - path=reverse(viewname="v2:prototype-accept-license", kwargs={"pk": bundle_prototype.pk}) - ) + response = self.client.v2[bundle_prototype, "license", "accept"].post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -167,9 +150,7 @@ def test_audit_accept_license_denied(self): bundle_prototype = Prototype.objects.get(bundle=bundle, type="cluster") self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:prototype-accept-license", kwargs={"pk": bundle_prototype.pk}) - ) + response = self.client.v2[bundle_prototype, "license", "accept"].post() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -181,7 +162,7 @@ def test_audit_accept_license_denied(self): ) def test_audit_accept_license_fail(self): - response = self.client.post(path=reverse(viewname="v2:prototype-accept-license", kwargs={"pk": 1000})) + response = (self.client.v2 / "prototypes" / self.get_non_existent_pk(Bundle) / "license" / "accept").post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( diff --git a/python/api_v2/tests/test_audit/test_cluster.py b/python/api_v2/tests/test_audit/test_cluster.py index 10263d84f8..a7082521fa 100644 --- a/python/api_v2/tests/test_audit/test_cluster.py +++ b/python/api_v2/tests/test_audit/test_cluster.py @@ -23,7 +23,6 @@ Upgrade, ) from django.contrib.contenttypes.models import ContentType -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -88,8 +87,7 @@ def setUp(self) -> None: self.cluster_upgrade = Upgrade.objects.get(bundle=self.upgrade_bundle, name="upgrade_via_action_simple") def test_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-list"), + response = (self.client.v2 / "clusters").post( data={"prototypeId": self.prototype.pk, "name": "audit_test_cluster"}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -107,8 +105,7 @@ def test_create_success(self): def test_create_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:cluster-list"), + response = (self.client.v2 / "clusters").post( data={"prototypeId": self.prototype.pk, "name": "audit_test_cluster"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -122,8 +119,7 @@ def test_create_denied(self): ) def test_create_fail(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-list"), + response = (self.client.v2 / "clusters").post( data={"prototypeId": self.prototype.pk, "name": self.cluster_1.name}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -138,8 +134,7 @@ def test_create_fail(self): def test_edit_success(self): old_name = self.cluster_1.name - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1].patch( data={"name": "new_cluster_name"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -158,8 +153,7 @@ def test_edit_success(self): def test_edit_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1].patch( data={"name": "new_cluster_name"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -173,8 +167,7 @@ def test_edit_denied(self): ) def test_edit_incorrect_body_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1].patch( data={"name": "new , cluster_name"}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -188,8 +181,7 @@ def test_edit_incorrect_body_fail(self): ) def test_edit_not_found_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.get_non_existent_pk(model=Cluster)}), + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster)).patch( data={"name": "new_cluster_name"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -213,9 +205,7 @@ def test_delete_success(self): is_deleted=False, ) - response = self.client.delete( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -229,9 +219,7 @@ def test_delete_success(self): def test_delete_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -243,9 +231,7 @@ def test_delete_denied(self): ) def test_delete_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.get_non_existent_pk(model=Cluster)}), - ) + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -257,8 +243,7 @@ def test_delete_fail(self): ) def test_create_mapping_success(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "mapping"].post( data=[{"hostId": self.host_1.pk, "componentId": self.component_1.pk}], ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -275,8 +260,7 @@ def test_create_mapping_only_view_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View host-components"): - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "mapping"].post( data=[{"hostId": self.host_1.pk, "componentId": self.component_1.pk}], ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -292,8 +276,7 @@ def test_create_mapping_only_view_denied(self): def test_create_mapping_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "mapping"].post( data=[{"hostId": self.host_1.pk, "componentId": self.component_1.pk}], ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -307,8 +290,7 @@ def test_create_mapping_denied(self): ) def test_create_mapping_incorrect_body_fail(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "mapping"].post( data=[{"host_id": 4}], ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -322,8 +304,7 @@ def test_create_mapping_incorrect_body_fail(self): ) def test_create_mapping_not_found_fail(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.get_non_existent_pk(model=Cluster)}), + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "mapping").post( data=[{"hostId": self.host_1.pk, "componentId": self.component_1.pk}], ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -337,8 +318,7 @@ def test_create_mapping_not_found_fail(self): ) def test_create_import_success(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), + response = self.client.v2[self.import_cluster, "imports"].post( data=[{"source": {"id": self.cluster_1.pk, "type": ObjectType.CLUSTER}}], ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -354,8 +334,7 @@ def test_create_import_success(self): def test_create_import_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), + response = self.client.v2[self.import_cluster, "imports"].post( data=[{"source": {"id": self.cluster_1.pk, "type": ObjectType.CLUSTER}}], ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -369,8 +348,7 @@ def test_create_import_denied(self): ) def test_create_import_incorrect_body_fail(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), + response = self.client.v2[self.import_cluster, "imports"].post( data=[{}], ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -384,10 +362,7 @@ def test_create_import_incorrect_body_fail(self): ) def test_create_import_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.get_non_existent_pk(model=Cluster)} - ), + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "imports").post( data=[{"source": {"id": self.cluster_1.pk, "type": ObjectType.CLUSTER}}], ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -401,8 +376,7 @@ def test_create_import_fail(self): ) def test_update_config_success(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "configs"].post( data=self.cluster_1_config_post_data, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -419,8 +393,7 @@ def test_update_config_view_only_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View cluster configurations"): - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "configs"].post( data=self.cluster_1_config_post_data, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -436,8 +409,7 @@ def test_update_config_view_only_denied(self): def test_update_config_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "configs"].post( data=self.cluster_1_config_post_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -451,8 +423,7 @@ def test_update_config_no_perms_denied(self): ) def test_update_config_incorrect_body_fail(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "configs"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -466,10 +437,7 @@ def test_update_config_incorrect_body_fail(self): ) def test_update_config_not_found_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.get_non_existent_pk(model=Cluster)} - ), + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "configs").post( data=self.cluster_1_config_post_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -483,11 +451,7 @@ def test_update_config_not_found_fail(self): ) def test_delete_host_success(self): - response = self.client.delete( - path=reverse( - viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} - ), - ) + response = (self.client.v2[self.cluster_1] / "hosts" / self.host_1).delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -502,11 +466,7 @@ def test_delete_host_success(self): def test_delete_host_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse( - viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} - ), - ) + response = (self.client.v2[self.cluster_1] / "hosts" / self.host_1).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -518,12 +478,7 @@ def test_delete_host_denied(self): ) def test_delete_host_fail(self): - response = self.client.delete( - path=reverse( - viewname="v2:host-cluster-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.get_non_existent_pk(model=Host)}, - ), - ) + response = (self.client.v2[self.cluster_1] / "hosts" / self.get_non_existent_pk(model=Host)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -537,8 +492,7 @@ def test_delete_host_fail(self): # add single host def test_add_host_success(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2[self.cluster_1] / "hosts").post( data={"hostId": self.host_2.pk}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -554,8 +508,7 @@ def test_add_host_success(self): def test_add_host_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2[self.cluster_1] / "hosts").post( data={"hostId": self.host_2.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -569,8 +522,7 @@ def test_add_host_no_perms_denied(self): ) def test_add_host_incorrect_body_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2[self.cluster_1] / "hosts").post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -584,8 +536,7 @@ def test_add_host_incorrect_body_fail(self): ) def test_add_non_existing_host_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2[self.cluster_1] / "hosts").post( data={"hostId": self.get_non_existent_pk(Host)}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -599,10 +550,7 @@ def test_add_non_existing_host_fail(self): ) def test_add_host_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.get_non_existent_pk(model=Cluster)} - ), + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "hosts").post( data={"hostId": self.host_2.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -616,8 +564,7 @@ def test_add_host_fail(self): ) def test_add_host_wrong_data_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2 / "clusters" / self.cluster_1.pk / "hosts").post( data=[{"totally": "wrong"}, "request data"], ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -633,8 +580,7 @@ def test_add_host_wrong_data_fail(self): # add multiple hosts def test_add_many_hosts_success(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2 / "clusters" / self.cluster_1.pk / "hosts").post( data=[{"hostId": self.host_2.pk}, {"hostId": self.host_3.pk}], ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -650,8 +596,7 @@ def test_add_many_hosts_success(self): def test_add_many_hosts_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2 / "clusters" / self.cluster_1.pk / "hosts").post( data=[{"hostId": self.host_2.pk}, {"hostId": self.host_3.pk}], ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -665,8 +610,7 @@ def test_add_many_hosts_no_perms_denied(self): ) def test_add_many_hosts_incorrect_body_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2 / "clusters" / self.cluster_1.pk / "hosts").post( data=[{"hey": "you"}, {"hostId": self.host_2.pk}], ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -680,8 +624,7 @@ def test_add_many_hosts_incorrect_body_fail(self): ) def test_add_many_non_existing_host_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = (self.client.v2 / "clusters" / self.cluster_1.pk / "hosts").post( data=[{"hostId": self.get_non_existent_pk(Host)}], ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -695,11 +638,9 @@ def test_add_many_non_existing_host_fail(self): ) def test_change_host_mm_success(self): - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk}, - ), + response = ( + self.client.v2 / "clusters" / self.cluster_1.pk / "hosts" / self.host_1.pk / "maintenance-mode" + ).post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -716,11 +657,9 @@ def test_change_host_mm_success(self): def test_change_host_mm_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk}, - ), + response = ( + self.client.v2 / "clusters" / self.cluster_1.pk / "hosts" / self.host_1.pk / "maintenance-mode" + ).post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -738,11 +677,9 @@ def test_change_host_mm_fail(self): cluster_proto.allow_maintenance_mode = False cluster_proto.save(update_fields=["allow_maintenance_mode"]) - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk}, - ), + response = ( + self.client.v2 / "clusters" / self.cluster_1.pk / "hosts" / self.host_1.pk / "maintenance-mode" + ).post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -758,8 +695,7 @@ def test_change_host_mm_fail(self): # add multiple services def test_add_service_success(self): - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "services"].post( data=[ {"prototypeId": self.service_add_prototypes[0].pk}, {"prototypeId": self.service_add_prototypes[1].pk}, @@ -778,8 +714,7 @@ def test_add_service_success(self): ) def test_add_service_wrong_data_fail(self): - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "services"].post( data=[ {"id_of_prototype": self.service_add_prototypes[0].pk}, {"prototypeId": self.service_add_prototypes[1].pk}, @@ -798,8 +733,7 @@ def test_add_service_wrong_data_fail(self): def test_add_service_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "services"].post( data=[ {"prototypeId": self.service_add_prototypes[0].pk}, {"prototypeId": self.service_add_prototypes[1].pk}, @@ -818,8 +752,7 @@ def test_add_service_denied(self): ) def test_add_service_fail(self): - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.get_non_existent_pk(model=Cluster)}), + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "services").post( data=[ {"prototypeId": self.service_add_prototypes[0].pk}, {"prototypeId": self.service_add_prototypes[1].pk}, @@ -840,8 +773,7 @@ def test_add_service_fail(self): # add single service def test_add_one_service_success(self): - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "services"].post( data={"prototypeId": self.service_add_prototypes[0].pk}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -855,8 +787,7 @@ def test_add_one_service_success(self): ) def test_add_one_service_wrong_data_fail(self): - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "services"].post( data={"id_of_prototype": self.service_add_prototypes[0].pk}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -872,8 +803,7 @@ def test_add_one_service_wrong_data_fail(self): def test_add_one_service_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "services"].post( data={"prototypeId": self.service_add_prototypes[1].pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -887,8 +817,7 @@ def test_add_one_service_denied(self): ) def test_add_one_service_fail(self): - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.get_non_existent_pk(model=Cluster)}), + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "services").post( data={"prototypeId": self.service_add_prototypes[0].pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -902,11 +831,7 @@ def test_add_one_service_fail(self): ) def test_delete_service_success(self): - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk} - ), - ) + response = self.client.v2[self.service_1].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -920,11 +845,7 @@ def test_delete_service_success(self): def test_delete_service_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk} - ), - ) + response = self.client.v2[self.service_1].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -936,12 +857,7 @@ def test_delete_service_denied(self): ) def test_delete_non_existent_service_fail(self): - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.get_non_existent_pk(model=ClusterObject)}, - ), - ) + response = self.client.v2[self.cluster_1, "services", self.get_non_existent_pk(model=ServiceComponent)].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -953,12 +869,9 @@ def test_delete_non_existent_service_fail(self): ) def test_delete_service_from_non_existent_cluster_fail(self): - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", - kwargs={"cluster_pk": self.get_non_existent_pk(model=Cluster), "pk": self.service_1.pk}, - ), - ) + response = ( + self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "services" / self.service_1.pk + ).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -970,11 +883,7 @@ def test_delete_service_from_non_existent_cluster_fail(self): ) def test_run_cluster_action_success(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-action-run", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_action.pk} - ), - ) + response = (self.client.v2 / "clusters" / self.cluster_1.pk / "actions" / self.cluster_action.pk / "run").post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -989,12 +898,9 @@ def test_run_cluster_action_no_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View cluster configurations"): - response = self.client.post( - path=reverse( - viewname="v2:cluster-action-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_action.pk}, - ), - ) + response = ( + self.client.v2 / "clusters" / self.cluster_1.pk / "actions" / self.cluster_action.pk / "run" + ).post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -1006,14 +912,14 @@ def test_run_cluster_action_no_perms_denied(self): ) def test_run_cluster_action_incorrect_body_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "pk": Action.objects.get(name="with_config", prototype=self.cluster_1.prototype).pk, - }, - ), + response = ( + self.client.v2 + / "clusters" + / self.cluster_1.pk + / "actions" + / Action.objects.get(name="with_config", prototype=self.cluster_1.prototype).pk + / "run" + ).post( data={}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -1027,12 +933,9 @@ def test_run_cluster_action_incorrect_body_fail(self): ) def test_run_non_existent_cluster_action_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-action-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.get_non_existent_pk(model=Action)}, - ), - ) + response = ( + self.client.v2 / "clusters" / self.cluster_1.pk / "actions" / self.get_non_existent_pk(model=Action) / "run" + ).post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -1044,16 +947,16 @@ def test_run_non_existent_cluster_action_fail(self): ) def test_run_host_action_success(self): - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "host_pk": self.host_1.pk, - "pk": self.host_action.pk, - }, - ), - ) + response = ( + self.client.v2 + / "clusters" + / self.cluster_1.pk + / "hosts" + / self.host_1.pk + / "actions" + / self.host_action.pk + / "run" + ).post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -1070,16 +973,16 @@ def test_run_host_action_denied(self): with self.grant_permissions( to=self.test_user, on=self.host_1, role_name="View host configurations" ), self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View cluster configurations"): - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "host_pk": self.host_1.pk, - "pk": self.host_action.pk, - }, - ), - ) + response = ( + self.client.v2 + / "clusters" + / self.cluster_1.pk + / "hosts" + / self.host_1.pk + / "actions" + / self.host_action.pk + / "run" + ).post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1092,16 +995,16 @@ def test_run_host_action_denied(self): ) def test_run_host_action_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "host_pk": self.host_1.pk, - "pk": self.get_non_existent_pk(model=Action), - }, - ), - ) + response = ( + self.client.v2 + / "clusters" + / self.cluster_1.pk + / "hosts" + / self.host_1.pk + / "actions" + / self.get_non_existent_pk(model=Action) + / "run" + ).post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -1113,15 +1016,7 @@ def test_run_host_action_fail(self): ) def test_upgrade_cluster_success(self): - response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "pk": self.cluster_upgrade.pk, - }, - ), - ) + response = self.client.v2[self.cluster_1, "upgrades", self.cluster_upgrade, "run"].post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -1136,15 +1031,7 @@ def test_upgrade_cluster_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View cluster configurations"): - response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "pk": self.cluster_upgrade.pk, - }, - ), - ) + response = self.client.v2[self.cluster_1, "upgrades", self.cluster_upgrade, "run"].post() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -1156,16 +1043,12 @@ def test_upgrade_cluster_denied(self): ) def test_upgrade_cluster_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "pk": Upgrade.objects.get(bundle=self.upgrade_bundle, name="upgrade_via_action_complex").pk, - }, - ), - data={}, - ) + response = self.client.v2[ + self.cluster_1, + "upgrades", + Upgrade.objects.get(bundle=self.upgrade_bundle, name="upgrade_via_action_complex").pk, + "run", + ].post() self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.check_last_audit_record( @@ -1177,15 +1060,7 @@ def test_upgrade_cluster_incorrect_data_fail(self): ) def test_upgrade_cluster_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "pk": self.get_non_existent_pk(model=Upgrade), - }, - ), - ) + response = self.client.v2[self.cluster_1, "upgrades", self.get_non_existent_pk(model=Upgrade), "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -1197,8 +1072,7 @@ def test_upgrade_cluster_fail(self): ) def test_cluster_object_changes_one_field_success(self): - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1].patch( data={"name": "new_cluster_name"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -1214,8 +1088,7 @@ def test_cluster_object_changes_one_field_success(self): def test_cluster_object_changes_name_on_installed_fail(self): self.cluster_1.set_state("installed") - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1].patch( data={"name": "new_cluster_name"}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -1229,8 +1102,7 @@ def test_cluster_object_changes_name_on_installed_fail(self): ) def test_cluster_object_changes_all_fields_success(self): - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1].patch( data={"name": "new_cluster_name", "description": "new description"}, ) self.assertEqual(response.status_code, HTTP_200_OK) diff --git a/python/api_v2/tests/test_audit/test_group.py b/python/api_v2/tests/test_audit/test_group.py index 033cf597c7..3c6671bbee 100644 --- a/python/api_v2/tests/test_audit/test_group.py +++ b/python/api_v2/tests/test_audit/test_group.py @@ -15,7 +15,6 @@ from django.utils import timezone from rbac.models import Group from rbac.services.group import create as create_group -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -47,8 +46,7 @@ def setUp(self) -> None: self.group = create_group(name_to_display="Some group") def test_group_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:group-list"), + response = (self.client.v2 / "rbac" / "groups").post( data={"displayName": "New test group"}, ) @@ -64,8 +62,7 @@ def test_group_create_success(self): def test_group_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:rbac:group-list"), + response = (self.client.v2 / "rbac" / "groups").post( data={"displayName": "New test group"}, ) @@ -79,8 +76,7 @@ def test_group_create_no_perms_denied(self): ) def test_group_create_wrong_data_fail(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:group-list"), + response = (self.client.v2 / "rbac" / "groups").post( data={"description": "dscr"}, ) @@ -103,8 +99,7 @@ def test_group_update_success(self): "previous": {"description": "", "name": "Some group", "user": []}, } - response = self.client.patch( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group.pk}), + response = self.client.v2[self.group].patch( data=self.group_update_data, ) @@ -130,8 +125,7 @@ def test_group_update_one_field_success(self): "previous": {"name": "Some group"}, } - response = self.client.patch( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group.pk}), + response = self.client.v2[self.group].patch( data={"displayName": "new display name"}, ) @@ -152,8 +146,7 @@ def test_group_update_one_field_success(self): def test_group_update_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group.pk}), + response = self.client.v2[self.group].patch( data=self.group_update_data, ) @@ -170,8 +163,7 @@ def test_group_update_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View group"): - response = self.client.patch( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group.pk}), + response = self.client.v2[self.group].patch( data=self.group_update_data, ) @@ -185,8 +177,7 @@ def test_group_update_view_perms_denied(self): ) def test_group_update_incorrect_data_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group.pk}), + response = self.client.v2[self.group].patch( data={"displayName": []}, ) @@ -200,8 +191,7 @@ def test_group_update_incorrect_data_fail(self): ) def test_group_update_not_exists_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.get_non_existent_pk(model=Group)}), + response = (self.client.v2 / "rbac" / "groups" / self.get_non_existent_pk(model=Group)).patch( data=self.group_update_data, ) @@ -226,9 +216,7 @@ def test_group_delete_success(self): is_deleted=False, ) - response = self.client.delete( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group.pk}), - ) + response = self.client.v2[self.group].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -242,9 +230,7 @@ def test_group_delete_success(self): def test_group_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group.pk}), - ) + response = self.client.v2[self.group].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -259,9 +245,7 @@ def test_group_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View group"): - response = self.client.delete( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group.pk}), - ) + response = self.client.v2[self.group].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -273,9 +257,7 @@ def test_group_delete_view_perms_denied(self): ) def test_group_delete_not_exists_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.get_non_existent_pk(model=Group)}), - ) + response = (self.client.v2 / "rbac" / "groups" / self.get_non_existent_pk(model=Group)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( diff --git a/python/api_v2/tests/test_audit/test_group_config.py b/python/api_v2/tests/test_audit/test_group_config.py index cbe8bf40ba..bae63f302d 100644 --- a/python/api_v2/tests/test_audit/test_group_config.py +++ b/python/api_v2/tests/test_audit/test_group_config.py @@ -10,9 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from cm.models import GroupConfig, ServiceComponent +from cm.models import Cluster, ClusterObject, GroupConfig, Host, HostProvider, ServiceComponent from django.contrib.contenttypes.models import ContentType -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -135,8 +134,7 @@ def setUp(self) -> None: } def test_cluster_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -150,8 +148,7 @@ def test_cluster_create_success(self): ) def test_cluster_create_incorrect_body_fail(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "config-groups"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -165,8 +162,7 @@ def test_cluster_create_incorrect_body_fail(self): ) def test_cluster_create_fail(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": 1000}), + response = (self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "config-groups").post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -182,8 +178,7 @@ def test_cluster_create_fail(self): def test_cluster_create_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.cluster_1], role_name="View cluster configurations"): - response = self.client.post( - path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -198,8 +193,7 @@ def test_cluster_create_view_perms_denied(self): def test_cluster_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -213,8 +207,7 @@ def test_cluster_create_no_perms_denied(self): ) def test_provider_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-group-config-list", kwargs={"hostprovider_pk": self.provider.pk}), + response = self.client.v2[self.provider, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -228,8 +221,7 @@ def test_provider_create_success(self): ) def test_provider_create_incorrect_body_fail(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-group-config-list", kwargs={"hostprovider_pk": self.provider.pk}), + response = self.client.v2[self.provider, "config-groups"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -243,8 +235,7 @@ def test_provider_create_incorrect_body_fail(self): ) def test_provider_create_fail(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-group-config-list", kwargs={"hostprovider_pk": 1000}), + response = (self.client.v2 / "hostproviders" / self.get_non_existent_pk(model=Cluster) / "config-groups").post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -260,10 +251,7 @@ def test_provider_create_fail(self): def test_provider_create_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.provider], role_name="View provider configurations"): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-list", kwargs={"hostprovider_pk": self.provider.pk} - ), + response = self.client.v2[self.provider, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -278,8 +266,7 @@ def test_provider_create_view_perms_denied(self): def test_provider_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:hostprovider-group-config-list", kwargs={"hostprovider_pk": self.provider.pk}), + response = self.client.v2[self.provider, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -293,15 +280,7 @@ def test_provider_create_no_perms_denied(self): ) def test_component_create_success(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), + response = self.client.v2[self.component_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -315,15 +294,7 @@ def test_component_create_success(self): ) def test_component_create_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), + response = self.client.v2[self.component_1, "config-groups"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -337,15 +308,9 @@ def test_component_create_incorrect_data_fail(self): ) def test_component_create_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": 1000, - }, - ), + response = self.client.v2[ + self.service_1, "components", self.get_non_existent_pk(model=ServiceComponent), "config-groups" + ].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -363,15 +328,7 @@ def test_component_create_view_perms_denied(self): with self.grant_permissions( to=self.test_user, on=[self.component_1], role_name="View component configurations" ): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), + response = self.client.v2[self.component_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -386,15 +343,7 @@ def test_component_create_view_perms_denied(self): def test_component_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), + response = self.client.v2[self.component_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -408,11 +357,7 @@ def test_component_create_no_perms_denied(self): ) def test_service_create_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), + response = self.client.v2[self.service_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -426,11 +371,7 @@ def test_service_create_success(self): ) def test_service_create_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), + response = self.client.v2[self.service_1, "config-groups"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -444,11 +385,9 @@ def test_service_create_incorrect_data_fail(self): ) def test_service_create_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": 1000}, - ), + response = self.client.v2[ + self.cluster_1, "services", self.get_non_existent_pk(model=ClusterObject), "config-groups" + ].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -464,11 +403,7 @@ def test_service_create_fail(self): def test_service_create_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.service_1], role_name="View service configurations"): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), + response = self.client.v2[self.service_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -483,11 +418,7 @@ def test_service_create_view_perms_denied(self): def test_service_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), + response = self.client.v2[self.service_1, "config-groups"].post( data={"name": "group-config-new", "description": "group-config-new"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -501,12 +432,7 @@ def test_service_create_no_perms_denied(self): ) def test_cluster_delete_success(self): - response = self.client.delete( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_group_config.pk}, - ), - ) + response = self.client.v2[self.cluster_1_group_config].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -518,12 +444,7 @@ def test_cluster_delete_success(self): ) def test_cluster_delete_fail(self): - response = self.client.delete( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": 1000}, - ), - ) + response = self.client.v2[self.cluster_1, "config-groups", self.get_non_existent_pk(model=GroupConfig)].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -537,12 +458,7 @@ def test_cluster_delete_fail(self): def test_cluster_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.cluster_1], role_name="View cluster configurations"): - response = self.client.delete( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_group_config.pk}, - ), - ) + response = self.client.v2[self.cluster_1_group_config].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -555,12 +471,7 @@ def test_cluster_delete_view_perms_denied(self): def test_cluster_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_group_config.pk}, - ), - ) + response = self.client.v2[self.cluster_1_group_config].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -572,12 +483,7 @@ def test_cluster_delete_no_perms_denied(self): ) def test_provider_delete_success(self): - response = self.client.delete( - path=reverse( - viewname="v2:hostprovider-group-config-detail", - kwargs={"hostprovider_pk": self.cluster_1.pk, "pk": self.provider_group_config.pk}, - ), - ) + response = self.client.v2[self.provider_group_config].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -589,12 +495,7 @@ def test_provider_delete_success(self): ) def test_provider_delete_fail(self): - response = self.client.delete( - path=reverse( - viewname="v2:hostprovider-group-config-detail", - kwargs={"hostprovider_pk": self.provider.pk, "pk": 1000}, - ), - ) + response = self.client.v2[self.provider, "config-groups", self.get_non_existent_pk(model=GroupConfig)].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -608,12 +509,7 @@ def test_provider_delete_fail(self): def test_provider_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.provider], role_name="View provider configurations"): - response = self.client.delete( - path=reverse( - viewname="v2:hostprovider-group-config-detail", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.provider_group_config.pk}, - ), - ) + response = self.client.v2[self.provider_group_config].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -626,12 +522,7 @@ def test_provider_delete_view_perms_denied(self): def test_provider_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse( - viewname="v2:hostprovider-group-config-detail", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.provider_group_config.pk}, - ), - ) + response = self.client.v2[self.provider_group_config].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -643,16 +534,7 @@ def test_provider_delete_no_perms_denied(self): ) def test_service_delete_success(self): - response = self.client.delete( - path=reverse( - viewname="v2:service-group-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.service_1_group_config.pk, - }, - ), - ) + response = self.client.v2[self.service_1_group_config].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -664,12 +546,7 @@ def test_service_delete_success(self): ) def test_service_delete_fail(self): - response = self.client.delete( - path=reverse( - viewname="v2:service-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk, "pk": 1000}, - ), - ) + response = self.client.v2[self.service_1, "config-groups", self.get_non_existent_pk(model=GroupConfig)].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -683,16 +560,7 @@ def test_service_delete_fail(self): def test_service_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.service_1], role_name="View service configurations"): - response = self.client.delete( - path=reverse( - viewname="v2:service-group-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.service_1_group_config.pk, - }, - ), - ) + response = self.client.v2[self.service_1_group_config].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -705,16 +573,7 @@ def test_service_delete_view_perms_denied(self): def test_service_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse( - viewname="v2:service-group-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.service_1_group_config.pk, - }, - ), - ) + response = self.client.v2[self.service_1_group_config].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -726,17 +585,7 @@ def test_service_delete_no_perms_denied(self): ) def test_component_delete_success(self): - response = self.client.delete( - path=reverse( - viewname="v2:component-group-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.component_1_group_config.pk, - }, - ), - ) + response = self.client.v2[self.component_1_group_config].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -748,17 +597,9 @@ def test_component_delete_success(self): ) def test_component_delete_fail(self): - response = self.client.delete( - path=reverse( - viewname="v2:component-group-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": 1000, - }, - ), - ) + response = self.client.v2[ + self.component_1, "config-groups", self.get_non_existent_pk(model=GroupConfig) + ].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -774,17 +615,7 @@ def test_component_delete_view_perms_denied(self): with self.grant_permissions( to=self.test_user, on=[self.component_1], role_name="View component configurations" ): - response = self.client.delete( - path=reverse( - viewname="v2:component-group-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.component_1_group_config.pk, - }, - ), - ) + response = self.client.v2[self.component_1_group_config].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -797,17 +628,7 @@ def test_component_delete_view_perms_denied(self): def test_component_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse( - viewname="v2:component-group-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.component_1_group_config.pk, - }, - ), - ) + response = self.client.v2[self.component_1_group_config].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -819,12 +640,7 @@ def test_component_delete_no_perms_denied(self): ) def test_cluster_update_success(self): - response = self.client.patch( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_group_config.pk}, - ), - ) + response = self.client.v2[self.cluster_1_group_config].patch(data={}) self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -836,11 +652,7 @@ def test_cluster_update_success(self): ) def test_cluster_update_incorrect_body_fail(self): - response = self.client.patch( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config].patch( data={"name": {}}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -854,11 +666,8 @@ def test_cluster_update_incorrect_body_fail(self): ) def test_cluster_update_fail(self): - response = self.client.patch( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": 1000}, - ), + response = self.client.v2[self.cluster_1, "config-groups", self.get_non_existent_pk(model=GroupConfig)].patch( + data={} ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -873,12 +682,7 @@ def test_cluster_update_fail(self): def test_cluster_update_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.cluster_1], role_name="View cluster configurations"): - response = self.client.patch( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_group_config.pk}, - ), - ) + response = self.client.v2[self.cluster_1_group_config].patch(data={}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -891,12 +695,7 @@ def test_cluster_update_view_perms_denied(self): def test_cluster_update_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse( - viewname="v2:cluster-group-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_group_config.pk}, - ), - ) + response = self.client.v2[self.cluster_1_group_config].patch(data={}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -908,11 +707,7 @@ def test_cluster_update_no_perms_denied(self): ) def test_cluster_config_create_success(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config, "configs"].post( data=self.cluster_config_data, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -926,11 +721,7 @@ def test_cluster_config_create_success(self): ) def test_cluster_config_create_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config, "configs"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -944,11 +735,14 @@ def test_cluster_config_create_incorrect_data_fail(self): ) def test_cluster_config_create_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": 1000, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = ( + self.client.v2 + / "clusters" + / self.get_non_existent_pk(model=Cluster) + / "config-groups" + / self.cluster_1_group_config.pk + / "configs" + ).post( data=self.cluster_config_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -964,11 +758,7 @@ def test_cluster_config_create_fail(self): def test_cluster_config_create_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.cluster_1], role_name="View cluster configurations"): - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config, "configs"].post( data=self.cluster_config_data, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -983,11 +773,7 @@ def test_cluster_config_create_view_perms_denied(self): def test_cluster_config_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config, "configs"].post( data=self.cluster_config_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1001,15 +787,7 @@ def test_cluster_config_create_no_perms_denied(self): ) def test_service_config_create_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[self.service_1_group_config, "configs"].post( data=self.service_config_data, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1023,15 +801,7 @@ def test_service_config_create_success(self): ) def test_service_config_create_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[self.service_1_group_config, "configs"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -1045,15 +815,9 @@ def test_service_config_create_incorrect_data_fail(self): ) def test_service_config_create_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": 1000, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[ + self.cluster_1, "services", 1000, "config-groups", self.service_1_group_config.pk, "configs" + ].post( data=self.service_config_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1069,15 +833,7 @@ def test_service_config_create_fail(self): def test_service_config_create_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.service_1], role_name="View service configurations"): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[self.service_1_group_config, "configs"].post( data=self.service_config_data, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1092,15 +848,7 @@ def test_service_config_create_view_perms_denied(self): def test_service_config_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[self.service_1_group_config, "configs"].post( data=self.service_config_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1114,16 +862,7 @@ def test_service_config_create_no_perms_denied(self): ) def test_component_config_create_success(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[self.component_1_group_config, "configs"].post( data=self.component_config_data, ) @@ -1138,16 +877,7 @@ def test_component_config_create_success(self): ) def test_component_config_create_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[self.component_1_group_config, "configs"].post( data={"config": {}, "adcmMeta": {}}, ) @@ -1162,16 +892,16 @@ def test_component_config_create_incorrect_data_fail(self): ) def test_component_config_create_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": 1000, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[ + self.cluster_1, + "services", + self.service_1.pk, + "components", + 1000, + "config-groups", + self.component_1_group_config.pk, + "configs", + ].post( data=self.component_config_data, ) @@ -1190,16 +920,7 @@ def test_component_config_create_view_perms_denied(self): with self.grant_permissions( to=self.test_user, on=[self.component_1], role_name="View component configurations" ): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[self.component_1_group_config, "configs"].post( data=self.component_config_data, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1214,16 +935,7 @@ def test_component_config_create_view_perms_denied(self): def test_component_config_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[self.component_1_group_config, "configs"].post( data=self.component_config_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1237,14 +949,7 @@ def test_component_config_create_no_perms_denied(self): ) def test_hostprovider_config_create_success(self): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.provider_group_config.pk, - }, - ), + response = self.client.v2[self.provider_group_config, "configs"].post( data=self.provider_config_data, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1258,14 +963,7 @@ def test_hostprovider_config_create_success(self): ) def test_hostprovider_config_create_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.provider_group_config.pk, - }, - ), + response = self.client.v2[self.provider_group_config, "configs"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -1279,14 +977,14 @@ def test_hostprovider_config_create_incorrect_data_fail(self): ) def test_hostprovider_config_create_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={ - "hostprovider_pk": 1000, - "group_config_pk": self.provider_group_config.pk, - }, - ), + response = ( + self.client.v2 + / "hostproviders" + / self.get_non_existent_pk(model=HostProvider) + / "config-groups" + / self.provider_group_config.pk + / "configs" + ).post( data=self.provider_config_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1302,14 +1000,7 @@ def test_hostprovider_config_create_fail(self): def test_hostprovider_config_create_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.provider], role_name="View provider configurations"): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.provider_group_config.pk, - }, - ), + response = self.client.v2[self.provider_group_config, "configs"].post( data=self.provider_config_data, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1324,14 +1015,7 @@ def test_hostprovider_config_create_view_perms_denied(self): def test_hostprovider_config_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.provider_group_config.pk, - }, - ), + response = self.client.v2[self.provider_group_config, "configs"].post( data=self.provider_config_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1345,11 +1029,7 @@ def test_hostprovider_config_create_no_perms_denied(self): ) def test_provider_add_host_success(self): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-hosts-list", - kwargs={"hostprovider_pk": self.provider.pk, "group_config_pk": self.provider_group_config.pk}, - ), + response = self.client.v2[self.provider_group_config, "hosts"].post( data={"hostId": self.new_host.pk}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1363,11 +1043,7 @@ def test_provider_add_host_success(self): ) def test_provider_add_host_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-hosts-list", - kwargs={"hostprovider_pk": self.provider.pk, "group_config_pk": self.provider_group_config.pk}, - ), + response = self.client.v2[self.provider_group_config, "hosts"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -1381,11 +1057,9 @@ def test_provider_add_host_incorrect_data_fail(self): ) def test_provider_add_host_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-hosts-list", - kwargs={"hostprovider_pk": self.provider.pk, "group_config_pk": 10000}, - ), + response = self.client.v2[ + self.provider, "config-groups", self.get_non_existent_pk(model=GroupConfig), "hosts" + ].post( data={"hostId": self.new_host.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1401,11 +1075,7 @@ def test_provider_add_host_fail(self): def test_provider_add_host_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.provider], role_name="View provider configurations"): - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-hosts-list", - kwargs={"hostprovider_pk": self.provider.pk, "group_config_pk": self.provider_group_config.pk}, - ), + response = self.client.v2[self.provider_group_config, "hosts"].post( data={"hostId": self.new_host.pk}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1420,11 +1090,7 @@ def test_provider_add_host_view_perms_denied(self): def test_provider_add_host_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-hosts-list", - kwargs={"hostprovider_pk": self.provider.pk, "group_config_pk": self.provider_group_config.pk}, - ), + response = self.client.v2[self.provider_group_config, "hosts"].post( data={"hostId": self.new_host.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1438,15 +1104,7 @@ def test_provider_add_host_no_perms_denied(self): ) def test_service_add_host_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[self.service_1_group_config, "hosts"].post( data={"hostId": self.host_for_service.pk}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1461,15 +1119,14 @@ def test_service_add_host_success(self): ) def test_service_add_host_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": 1000, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[ + self.cluster_1, + "services", + self.get_non_existent_pk(model=ClusterObject), + "config-groups", + self.service_1_group_config, + "hosts", + ].post( data={"hostId": self.host_for_service.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1487,15 +1144,7 @@ def test_service_add_host_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.service_1], role_name="View service configurations"): - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[self.service_1_group_config, "hosts"].post( data={"hostId": self.host_for_service.pk}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1511,15 +1160,7 @@ def test_service_add_host_view_perms_denied(self): def test_service_add_host_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.service_1_group_config.pk, - }, - ), + response = self.client.v2[self.service_1_group_config, "hosts"].post( data={"hostId": self.host_for_service.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1534,11 +1175,7 @@ def test_service_add_host_no_perms_denied(self): ) def test_cluster_add_host_success(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-hosts-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config, "hosts"].post( data={"hostId": self.new_host.pk}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1552,11 +1189,7 @@ def test_cluster_add_host_success(self): ) def test_cluster_add_host_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-hosts-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config, "hosts"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -1570,11 +1203,14 @@ def test_cluster_add_host_incorrect_data_fail(self): ) def test_cluster_add_host_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-hosts-list", - kwargs={"cluster_pk": 1000, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = ( + self.client.v2 + / "clusters" + / self.get_non_existent_pk(model=Cluster) + / "config-groups" + / self.cluster_1_group_config + / "hosts" + ).post( data={"hostId": self.new_host.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1591,11 +1227,7 @@ def test_cluster_add_host_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.cluster_1], role_name="View cluster configurations"): - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-hosts-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config, "hosts"].post( data={"hostId": self.new_host.pk}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1610,11 +1242,7 @@ def test_cluster_add_host_view_perms_denied(self): def test_cluster_add_host_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-hosts-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.cluster_1_group_config.pk}, - ), + response = self.client.v2[self.cluster_1_group_config, "hosts"].post( data={"hostId": self.new_host.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1628,16 +1256,7 @@ def test_cluster_add_host_no_perms_denied(self): ) def test_component_add_host_success(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[self.component_1_group_config, "hosts"].post( data={"hostId": self.host_for_service.pk}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1652,16 +1271,7 @@ def test_component_add_host_success(self): ) def test_component_add_host_incorrect_data_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[self.component_1_group_config, "hosts"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -1675,16 +1285,14 @@ def test_component_add_host_incorrect_data_fail(self): ) def test_component_add_host_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": 1000, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[ + self.service_1, + "components", + self.get_non_existent_pk(model=ServiceComponent), + "config-groups", + self.component_1_group_config, + "hosts", + ].post( data={"hostId": self.host_for_service.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1703,16 +1311,7 @@ def test_component_add_host_view_perms_denied(self): with self.grant_permissions( to=self.test_user, on=[self.component_1], role_name="View component configurations" ): - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[self.component_1_group_config, "hosts"].post( data={"hostId": self.host_for_service.pk}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1728,16 +1327,7 @@ def test_component_add_host_view_perms_denied(self): def test_component_add_host_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-hosts-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - }, - ), + response = self.client.v2[self.component_1_group_config, "hosts"].post( data={"hostId": self.host_for_service.pk}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -1754,18 +1344,7 @@ def test_component_add_host_no_perms_denied(self): def test_component_remove_host_success(self): self.component_1_group_config.hosts.add(self.host_for_service) - response = self.client.delete( - path=reverse( - viewname="v2:component-group-config-hosts-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.component_1_group_config.pk, - "pk": self.host_for_service.pk, - }, - ), - ) + response = self.client.v2[self.component_1_group_config, "hosts", self.host_for_service.pk].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -1780,17 +1359,7 @@ def test_component_remove_host_success(self): def test_service_remove_host_not_found_fail(self): self.service_1_group_config.hosts.add(self.host_for_service) - response = self.client.delete( - path=reverse( - viewname="v2:service-group-config-hosts-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.service_1_group_config.pk, - "pk": 100, - }, - ), - ) + response = self.client.v2[self.service_1_group_config, "hosts", self.get_non_existent_pk(model=Host)].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -1805,16 +1374,7 @@ def test_cluster_remove_host_no_perms_denied(self): self.client.login(**self.test_user_credentials) self.cluster_1_group_config.hosts.add(self.host_for_service) - response = self.client.delete( - path=reverse( - viewname="v2:cluster-group-config-hosts-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "group_config_pk": self.cluster_1_group_config.pk, - "pk": self.host_for_service.pk, - }, - ), - ) + response = self.client.v2[self.cluster_1_group_config, "hosts", self.host_for_service.pk].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -1831,16 +1391,7 @@ def test_hostprovider_remove_host_view_perms_denied(self): self.provider_group_config.hosts.add(self.host_for_service) with self.grant_permissions(to=self.test_user, on=[self.provider], role_name="View provider configurations"): - response = self.client.delete( - path=reverse( - viewname="v2:hostprovider-group-config-hosts-detail", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.provider_group_config.pk, - "pk": self.host_for_service.pk, - }, - ), - ) + response = self.client.v2[self.provider_group_config, "hosts", self.host_for_service.pk].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( diff --git a/python/api_v2/tests/test_audit/test_host.py b/python/api_v2/tests/test_audit/test_host.py index 4f96752146..2f25d88a73 100644 --- a/python/api_v2/tests/test_audit/test_host.py +++ b/python/api_v2/tests/test_audit/test_host.py @@ -11,8 +11,7 @@ # limitations under the License. from audit.models import AuditObject -from cm.models import Host, ObjectType, Prototype -from rest_framework.reverse import reverse +from cm.models import Cluster, Host, ObjectType, Prototype from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -38,8 +37,7 @@ def setUp(self) -> None: self.add_host_to_cluster(cluster=self.cluster_1, host=self.host_1) def test_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:host-list"), + response = (self.client.v2 / "hosts").post( data={ "hostproviderId": self.provider.pk, "name": "new-test-host", @@ -56,8 +54,7 @@ def test_create_success(self): ) def test_create_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-list"), + response = (self.client.v2 / "hosts").post( data={ "name": "new-test-host", }, @@ -75,8 +72,7 @@ def test_create_fail(self): def test_create_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:host-list"), + response = (self.client.v2 / "hosts").post( data={ "hostproviderId": self.provider.pk, "name": "new-test-host", @@ -93,8 +89,7 @@ def test_create_denied(self): ) def test_update_success(self): - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), + response = self.client.v2[self.host_2].patch( data={"name": "new.name"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -112,8 +107,7 @@ def test_update_success(self): ) def test_update_full_success(self): - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), + response = self.client.v2[self.host_2].patch( data={"name": "new.name", "description": "new description"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -134,8 +128,7 @@ def test_update_full_success(self): ) def test_update_incorrect_data_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), + response = self.client.v2[self.host_2].patch( data={"name": "a"}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -149,8 +142,7 @@ def test_update_incorrect_data_fail(self): ) def test_update_not_found_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": 1000}), + response = (self.client.v2 / "hosts" / self.get_non_existent_pk(model=Host)).patch( data={"name": "new.name"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -166,8 +158,7 @@ def test_update_not_found_fail(self): def test_update_view_config_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.host_2], role_name="View host configurations"): - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), + response = self.client.v2[self.host_2].patch( data={"name": "new.name"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -183,8 +174,7 @@ def test_update_view_config_denied(self): def test_update_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), + response = self.client.v2[self.host_2].patch( data={"name": "new.name"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -208,9 +198,7 @@ def test_delete_success(self): is_deleted=False, ) - response = self.client.delete( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), - ) + response = self.client.v2[self.host_2].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -222,10 +210,7 @@ def test_delete_success(self): ) def test_delete_not_found_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:host-detail", kwargs={"pk": 1000}), - data={"name": "new.name"}, - ) + response = (self.client.v2 / "hosts" / self.get_non_existent_pk(model=Host)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -239,9 +224,7 @@ def test_delete_not_found_fail(self): def test_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.host_2], role_name="View host configurations"): - response = self.client.delete( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), - ) + response = self.client.v2[self.host_2].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -254,9 +237,7 @@ def test_delete_view_perms_denied(self): def test_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), - ) + response = self.client.v2[self.host_2].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -268,11 +249,7 @@ def test_delete_no_perms_denied(self): ) def test_remove_from_cluster_success(self): - response = self.client.delete( - path=reverse( - viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host_1].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) @@ -284,9 +261,9 @@ def test_remove_from_cluster_success(self): ) def test_remove_from_cluster_not_found_cluster_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:host-cluster-detail", kwargs={"cluster_pk": 100, "pk": self.host_2.pk}), - ) + response = ( + self.client.v2 / "clusters" / self.get_non_existent_pk(model=Cluster) / "hosts" / self.host_2 + ).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -298,9 +275,7 @@ def test_remove_from_cluster_not_found_cluster_fail(self): ) def test_remove_from_cluster_not_found_host_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": 1000}), - ) + response = self.client.v2[self.cluster_1, "hosts", self.get_non_existent_pk(model=Host)].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -314,11 +289,7 @@ def test_remove_from_cluster_not_found_host_fail(self): def test_remove_from_cluster_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.cluster_1], role_name="View cluster configurations"): - response = self.client.delete( - path=reverse( - viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host_1].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -332,11 +303,7 @@ def test_remove_from_cluster_view_perms_denied(self): def test_remove_from_cluster_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse( - viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host_1].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -349,11 +316,7 @@ def test_remove_from_cluster_no_perms_denied(self): ) def test_switch_maintenance_mode_success(self): - response = self.client.post( - path=reverse( - viewname="v2:host-maintenance-mode", - kwargs={"pk": self.host_1.pk}, - ), + response = self.client.v2[self.host_1, "maintenance-mode"].post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -367,11 +330,7 @@ def test_switch_maintenance_mode_success(self): ) def test_switch_maintenance_mode_incorrect_body_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:host-maintenance-mode", - kwargs={"pk": self.host_1.pk}, - ), + response = self.client.v2[self.host_1, "maintenance-mode"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -384,11 +343,7 @@ def test_switch_maintenance_mode_incorrect_body_fail(self): ) def test_switch_maintenance_mode_not_found_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:host-maintenance-mode", - kwargs={"pk": 1000}, - ), + response = (self.client.v2 / "hosts" / self.get_non_existent_pk(model=Host) / "maintenance-mode").post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -404,11 +359,7 @@ def test_switch_maintenance_mode_not_found_fail(self): def test_switch_maintenance_mode_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.host_1], role_name="View host configurations"): - response = self.client.post( - path=reverse( - viewname="v2:host-maintenance-mode", - kwargs={"pk": self.host_1.pk}, - ), + response = self.client.v2[self.host_1, "maintenance-mode"].post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -423,11 +374,7 @@ def test_switch_maintenance_mode_view_perms_denied(self): def test_switch_maintenance_mode_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:host-maintenance-mode", - kwargs={"pk": self.host_1.pk}, - ), + response = self.client.v2[self.host_1, "maintenance-mode"].post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -441,11 +388,7 @@ def test_switch_maintenance_mode_no_perms_denied(self): ) def test_switch_maintenance_mode_cluster_success(self): - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk}, - ), + response = self.client.v2[self.cluster_1, "hosts", self.host_1, "maintenance-mode"].post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -459,11 +402,7 @@ def test_switch_maintenance_mode_cluster_success(self): ) def test_switch_maintenance_mode_cluster_incorrect_body_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk}, - ), + response = self.client.v2[self.cluster_1, "hosts", self.host_1, "maintenance-mode"].post( data={}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -476,11 +415,9 @@ def test_switch_maintenance_mode_cluster_incorrect_body_fail(self): ) def test_switch_maintenance_mode_cluster_not_found_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": 1000}, - ), + response = self.client.v2[ + self.cluster_1, "hosts", self.get_non_existent_pk(model=Host), "maintenance-mode" + ].post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -494,11 +431,7 @@ def test_switch_maintenance_mode_cluster_not_found_fail(self): def test_switch_maintenance_mode_cluster_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk}, - ), + response = self.client.v2[self.cluster_1, "hosts", self.host_1, "maintenance-mode"].post( data={"maintenanceMode": "on"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -526,8 +459,7 @@ def test_update_host_config_success(self): "adcmMeta": {"/activatable_group": {"isActive": True}}, "description": "new config", } - response = self.client.post( - path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host_1.pk}), + response = self.client.v2[self.host_1, "configs"].post( data=data, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -540,8 +472,7 @@ def test_update_host_config_success(self): ) def test_update_host_config_incorrect_data_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host_1.pk}), + response = self.client.v2[self.host_1, "configs"].post( data={"config": {}, "adcmMeta": {"/activatable_group": {"isActive": True}}}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -554,8 +485,7 @@ def test_update_host_config_incorrect_data_fail(self): ) def test_update_host_config_not_found_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": 1000}), + response = (self.client.v2 / "hosts" / self.get_non_existent_pk(model=Host) / "configs").post( data={}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -571,8 +501,7 @@ def test_update_host_config_not_found_fail(self): def test_update_host_config_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[self.host_1], role_name="View host configurations"): - response = self.client.post( - path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host_1.pk}), + response = self.client.v2[self.host_1, "configs"].post( data={}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -587,8 +516,7 @@ def test_update_host_config_view_perms_denied(self): def test_update_host_config_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host_1.pk}), + response = self.client.v2[self.host_1, "configs"].post( data={}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -602,8 +530,7 @@ def test_update_host_config_no_perms_denied(self): ) def test_host_object_changes_all_fields_success(self): - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host_2.pk}), + response = self.client.v2[self.host_2].patch( data={"name": "new.name", "description": "new description"}, ) self.assertEqual(response.status_code, HTTP_200_OK) diff --git a/python/api_v2/tests/test_audit/test_host_provider.py b/python/api_v2/tests/test_audit/test_host_provider.py index 0cd96ea87b..65590c6ac5 100644 --- a/python/api_v2/tests/test_audit/test_host_provider.py +++ b/python/api_v2/tests/test_audit/test_host_provider.py @@ -13,7 +13,6 @@ from audit.models import AuditObject from cm.models import Action, HostProvider, Prototype, Upgrade -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -54,8 +53,7 @@ def setUp(self) -> None: self.provider_upgrade = Upgrade.objects.get(bundle=upgrade_bundle, name="upgrade") def test_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-list"), + response = (self.client.v2 / "hostproviders").post( data={"prototypeId": self.provider.prototype.pk, "name": "test_provider"}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -70,8 +68,7 @@ def test_create_success(self): def test_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:hostprovider-list"), + response = (self.client.v2 / "hostproviders").post( data={"prototypeId": self.provider.prototype.pk, "name": "test_provider"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -85,8 +82,7 @@ def test_create_no_perms_denied(self): ) def test_create_duplicate_name_fail(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-list"), + response = (self.client.v2 / "hostproviders").post( data={"prototypeId": self.provider.prototype.pk, "name": self.provider.name}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -99,8 +95,7 @@ def test_create_duplicate_name_fail(self): ) def test_create_non_existent_proto_fail(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-list"), + response = (self.client.v2 / "hostproviders").post( data={"prototypeId": self.get_non_existent_pk(model=Prototype), "name": "test_provider"}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -120,9 +115,7 @@ def test_delete_success(self): is_deleted=False, ) - response = self.client.delete( - path=reverse(viewname="v2:hostprovider-detail", kwargs={"pk": self.provider.pk}), - ) + response = self.client.v2[self.provider].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -135,9 +128,7 @@ def test_delete_success(self): def test_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete( - path=reverse(viewname="v2:hostprovider-detail", kwargs={"pk": self.provider.pk}), - ) + response = self.client.v2[self.provider].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -152,9 +143,7 @@ def test_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="View provider configurations"): - response = self.client.delete( - path=reverse(viewname="v2:hostprovider-detail", kwargs={"pk": self.provider.pk}), - ) + response = self.client.v2[self.provider].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -166,11 +155,7 @@ def test_delete_view_perms_denied(self): ) def test_delete_non_existent_fail(self): - response = self.client.delete( - path=reverse( - viewname="v2:hostprovider-detail", kwargs={"pk": self.get_non_existent_pk(model=HostProvider)} - ), - ) + response = (self.client.v2 / "hostproviders" / self.get_non_existent_pk(model=HostProvider)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -182,8 +167,7 @@ def test_delete_non_existent_fail(self): ) def test_update_config_success(self): - response = self.client.post( - path=reverse(viewname="v2:provider-config-list", kwargs={"hostprovider_pk": self.provider.pk}), + response = self.client.v2[self.provider, "configs"].post( data=self.config_post_data, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -199,8 +183,7 @@ def test_update_config_success(self): def test_update_config_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse(viewname="v2:provider-config-list", kwargs={"hostprovider_pk": self.provider.pk}), + response = self.client.v2[self.provider, "configs"].post( data=self.config_post_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -217,8 +200,7 @@ def test_update_config_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="View provider configurations"): - response = self.client.post( - path=reverse(viewname="v2:provider-config-list", kwargs={"hostprovider_pk": self.provider.pk}), + response = self.client.v2[self.provider, "configs"].post( data=self.config_post_data, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -232,8 +214,7 @@ def test_update_config_denied(self): ) def test_update_config_wrong_data_fail(self): - response = self.client.post( - path=reverse(viewname="v2:provider-config-list", kwargs={"hostprovider_pk": self.provider.pk}), + response = self.client.v2[self.provider, "configs"].post( data={"wrong": ["d", "a", "t", "a"]}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -247,11 +228,7 @@ def test_update_config_wrong_data_fail(self): ) def test_update_config_not_exists_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:provider-config-list", - kwargs={"hostprovider_pk": self.get_non_existent_pk(model=HostProvider)}, - ), + response = (self.client.v2 / "hostproviders" / self.get_non_existent_pk(model=HostProvider) / "configs").post( data=self.config_post_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -265,12 +242,7 @@ def test_update_config_not_exists_fail(self): ) def test_run_action_success(self): - response = self.client.post( - path=reverse( - viewname="v2:provider-action-run", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.provider_action.pk}, - ), - ) + response = self.client.v2[self.provider, "actions", self.provider_action, "run"].post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -285,12 +257,7 @@ def test_run_action_no_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="View provider configurations"): - response = self.client.post( - path=reverse( - viewname="v2:provider-action-run", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.provider_action.pk}, - ), - ) + response = self.client.v2[self.provider, "actions", self.provider_action, "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -302,12 +269,7 @@ def test_run_action_no_perms_denied(self): ) def test_run_action_not_exists_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:provider-action-run", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.get_non_existent_pk(model=Action)}, - ), - ) + response = self.client.v2[self.provider, "actions", self.get_non_existent_pk(model=Action), "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -319,15 +281,7 @@ def test_run_action_not_exists_fail(self): ) def test_upgrade_provider_success(self): - response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={ - "hostprovider_pk": self.provider.pk, - "pk": self.provider_upgrade.pk, - }, - ), - ) + response = self.client.v2[self.provider, "upgrades", self.provider_upgrade.pk, "run"].post() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -341,15 +295,7 @@ def test_upgrade_provider_success(self): def test_upgrade_provider_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={ - "hostprovider_pk": self.provider.pk, - "pk": self.provider_upgrade.pk, - }, - ), - ) + response = self.client.v2[self.provider, "upgrades", self.provider_upgrade.pk, "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -364,15 +310,7 @@ def test_upgrade_provider_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="View provider configurations"): - response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={ - "hostprovider_pk": self.provider.pk, - "pk": self.provider_upgrade.pk, - }, - ), - ) + response = self.client.v2[self.provider, "upgrades", self.provider_upgrade.pk, "run"].post() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -384,15 +322,7 @@ def test_upgrade_provider_denied(self): ) def test_upgrade_provider_fail(self): - response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={ - "hostprovider_pk": self.provider.pk, - "pk": self.get_non_existent_pk(model=Upgrade), - }, - ), - ) + response = self.client.v2[self.provider, "upgrades", self.get_non_existent_pk(model=Upgrade), "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( diff --git a/python/api_v2/tests/test_audit/test_login.py b/python/api_v2/tests/test_audit/test_login.py index 16107bf4ff..00f3da4a6c 100644 --- a/python/api_v2/tests/test_audit/test_login.py +++ b/python/api_v2/tests/test_audit/test_login.py @@ -12,7 +12,6 @@ from audit.models import AuditSession, AuditSessionLoginResult from rest_framework.response import Response -from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN from api_v2.tests.base import BaseAPITestCase @@ -23,7 +22,7 @@ def setUp(self) -> None: super().setUp() self.client.logout() - self.target_url_paths = [reverse(viewname="v2:token"), reverse(viewname="v2:login")] + self.target_url_paths = [(self.client.v2 / "token").path, (self.client.v2 / "login").path] self.test_user_credentials = {"username": "test_user_username", "password": "test_user_password"} self.test_user = self.create_user(**self.test_user_credentials) diff --git a/python/api_v2/tests/test_audit/test_object_renaming.py b/python/api_v2/tests/test_audit/test_object_renaming.py index f75f0bc79c..13d550cfc6 100644 --- a/python/api_v2/tests/test_audit/test_object_renaming.py +++ b/python/api_v2/tests/test_audit/test_object_renaming.py @@ -10,7 +10,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from django.urls import reverse from rbac.models import Role from rbac.services.group import create as create_group from rbac.services.policy import policy_create @@ -25,18 +24,14 @@ def test_cluster_rename(self) -> None: first_new_name = "Best Cluster Name EVER" second_new_name = "Another Name" - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), data={"name": first_new_name} - ) + response = self.client.v2[self.cluster_1].patch(data={"name": first_new_name}) self.assertEqual(response.status_code, HTTP_200_OK) log = self.get_most_recent_audit_log() self.assertEqual(log.audit_object.object_name, first_new_name) - response = self.client.patch( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}), data={"name": second_new_name} - ) + response = self.client.v2[self.cluster_1].patch(data={"name": second_new_name}) self.assertEqual(response.status_code, HTTP_200_OK) log.refresh_from_db() @@ -47,8 +42,7 @@ def test_host_rename(self) -> None: first_name = "best-cluster-fqdn-ever" second_name = "another-name" - response = self.client.post( - path=reverse(viewname="v2:host-list"), + response = (self.client.v2 / "hosts").post( data={"name": first_name, "hostproviderId": self.provider.pk}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -57,9 +51,7 @@ def test_host_rename(self) -> None: self.assertEqual(log.audit_object.object_name, first_name) - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": response.json()["id"]}), data={"name": second_name} - ) + response = (self.client.v2 / "hosts" / response.json()["id"]).patch(data={"name": second_name}) self.assertEqual(response.status_code, HTTP_200_OK) log.refresh_from_db() @@ -82,11 +74,7 @@ def test_policy_rename(self) -> None: object=[self.cluster_1], ) - response = self.client.patch( - path=reverse( - viewname="v2:rbac:policy-detail", - kwargs={"pk": policy.pk}, - ), + response = self.client.v2[policy].patch( data={"displayName": "changed display name"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -95,9 +83,7 @@ def test_policy_rename(self) -> None: self.assertEqual(log.audit_object.object_name, first_name) - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": policy.pk}), data={"name": second_name} - ) + response = self.client.v2[policy].patch(data={"name": second_name}) self.assertEqual(response.status_code, HTTP_200_OK) log.refresh_from_db() diff --git a/python/api_v2/tests/test_audit/test_policy.py b/python/api_v2/tests/test_audit/test_policy.py index 6b93bdc8ec..94c30ea282 100644 --- a/python/api_v2/tests/test_audit/test_policy.py +++ b/python/api_v2/tests/test_audit/test_policy.py @@ -16,7 +16,6 @@ from rbac.services.group import create as create_group from rbac.services.policy import policy_create from rbac.services.role import role_create -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -69,8 +68,7 @@ def setUp(self) -> None: ) def test_policy_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:policy-list"), + response = (self.client.v2 / "rbac" / "policies").post( data=self.policy_create_data, ) @@ -87,8 +85,7 @@ def test_policy_create_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View policy"): - response = self.client.post( - path=reverse(viewname="v2:rbac:policy-list"), + response = (self.client.v2 / "rbac" / "policies").post( data=self.policy_create_data, ) @@ -105,8 +102,7 @@ def test_policy_create_wrong_data_fail(self): wrong_data = self.policy_create_data.copy() wrong_data["objects"] = [{"id": self.provider.pk, "type": "provider"}] - response = self.client.post( - path=reverse(viewname="v2:rbac:policy-list"), + response = (self.client.v2 / "rbac" / "policies").post( data=wrong_data, ) @@ -120,8 +116,7 @@ def test_policy_create_wrong_data_fail(self): ) def test_policy_edit_success(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.policy.pk}), + response = self.client.v2[self.policy].patch( data=self.policy_update_data, ) @@ -145,8 +140,7 @@ def test_policy_all_fields_edit_success(self): "role": {"id": self.another_role.pk}, "object": [{"name": self.cluster_2.name, "type": self.cluster_2.prototype.type, "id": self.cluster_2.pk}], } - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.another_policy.pk}), + response = self.client.v2[self.another_policy].patch( data=policy_update_data, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -180,8 +174,7 @@ def test_policy_all_fields_edit_success(self): def test_policy_edit_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.policy.pk}), + response = self.client.v2[self.policy].patch( data=self.policy_update_data, ) @@ -198,8 +191,7 @@ def test_policy_edit_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View policy"): - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.policy.pk}), + response = self.client.v2[self.policy].patch( data=self.policy_update_data, ) @@ -213,8 +205,7 @@ def test_policy_edit_view_perms_denied(self): ) def test_policy_edit_incorrect_data_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.policy.pk}), + response = self.client.v2[self.policy].patch( data={"name": ""}, ) @@ -228,8 +219,7 @@ def test_policy_edit_incorrect_data_fail(self): ) def test_policy_edit_not_exists_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.get_non_existent_pk(model=Policy)}), + response = (self.client.v2 / "rbac" / "policies" / self.get_non_existent_pk(model=Policy)).patch( data=self.policy_update_data, ) @@ -253,7 +243,7 @@ def test_policy_delete_success(self): is_deleted=False, ) - response = self.client.delete(path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.policy.pk})) + response = self.client.v2[self.policy].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -267,7 +257,7 @@ def test_policy_delete_success(self): def test_policy_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete(path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.policy.pk})) + response = self.client.v2[self.policy].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -282,7 +272,7 @@ def test_policy_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View policy"): - response = self.client.delete(path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.policy.pk})) + response = self.client.v2[self.policy].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -294,9 +284,7 @@ def test_policy_delete_view_perms_denied(self): ) def test_policy_delete_not_exists_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.get_non_existent_pk(model=Policy)}) - ) + response = (self.client.v2 / "rbac" / "policies" / self.get_non_existent_pk(model=Policy)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( diff --git a/python/api_v2/tests/test_audit/test_role.py b/python/api_v2/tests/test_audit/test_role.py index f5dc6b9522..7b1e5e6cfc 100644 --- a/python/api_v2/tests/test_audit/test_role.py +++ b/python/api_v2/tests/test_audit/test_role.py @@ -14,7 +14,6 @@ from audit.models import AuditObject from rbac.models import Role from rbac.services.role import role_create -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -46,7 +45,7 @@ def setUp(self) -> None: ) def test_role_create_success(self): - response = self.client.post(path=reverse(viewname="v2:rbac:role-list"), data=self.role_create_data) + response = (self.client.v2 / "rbac" / "roles").post(data=self.role_create_data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.check_last_audit_record( @@ -60,7 +59,7 @@ def test_role_create_success(self): def test_role_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post(path=reverse(viewname="v2:rbac:role-list"), data=self.role_create_data) + response = (self.client.v2 / "rbac" / "roles").post(data=self.role_create_data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -72,7 +71,7 @@ def test_role_create_no_perms_denied(self): ) def test_role_create_wrong_data_fail(self): - response = self.client.post(path=reverse(viewname="v2:rbac:role-list"), data={"displayName": "Some role"}) + response = (self.client.v2 / "rbac" / "roles").post(data={"displayName": "Some role"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.check_last_audit_record( @@ -84,8 +83,7 @@ def test_role_create_wrong_data_fail(self): ) def test_role_update_success(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk}), + response = self.client.v2[self.custom_role].patch( data=self.role_create_data, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -123,8 +121,7 @@ def test_role_update_all_fields_success(self): old_description = self.custom_role.description - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk}), + response = self.client.v2[self.custom_role].patch( data=role_create_data, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -161,8 +158,7 @@ def test_role_update_one_field_success(self): "description": "new description", } - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk}), + response = self.client.v2[self.custom_role].patch( data=role_create_data, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -193,8 +189,7 @@ def test_role_update_one_field_success(self): def test_role_update_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk}), + response = self.client.v2[self.custom_role].patch( data=self.role_create_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -211,8 +206,7 @@ def test_role_update_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View roles"): - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk}), + response = self.client.v2[self.custom_role].patch( data=self.role_create_data, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -226,8 +220,7 @@ def test_role_update_view_perms_denied(self): ) def test_role_update_not_found_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": 1000}), + response = (self.client.v2 / "rbac" / "roles" / self.get_non_existent_pk(model=Role)).patch( data={"displayName": "Custom role name"}, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -243,8 +236,7 @@ def test_role_update_not_found_fail(self): def test_role_update_duplicate_name_fail(self): role_create(display_name="Custom role name", child=[Role.objects.get(name="View cluster configurations")]) - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk}), + response = self.client.v2[self.custom_role].patch( data={"displayName": "Custom role name"}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -268,7 +260,7 @@ def test_role_delete_success(self): is_deleted=False, ) - response = self.client.delete(path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk})) + response = self.client.v2[self.custom_role].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -282,7 +274,7 @@ def test_role_delete_success(self): def test_role_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete(path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk})) + response = self.client.v2[self.custom_role].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -297,9 +289,7 @@ def test_role_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View roles"): - response = self.client.delete( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.custom_role.pk}) - ) + response = self.client.v2[self.custom_role].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -311,9 +301,7 @@ def test_role_delete_view_perms_denied(self): ) def test_role_delete_not_exists_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.get_non_existent_pk(model=Role)}) - ) + response = (self.client.v2 / "rbac" / "roles" / self.get_non_existent_pk(model=Role)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( diff --git a/python/api_v2/tests/test_audit/test_user.py b/python/api_v2/tests/test_audit/test_user.py index 2298155a02..9cb05e43f2 100644 --- a/python/api_v2/tests/test_audit/test_user.py +++ b/python/api_v2/tests/test_audit/test_user.py @@ -14,7 +14,6 @@ from django.utils import timezone from rbac.models import User from rbac.services.group import create as create_group -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -51,7 +50,7 @@ def setUp(self) -> None: self.group = create_group(name_to_display="Some group") def test_user_create_success(self): - response = self.client.post(path=reverse(viewname="v2:rbac:user-list"), data=self.user_create_data) + response = (self.client.v2 / "rbac" / "users").post(data=self.user_create_data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.check_last_audit_record( @@ -65,7 +64,7 @@ def test_user_create_success(self): def test_user_create_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post(path=reverse(viewname="v2:rbac:user-list"), data={"wrong": "data"}) + response = (self.client.v2 / "rbac" / "users").post(data={"wrong": "data"}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -78,7 +77,7 @@ def test_user_create_no_perms_denied(self): ) def test_user_create_wrong_data_fail(self): - response = self.client.post(path=reverse(viewname="v2:rbac:user-list"), data={"wrong": "data"}) + response = (self.client.v2 / "rbac" / "users").post(data={"wrong": "data"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.check_last_audit_record( @@ -91,9 +90,7 @@ def test_user_create_wrong_data_fail(self): ) def test_user_update_success(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.test_user.pk}), data=self.user_update_data - ) + response = self.client.v2[self.test_user].patch(data=self.user_update_data) self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -114,9 +111,7 @@ def test_user_update_all_fields_success(self): "password": "new_password1", "groups": [self.group.pk], } - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.test_user.pk}), data=user_update_data - ) + response = self.client.v2[self.test_user].patch(data=user_update_data) self.assertEqual(response.status_code, HTTP_200_OK) expected_object_changes = { @@ -150,8 +145,7 @@ def test_user_update_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View users"): - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.blocked_user.pk}), + response = self.client.v2[self.blocked_user].patch( data=self.user_update_data, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -167,8 +161,7 @@ def test_user_update_view_perms_denied(self): def test_user_update_no_view_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.blocked_user.pk}), + response = self.client.v2[self.blocked_user].patch( data=self.user_update_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -182,8 +175,7 @@ def test_user_update_no_view_perms_denied(self): ) def test_user_update_incorrect_data_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.test_user.pk}), + response = self.client.v2[self.test_user].patch( data={"email": "s"}, ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -197,8 +189,7 @@ def test_user_update_incorrect_data_fail(self): ) def test_user_update_not_exists_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.get_non_existent_pk(model=User)}), + response = (self.client.v2 / "rbac" / "users" / self.get_non_existent_pk(model=User)).patch( data=self.user_update_data, ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -223,7 +214,7 @@ def test_user_delete_success(self): is_deleted=False, ) - response = self.client.delete(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.blocked_user.pk})) + response = self.client.v2[self.blocked_user].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.check_last_audit_record( @@ -237,7 +228,7 @@ def test_user_delete_success(self): def test_user_delete_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.delete(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.blocked_user.pk})) + response = self.client.v2[self.blocked_user].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -252,9 +243,7 @@ def test_user_delete_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View users"): - response = self.client.delete( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.blocked_user.pk}) - ) + response = self.client.v2[self.blocked_user].delete() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -266,9 +255,7 @@ def test_user_delete_view_perms_denied(self): ) def test_user_delete_non_existent_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.get_non_existent_pk(model=User)}) - ) + response = (self.client.v2 / "rbac" / "users" / self.get_non_existent_pk(model=User)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -281,7 +268,7 @@ def test_user_delete_non_existent_fail(self): ) def test_user_unblock_success(self): - response = self.client.post(path=reverse(viewname="v2:rbac:user-unblock", kwargs={"pk": self.blocked_user.pk})) + response = self.client.v2[self.blocked_user, "unblock"].post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -295,7 +282,7 @@ def test_user_unblock_success(self): def test_user_unblock_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post(path=reverse(viewname="v2:rbac:user-unblock", kwargs={"pk": self.blocked_user.pk})) + response = self.client.v2[self.blocked_user, "unblock"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -310,9 +297,7 @@ def test_user_unblock_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View users"): - response = self.client.post( - path=reverse(viewname="v2:rbac:user-unblock", kwargs={"pk": self.blocked_user.pk}) - ) + response = self.client.v2[self.blocked_user, "unblock"].post() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -324,9 +309,7 @@ def test_user_unblock_view_perms_denied(self): ) def test_user_unblock_not_exists_fail(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:user-unblock", kwargs={"pk": self.get_non_existent_pk(model=User)}) - ) + response = (self.client.v2 / "rbac" / "users" / self.get_non_existent_pk(model=User) / "unblock").post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -339,7 +322,7 @@ def test_user_unblock_not_exists_fail(self): ) def test_user_block_success(self): - response = self.client.post(path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.test_user.pk})) + response = self.client.v2[self.test_user, "block"].post() self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( @@ -353,7 +336,7 @@ def test_user_block_success(self): def test_user_block_no_perms_denied(self): self.client.login(**self.test_user_credentials) - response = self.client.post(path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.blocked_user.pk})) + response = self.client.v2[self.blocked_user, "block"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( @@ -368,9 +351,7 @@ def test_user_block_view_perms_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View users"): - response = self.client.post( - path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.blocked_user.pk}) - ) + response = self.client.v2[self.blocked_user, "block"].post() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -382,9 +363,7 @@ def test_user_block_view_perms_denied(self): ) def test_user_block_not_exists_fail(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.get_non_existent_pk(model=User)}) - ) + response = (self.client.v2 / "rbac" / "users" / self.get_non_existent_pk(model=User) / "block").post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( diff --git a/python/api_v2/tests/test_user.py b/python/api_v2/tests/test_user.py index e797cab342..d63f6c9a6f 100644 --- a/python/api_v2/tests/test_user.py +++ b/python/api_v2/tests/test_user.py @@ -604,20 +604,6 @@ def test_delete_built_in_fail(self): {"code": "USER_DELETE_ERROR", "desc": "Built-in user could not be deleted", "level": "error"}, ) - def test_unblock_success(self): - user = self.create_user() - user.blocked_at = now() - user.failed_login_attempts = 5 - user.save(update_fields=["blocked_at", "failed_login_attempts"]) - - response = self.client.v2[user, "unblock"].post(data=None) - - user.refresh_from_db() - self.assertEqual(response.status_code, HTTP_200_OK) - self.assertIsNone(response.data) - self.assertIsNone(user.blocked_at) - self.assertEqual(user.failed_login_attempts, 0) - def test_ordering_success(self): user_data = [ { From aca0e638e244bbbc99c0b4dcb4d857ff20d2c7f0 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Mon, 15 Jul 2024 10:39:34 +0000 Subject: [PATCH 09/50] ADCM-5761: Implement GET method to `/action-host-groups/{id}/hosts/` --- python/api_v2/action_host_group/views.py | 30 ++++++++- python/api_v2/tests/test_action_host_group.py | 61 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/python/api_v2/action_host_group/views.py b/python/api_v2/action_host_group/views.py index 9d8102ffc1..5bbd4959b4 100644 --- a/python/api_v2/action_host_group/views.py +++ b/python/api_v2/action_host_group/views.py @@ -239,8 +239,8 @@ def host_candidate(self, request: Request, parent: CoreObjectDescriptor, pk: str check_has_group_permissions(user=request.user, parent=parent, dto=VIEW_ONLY_NOT_FOUND) - hosts = self.action_host_group_service.get_host_candidates(group_id=int(pk)) - return Response(data=list(Host.objects.values("id", name=F("fqdn")).filter(id__in=hosts).order_by("fqdn"))) + host_ids = self.action_host_group_service.get_host_candidates(group_id=int(pk)) + return Response(data=list(Host.objects.values("id", name=F("fqdn")).filter(id__in=host_ids).order_by("fqdn"))) def filter_by_parent(self, qs: QuerySet, parent: CoreObjectDescriptor) -> QuerySet: return qs.filter( @@ -279,7 +279,8 @@ def get_parent_name(self, parent: CoreObjectDescriptor) -> str: ) class HostActionHostGroupViewSet(ADCMGenericViewSet): serializer_class = AddHostSerializer - action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + repo = ActionHostGroupRepo() + action_host_group_service = ActionHostGroupService(repository=repo) @contextmanager def convert_exception(self) -> None: @@ -334,6 +335,29 @@ def destroy( return Response(status=HTTP_204_NO_CONTENT) + @with_group_object + def list( + self, request: Request, *_, parent: CoreObjectDescriptor, host_group: HostGroupDescriptor, **__ + ) -> Response: + check_has_group_permissions(user=request.user, parent=parent, dto=VIEW_ONLY_NOT_FOUND) + + host_ids = self.repo.get_hosts(id=host_group.id) + return Response(data=list(Host.objects.values("id", name=F("fqdn")).filter(id__in=host_ids).order_by("fqdn"))) + + @with_group_object + def retrieve( + self, request: Request, *_, parent: CoreObjectDescriptor, host_group: HostGroupDescriptor, **__ + ) -> Response: + check_has_group_permissions(user=request.user, parent=parent, dto=VIEW_ONLY_NOT_FOUND) + host_id = int(self.kwargs["pk"]) + + all_hosts = self.repo.get_hosts(id=host_group.id) + + if host_id not in all_hosts: + raise NotFound() + + return Response(data=ShortHostSerializer(instance=Host.objects.get(id=host_id)).data) + @extend_schema_view( run=extend_schema( diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py index b021080fc8..20357fe246 100644 --- a/python/api_v2/tests/test_action_host_group.py +++ b/python/api_v2/tests/test_action_host_group.py @@ -346,6 +346,67 @@ def setUp(self) -> None: object_: self.create_action_host_group(owner=object_, name=f"Group for {object_.name}") for object_ in objects } + self.user_credentials = {"username": "test_user_username", "password": "test_user_password"} + self.test_user = self.create_user(user_data=self.user_credentials) + self.user_client = self.client_class() + self.user_client.login(**self.user_credentials) + + def test_list_hosts_in_group_success(self) -> None: + host_1, host_2, *_ = self.hosts + expected = [{"id": host_1.id, "name": host_1.name}, {"id": host_2.id, "name": host_2.name}] + + for target in (self.cluster, self.service, self.component): + group = self.group_map[target] + type_ = orm_object_to_core_type(target) + with self.subTest(f"[{type_.name}] {target.name} Expect {len(expected)}"): + self.action_host_group_service.add_hosts_to_group(group_id=group.id, hosts=[host_1.id, host_2.id]) + + response = self.client.v2[group, "hosts"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json(), expected) + + def test_retrieve_host_from_group_success(self) -> None: + host_1, host_2, *_ = self.hosts + expected = {"id": host_1.id, "name": host_1.name} + + for target in (self.cluster, self.service, self.component): + group = self.group_map[target] + type_ = orm_object_to_core_type(target) + with self.subTest(f"[{type_.name}] {target.name} Expect {len(expected)}"): + self.action_host_group_service.add_hosts_to_group(group_id=group.id, hosts=[host_1.id, host_2.id]) + + response = self.client.v2[group, "hosts", host_1.pk].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["id"], host_1.pk) + + def test_retrieve_host_from_group_not_found_fail(self) -> None: + host_1, host_2, host_3, *_ = self.hosts + + for target in (self.cluster, self.service, self.component): + group = self.group_map[target] + type_ = orm_object_to_core_type(target) + with self.subTest(f"[{type_.name}] {target.name} Expect Not Found"): + self.action_host_group_service.add_hosts_to_group(group_id=group.id, hosts=[host_1.id, host_2.id]) + + response = self.client.v2[group, "hosts", host_3.pk].get() + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + def test_retrieve_host_from_group_permission_denied(self) -> None: + self.user_client.login(**self.user_credentials) + host_1, host_2, *_ = self.hosts + + for target in (self.cluster, self.service, self.component): + group = self.group_map[target] + type_ = orm_object_to_core_type(target) + with self.subTest(f"[{type_.name}] {target.name} Expect Permission Denied"): + self.action_host_group_service.add_hosts_to_group(group_id=group.id, hosts=[host_1.id, host_2.id]) + + response = self.user_client.v2[group, "hosts", host_1.pk].get() + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_add_host_to_group(self) -> None: host_1, host_2, host_3, host_4, *_ = self.hosts From d32c83d59456cb6d631bbcddc34651808780f68e Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Mon, 15 Jul 2024 14:33:45 +0300 Subject: [PATCH 10/50] feature/ADCM-5784 + cluster action host groups --- adcm-web/app/src/App.tsx | 5 + .../src/api/adcm/clusterActionHostGroups.ts | 118 +++++++++++++ .../adcm/clusterServiceActionHostGroups.ts | 141 +++++++++++++++ ...clusterServiceComponentActionHostGroups.ts | 163 ++++++++++++++++++ adcm-web/app/src/api/index.ts | 3 + .../ClusterActionHostGroups.tsx | 14 ++ .../ClusterActionHostGroupsTable.constants.ts | 22 +++ .../ClusterActionHostGroupsTable.tsx | 88 ++++++++++ ...HostGroupsTableExpandedContent.module.scss | 14 ++ ...erActionHostGroupsTableExpandedContent.tsx | 37 ++++ .../useRequestClusterActionHostGroups.ts | 28 +++ .../ClusterConfigurationsNavigation.tsx | 1 + .../app/src/models/adcm/actionHostGroup.ts | 27 +++ adcm-web/app/src/models/adcm/index.ts | 1 + adcm-web/app/src/models/adcm/jobs.ts | 5 + adcm-web/app/src/routes/routes.ts | 19 ++ .../actionHostGroupsSlice.ts | 95 ++++++++++ adcm-web/app/src/store/store.ts | 2 + 18 files changed, 783 insertions(+) create mode 100644 adcm-web/app/src/api/adcm/clusterActionHostGroups.ts create mode 100644 adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts create mode 100644 adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.constants.ts create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.module.scss create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useRequestClusterActionHostGroups.ts create mode 100644 adcm-web/app/src/models/adcm/actionHostGroup.ts create mode 100644 adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.ts diff --git a/adcm-web/app/src/App.tsx b/adcm-web/app/src/App.tsx index b4fc2e4e93..c3f68d382f 100644 --- a/adcm-web/app/src/App.tsx +++ b/adcm-web/app/src/App.tsx @@ -62,6 +62,7 @@ import ServiceComponentConfigGroupSingle from '@pages/cluster/service/component/ import HostProviderConfigurationGroupSingle from '@pages/HostProviderPage/HostProviderConfigurationGroupSingle/HostProviderConfigurationGroupSingle'; import NotFoundPage from '@pages/NotFoundPage/NotFoundPage'; import ClusterAnsibleSettings from '@pages/cluster/ClusterConfiguration/ClusterAnsibleSettings/ClusterAnsibleSettings'; +import ClusterActionHostGroups from '@pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups'; function App() { return ( @@ -161,6 +162,10 @@ function App() { path="/clusters/:clusterId/configuration/ansible-settings" element={} /> + } + /> }> } /> diff --git a/adcm-web/app/src/api/adcm/clusterActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterActionHostGroups.ts new file mode 100644 index 0000000000..8e828d4602 --- /dev/null +++ b/adcm-web/app/src/api/adcm/clusterActionHostGroups.ts @@ -0,0 +1,118 @@ +import { httpClient } from '@api/httpClient'; +import type { PaginationParams, SortParams } from '@models/table'; +import type { + AdcmDynamicAction, + AdcmDynamicActionDetails, + AdcmDynamicActionRunConfig, + AdcmJob, + Batch, +} from '@models/adcm'; +import type { + AdcmActionHostGroupHost, + AdcmActionHostGroup, + AdcmActionHostGroupsActionsFilter, + AddAdcmActionHostGroupHostPayload, + CreateAdcmActionHostGroupPayload, +} from '@models/adcm/actionHostGroup'; +import { prepareQueryParams } from '@utils/apiUtils'; +import qs from 'qs'; + +export class AdcmClusterActionHostGroupsApi { + public static async getActionHostGroups(clusterId: number, paginationParams: PaginationParams) { + const queryParams = prepareQueryParams(undefined, undefined, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/action-host-groups/?${query}`, + ); + + return response.data; + } + + public static async postActionHostGroup(clusterId: number, newActionGroup: CreateAdcmActionHostGroupPayload) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/action-host-groups/`, + newActionGroup, + ); + + return response.data; + } + + public static async getActionHostGroup(clusterId: number, actionHostGroupId: number) { + await httpClient.get(`/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/`); + } + + public static async deleteActionHostGroup(clusterId: number, actionHostGroupId: number) { + const response = await httpClient.delete( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/`, + ); + + return response.data; + } + + public static async getActionHostGroupActions( + clusterId: number, + actionHostGroupId: number, + sortParams: SortParams, + paginationParams: PaginationParams, + filter: AdcmActionHostGroupsActionsFilter, + ) { + const queryParams = prepareQueryParams(filter, sortParams, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/actions/?${query}`, + ); + + return response.data; + } + + public static async getActionHostGroupAction(clusterId: number, actionHostGroupId: number, actionId: number) { + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/`, + ); + + return response.data; + } + + public static async postActionHostGroupAction( + clusterId: number, + actionHostGroupId: number, + actionId: number, + actionRunConfig: AdcmDynamicActionRunConfig, + ) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/run/`, + actionRunConfig, + ); + + return response.data; + } + + public static async postActionHostGroupHost( + clusterId: number, + actionHostGroupId: number, + host: AddAdcmActionHostGroupHostPayload, + ) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/hosts/`, + host, + ); + + return response.data; + } + + public static async deleteActionHostGroupHost(clusterId: number, actionHostGroupId: number, hostId: number) { + await httpClient.delete( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`, + ); + } + + public static async getActionHostGroupHostCandidates(clusterId: number, actionHostGroupId: number) { + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/host-candidates/`, + ); + + return response.data; + } +} diff --git a/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts new file mode 100644 index 0000000000..fd17b93244 --- /dev/null +++ b/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts @@ -0,0 +1,141 @@ +import { httpClient } from '@api/httpClient'; +import type { PaginationParams, SortParams } from '@models/table'; +import type { + AdcmDynamicAction, + AdcmDynamicActionDetails, + AdcmDynamicActionRunConfig, + AdcmJob, + Batch, +} from '@models/adcm'; +import type { + AdcmActionHostGroupHost, + AdcmActionHostGroup, + AdcmActionHostGroupsActionsFilter, + AddAdcmActionHostGroupHostPayload, + CreateAdcmActionHostGroupPayload, +} from '@models/adcm/actionHostGroup'; +import { prepareQueryParams } from '@utils/apiUtils'; +import qs from 'qs'; + +export class AdcmClusterServiceActionHostGroupsApi { + public static async getActionHostGroups(clusterId: number, serviceId: number, paginationParams: PaginationParams) { + const queryParams = prepareQueryParams(undefined, undefined, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/?${query}`, + ); + + return response.data; + } + + public static async postActionHostGroup( + clusterId: number, + serviceId: number, + newActionGroup: CreateAdcmActionHostGroupPayload, + ) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/`, + newActionGroup, + ); + + return response.data; + } + + public static async getActionHostGroup(clusterId: number, serviceId: number, actionHostGroupId: number) { + await httpClient.get( + `/api/v2/clusters/${clusterId}/service/${serviceId}/action-host-groups/${actionHostGroupId}/`, + ); + } + + public static async deleteActionHostGroup(clusterId: number, serviceId: number, actionHostGroupId: number) { + const response = await httpClient.delete( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/`, + ); + + return response.data; + } + + public static async getActionHostGroupActions( + clusterId: number, + serviceId: number, + actionHostGroupId: number, + sortParams: SortParams, + paginationParams: PaginationParams, + filter: AdcmActionHostGroupsActionsFilter, + ) { + const queryParams = prepareQueryParams(filter, sortParams, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/actions/?${query}`, + ); + + return response.data; + } + + public static async getActionHostGroupAction( + clusterId: number, + serviceId: number, + actionHostGroupId: number, + actionId: number, + ) { + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/`, + ); + + return response.data; + } + + public static async postActionHostGroupAction( + clusterId: number, + serviceId: number, + actionHostGroupId: number, + actionId: number, + actionRunConfig: AdcmDynamicActionRunConfig, + ) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/run/`, + actionRunConfig, + ); + + return response.data; + } + + public static async postActionHostGroupHost( + clusterId: number, + serviceId: number, + actionHostGroupId: number, + host: AddAdcmActionHostGroupHostPayload, + ) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/hosts/`, + host, + ); + + return response.data; + } + + public static async deleteActionHostGroupHost( + clusterId: number, + serviceId: number, + actionHostGroupId: number, + hostId: number, + ) { + await httpClient.delete( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`, + ); + } + + public static async getActionHostGroupHostCandidates( + clusterId: number, + serviceId: number, + actionHostGroupId: number, + ) { + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/host-candidates/`, + ); + + return response.data; + } +} diff --git a/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts new file mode 100644 index 0000000000..24ebaa17b2 --- /dev/null +++ b/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts @@ -0,0 +1,163 @@ +import { httpClient } from '@api/httpClient'; +import type { PaginationParams, SortParams } from '@models/table'; +import type { + AdcmDynamicAction, + AdcmDynamicActionDetails, + AdcmDynamicActionRunConfig, + AdcmJob, + Batch, +} from '@models/adcm'; +import type { + AdcmActionHostGroupHost, + AdcmActionHostGroup, + AdcmActionHostGroupsActionsFilter, + AddAdcmActionHostGroupHostPayload, + CreateAdcmActionHostGroupPayload, +} from '@models/adcm/actionHostGroup'; +import { prepareQueryParams } from '@utils/apiUtils'; +import qs from 'qs'; + +export class AdcmClusterServiceComponentActionHostGroupsApi { + public static async getActionHostGroups( + clusterId: number, + serviceId: number, + componentId: number, + paginationParams: PaginationParams, + ) { + const queryParams = prepareQueryParams(undefined, undefined, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/?${query}`, + ); + + return response.data; + } + + public static async postActionHostGroup( + clusterId: number, + serviceId: number, + componentId: number, + newActionGroup: CreateAdcmActionHostGroupPayload, + ) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/`, + newActionGroup, + ); + + return response.data; + } + + public static async getActionHostGroup( + clusterId: number, + serviceId: number, + componentId: number, + actionHostGroupId: number, + ) { + await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/`, + ); + } + + public static async deleteActionHostGroup( + clusterId: number, + serviceId: number, + componentId: number, + actionHostGroupId: number, + ) { + const response = await httpClient.delete( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/`, + ); + + return response.data; + } + + public static async getActionHostGroupActions( + clusterId: number, + serviceId: number, + componentId: number, + actionHostGroupId: number, + sortParams: SortParams, + paginationParams: PaginationParams, + filter: AdcmActionHostGroupsActionsFilter, + ) { + const queryParams = prepareQueryParams(filter, sortParams, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/actions/?${query}`, + ); + + return response.data; + } + + public static async getActionHostGroupAction( + clusterId: number, + serviceId: number, + componentId: number, + actionHostGroupId: number, + actionId: number, + ) { + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/`, + ); + + return response.data; + } + + public static async postActionHostGroupAction( + clusterId: number, + serviceId: number, + componentId: number, + actionHostGroupId: number, + actionId: number, + actionRunConfig: AdcmDynamicActionRunConfig, + ) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/run/`, + actionRunConfig, + ); + + return response.data; + } + + public static async postActionHostGroupHost( + clusterId: number, + serviceId: number, + componentId: number, + actionHostGroupId: number, + host: AddAdcmActionHostGroupHostPayload, + ) { + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/hosts/`, + host, + ); + + return response.data; + } + + public static async deleteActionHostGroupHost( + clusterId: number, + serviceId: number, + componentId: number, + actionHostGroupId: number, + hostId: number, + ) { + await httpClient.delete( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`, + ); + } + + public static async getActionHostGroupHostCandidates( + clusterId: number, + serviceId: number, + componentId: number, + actionHostGroupId: number, + ) { + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/host-candidates/`, + ); + + return response.data; + } +} diff --git a/adcm-web/app/src/api/index.ts b/adcm-web/app/src/api/index.ts index a95ea3df5c..1d7a36da50 100644 --- a/adcm-web/app/src/api/index.ts +++ b/adcm-web/app/src/api/index.ts @@ -34,3 +34,6 @@ export { AdcmProfileApi } from './adcm/profile'; export { AdcmClusterOverviewApi } from './adcm/clusterOverview'; export { AdcmSettingsApi } from './adcm/settings'; export { AdcmClusterAnsibleSettingsApi } from './adcm/clusterAnsibleSettings'; +export { AdcmClusterActionHostGroupsApi } from './adcm/clusterActionHostGroups'; +export { AdcmClusterServiceActionHostGroupsApi } from './adcm/clusterServiceActionHostGroups'; +export { AdcmClusterServiceComponentActionHostGroupsApi } from './adcm/clusterServiceComponentActionHostGroups'; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx new file mode 100644 index 0000000000..3ccbc8a83c --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx @@ -0,0 +1,14 @@ +import { useRequestClusterActionHostGroups } from './useRequestClusterActionHostGroups'; +import ClusterActionHostGroupsTable from './ClusterActionHostGroupsTable/ClusterActionHostGroupsTable'; + +const ClusterActionHostGroups = () => { + useRequestClusterActionHostGroups(); + + return ( + <> + + + ); +}; + +export default ClusterActionHostGroups; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.constants.ts b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.constants.ts new file mode 100644 index 0000000000..484e036ee1 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.constants.ts @@ -0,0 +1,22 @@ +import { TableColumn } from '@uikit'; + +export const columns: TableColumn[] = [ + { + label: 'Name', + name: 'name', + }, + { + label: 'Description', + name: 'description', + }, + { + label: 'Hosts', + name: 'hosts', + }, + { + label: 'Actions', + name: 'actions', + headerAlign: 'center', + width: '100px', + }, +]; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.tsx new file mode 100644 index 0000000000..59b64bc2af --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.tsx @@ -0,0 +1,88 @@ +import { useStore, useDispatch } from '@hooks'; +import { ExpandableRowComponent, IconButton, Table, TableCell } from '@uikit'; +import ClusterActionHostGroupsTableExpandedContent from './ClusterActionHostGroupsTableExpandedContent'; +import { columns } from './ClusterActionHostGroupsTable.constants'; +import { isShowSpinner } from '@uikit/Table/Table.utils'; +import { AdcmActionHostGroup } from '@models/adcm/actionHostGroup'; +import { deleteClusterActionHostGroup } from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; +import { useState } from 'react'; +import ExpandDetailsCell from '@commonComponents/ExpandDetailsCell/ExpandDetailsCell'; + +const ClusterActionHostGroupsTable = () => { + const dispatch = useDispatch(); + + const cluster = useStore(({ adcm }) => adcm.cluster.cluster); + const actionHostGroups = useStore(({ adcm }) => adcm.clusterActionHostGroups.actionHostGroups); + const isLoading = useStore(({ adcm }) => isShowSpinner(adcm.clusterActionHostGroups.loadState)); + + const [expandableRows, setExpandableRows] = useState>({}); + + const handleExpandClick = (id: number) => { + setExpandableRows({ + ...expandableRows, + [id]: expandableRows[id] === undefined ? true : !expandableRows[id], + }); + }; + + const handleRunClick = () => { + console.info('run'); + }; + + const handleEditClick = () => { + console.info('edit'); + }; + + const handleDeleteClick = (group: AdcmActionHostGroup) => { + if (cluster) { + // Confirm dialog? + dispatch(deleteClusterActionHostGroup({ clusterId: cluster.id, actionHostGroupId: group.id })); + } + }; + + return ( + + {actionHostGroups.map((group: AdcmActionHostGroup) => { + return ( + } + > + {group.name} + {group.description} + handleExpandClick(group.id)}> + {group.hosts.length} + + + + + handleDeleteClick(group)} + tooltipProps={{ placement: 'bottom-start' }} + /> + + + ); + })} +
+ ); +}; + +export default ClusterActionHostGroupsTable; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.module.scss new file mode 100644 index 0000000000..6a645f9e80 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.module.scss @@ -0,0 +1,14 @@ +.clusterActionHostGroupsTableExpandedContent { + margin-top: 20px; + + &__tags { + flex-direction: row; + width: fit-content; + margin: 16px 16px 0 0; + + div { + width: fit-content; + padding: 6px 12px; + } + } +} diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.tsx new file mode 100644 index 0000000000..6a4a24d762 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.tsx @@ -0,0 +1,37 @@ +import React, { useMemo, useState } from 'react'; +import { SearchInput, Tag, Tags } from '@uikit'; +import type { AdcmActionHostGroupHost } from '@models/adcm/actionHostGroup'; +import s from './ClusterActionHostGroupsTableExpandedContent.module.scss'; + +export interface ServiceComponentsTableExpandedContentProps { + children: AdcmActionHostGroupHost[]; +} + +const ClusterActionHostGroupsTableExpandedContent = ({ children }: ServiceComponentsTableExpandedContentProps) => { + const [textEntered, setTextEntered] = useState(''); + + const childrenFiltered = useMemo(() => { + return children.filter((child) => child.name.toLowerCase().includes(textEntered.toLowerCase())); + }, [children, textEntered]); + + if (!children.length) return null; + + const handleHostsNameFilter = (event: React.ChangeEvent) => { + setTextEntered(event.target.value); + }; + + return ( +
+ + {childrenFiltered.length > 0 && ( + + {childrenFiltered.map((child) => ( + + ))} + + )} +
+ ); +}; + +export default ClusterActionHostGroupsTableExpandedContent; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useRequestClusterActionHostGroups.ts b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useRequestClusterActionHostGroups.ts new file mode 100644 index 0000000000..867749cf32 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useRequestClusterActionHostGroups.ts @@ -0,0 +1,28 @@ +import { useDebounce, useDispatch, useRequestTimer } from '@hooks'; +import { useEffect } from 'react'; +import { + getClusterActionHostGroups, + cleanupClusterActionHostGroups, +} from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; +import { useParams } from 'react-router-dom'; +import { defaultDebounceDelay } from '@constants'; + +export const useRequestClusterActionHostGroups = () => { + const dispatch = useDispatch(); + const { clusterId: clusterIdFromUrl } = useParams(); + const clusterId = Number(clusterIdFromUrl); + + useEffect(() => { + return () => { + dispatch(cleanupClusterActionHostGroups()); + }; + }, [dispatch]); + + const debounceGetClusterActionHostGroups = useDebounce(() => { + if (clusterId) { + dispatch(getClusterActionHostGroups({ clusterId })); + } + }, defaultDebounceDelay); + + useRequestTimer(debounceGetClusterActionHostGroups, () => {}, 0, [clusterId]); +}; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx index 809145e139..9f3f6e3945 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx @@ -10,6 +10,7 @@ const ClusterConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups Ansible settings + Action hosts groups diff --git a/adcm-web/app/src/models/adcm/actionHostGroup.ts b/adcm-web/app/src/models/adcm/actionHostGroup.ts new file mode 100644 index 0000000000..0426d04e55 --- /dev/null +++ b/adcm-web/app/src/models/adcm/actionHostGroup.ts @@ -0,0 +1,27 @@ +export interface AdcmActionHostGroupHost { + id: number; + name: string; +} + +export interface AddAdcmActionHostGroupHostPayload { + hostId: number; +} + +export interface AdcmActionHostGroup { + id: number; + name: string; + description: string; + hosts: AdcmActionHostGroupHost[]; +} + +export interface CreateAdcmActionHostGroupPayload { + name: string; + description?: string; +} + +export interface AdcmActionHostGroupsActionsFilter { + displayName?: string; + isHostOwnAction?: boolean; + name?: string; + prototypeId?: number; +} diff --git a/adcm-web/app/src/models/adcm/index.ts b/adcm-web/app/src/models/adcm/index.ts index 743ea0c3cd..f76d0be104 100644 --- a/adcm-web/app/src/models/adcm/index.ts +++ b/adcm-web/app/src/models/adcm/index.ts @@ -26,3 +26,4 @@ export * from './backendEvents'; export * from './settings'; export * from './bundle'; export * from './common'; +export * from './actionHostGroup'; diff --git a/adcm-web/app/src/models/adcm/jobs.ts b/adcm-web/app/src/models/adcm/jobs.ts index fb0c77e955..28c97b7b5c 100644 --- a/adcm-web/app/src/models/adcm/jobs.ts +++ b/adcm-web/app/src/models/adcm/jobs.ts @@ -40,6 +40,11 @@ export interface AdcmJob { isTerminatable: boolean; childJobs?: AdcmJob[]; logs?: AdcmJobLogItem[]; + action?: { + id: number; + name: string; + displayName?: string; + }; } export type AdcmJobLogCheckContentItem = { diff --git a/adcm-web/app/src/routes/routes.ts b/adcm-web/app/src/routes/routes.ts index b259d1e637..6dc6e71f5d 100644 --- a/adcm-web/app/src/routes/routes.ts +++ b/adcm-web/app/src/routes/routes.ts @@ -394,6 +394,25 @@ const routes: RoutesConfigs = { }, ], }, + '/clusters/:clusterId/configuration/action-host-groups': { + pageTitle: 'Clusters', + breadcrumbs: [ + { + href: '/clusters', + label: 'Clusters', + }, + { + href: '/clusters/:clusterId', + label: ':clusterId', + }, + { + label: 'Configuration', + }, + { + label: 'Action hosts groups', + }, + ], + }, '/clusters/:clusterId/configuration/config-groups/:groupId': { pageTitle: 'Clusters', breadcrumbs: [ diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.ts new file mode 100644 index 0000000000..f980ee01cd --- /dev/null +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.ts @@ -0,0 +1,95 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { AdcmClusterActionHostGroupsApi } from '@api'; +import { createAsyncThunk } from '@store/redux'; +import { executeWithMinDelay } from '@utils/requestUtils'; +import { defaultSpinnerDelay } from '@constants'; +import { LoadState } from '@models/loadState'; +import type { AdcmActionHostGroup } from '@models/adcm/actionHostGroup'; + +type AdcmClusterActionHostGroupsState = { + actionHostGroups: AdcmActionHostGroup[]; + totalCount: number; + loadState: LoadState; +}; + +type GetClusterActionHostGroupsArgs = { + clusterId: number; +}; + +type DeleteClusterActionHostsGroupArgs = { + clusterId: number; + actionHostGroupId: number; +}; + +const loadClusterActionHostGroupsFromBackend = createAsyncThunk( + 'adcm/clusterActionHostGroups/loadClusterActionHostGroupsFromBackend', + async (clusterId: number, thunkAPI) => { + try { + const batch = await AdcmClusterActionHostGroupsApi.getActionHostGroups(clusterId, { pageNumber: 0, perPage: 10 }); + return batch; + } catch (error) { + return thunkAPI.rejectWithValue(error); + } + }, +); + +const getClusterActionHostGroups = createAsyncThunk( + 'adcm/clusterActionHostGroups/getClusterActionHostGroups', + async ({ clusterId }: GetClusterActionHostGroupsArgs, thunkAPI) => { + thunkAPI.dispatch(setLoadState(LoadState.Loading)); + const startDate = new Date(); + + await thunkAPI.dispatch(loadClusterActionHostGroupsFromBackend(clusterId)); + + executeWithMinDelay({ + startDate, + delay: defaultSpinnerDelay, + callback: () => { + thunkAPI.dispatch(setLoadState(LoadState.Loaded)); + }, + }); + }, +); + +const deleteClusterActionHostGroup = createAsyncThunk( + 'adcm/clusterActionHostGroups/deleteClusterActionHostGroup', + async (args: DeleteClusterActionHostsGroupArgs, thunkAPI) => { + try { + await AdcmClusterActionHostGroupsApi.deleteActionHostGroup(args.clusterId, args.actionHostGroupId); + } catch (error) { + return thunkAPI.rejectWithValue(error); + } + }, +); + +const createInitialState = (): AdcmClusterActionHostGroupsState => ({ + actionHostGroups: [], + totalCount: 0, + loadState: LoadState.NotLoaded, +}); + +const clusterHostsSlice = createSlice({ + name: 'adcm/clusterActionHostGroups', + initialState: createInitialState(), + reducers: { + setLoadState(state, action) { + state.loadState = action.payload; + }, + cleanupClusterActionHostGroups() { + return createInitialState(); + }, + }, + extraReducers: (builder) => { + builder.addCase(loadClusterActionHostGroupsFromBackend.fulfilled, (state, action) => { + state.actionHostGroups = action.payload.results; + state.totalCount = action.payload.count; + }); + builder.addCase(loadClusterActionHostGroupsFromBackend.rejected, (state) => { + state.actionHostGroups = []; + }); + }, +}); + +const { setLoadState, cleanupClusterActionHostGroups } = clusterHostsSlice.actions; +export { getClusterActionHostGroups, cleanupClusterActionHostGroups, deleteClusterActionHostGroup }; +export default clusterHostsSlice.reducer; diff --git a/adcm-web/app/src/store/store.ts b/adcm-web/app/src/store/store.ts index e36c69e486..4b67d20d25 100644 --- a/adcm-web/app/src/store/store.ts +++ b/adcm-web/app/src/store/store.ts @@ -4,6 +4,7 @@ import authSlice from '@store/authSlice'; import notificationsSlice from '@store/notificationsSlice'; import clustersSlice from '@store/adcm/clusters/clustersSlice'; import clustersDynamicActionsSlice from '@store/adcm/clusters/clustersDynamicActionsSlice'; +import clusterActionHostGroupsSlice from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; import clusterUpgradesSlice from '@store/adcm/clusters/clusterUpgradesSlice'; import clusterHostsSlice from '@store/adcm/cluster/hosts/hostsSlice'; import clusterHostsTableSlice from '@store/adcm/cluster/hosts/hostsTableSlice'; @@ -114,6 +115,7 @@ const rootReducer = combineReducers({ clusterHost: clusterHostSlice, clusterHostsActions: clusterHostsActionsSlice, clusterHostsDynamicActions: clusterHostsDynamicActionsSlice, + clusterActionHostGroups: clusterActionHostGroupsSlice, hostComponentsDynamicActions: hostComponentsDynamicActionsSlice, clusterHostsTable: clusterHostsTableSlice, clusterMapping: clusterMappingSlice, From 24a62f80e58ed2cb2603686abf9e50eb47309152 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 16 Jul 2024 14:18:39 +0000 Subject: [PATCH 11/50] ADCM-5806 Change object for action's config detection to correctly prepare config for Action Host Group --- python/api_v2/action/views.py | 17 +++++++++++++---- .../cluster_action_host_group/config.yaml | 4 ++++ python/api_v2/tests/test_action_host_group.py | 12 +++++++++--- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/python/api_v2/action/views.py b/python/api_v2/action/views.py index 9779c2b395..0eebf1ca2a 100644 --- a/python/api_v2/action/views.py +++ b/python/api_v2/action/views.py @@ -15,7 +15,15 @@ from adcm.mixins import GetParentObjectMixin from audit.utils import audit from cm.errors import AdcmEx -from cm.models import ADCM, Action, ADCMEntity, ConcernType, Host, HostComponent, PrototypeConfig +from cm.models import ( + ADCM, + Action, + ADCMEntity, + ConcernType, + Host, + HostComponent, + PrototypeConfig, +) from cm.services.config.jinja import get_jinja_config from cm.services.job.action import ActionRunPayload, run_action from cm.stack import check_hostcomponents_objects_exist @@ -195,7 +203,7 @@ def retrieve(self, request, *args, **kwargs): # noqa: ARG002 self.check_permissions_for_run(request=request, action=action_) - config_schema, config, adcm_meta = get_action_configuration(action_=action_, object_=self.parent_object) + config_schema, config, adcm_meta = get_action_configuration(action_=action_, object_=self._get_actions_owner()) serializer = self.get_serializer_class()( instance=action_, @@ -214,10 +222,11 @@ def retrieve(self, request, *args, **kwargs): # noqa: ARG002 def run(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, ARG002 self.parent_object = self.get_parent_object() target_action = self.get_object() + action_owner = self._get_actions_owner() self.check_permissions_for_run(request=request, action=target_action) - if reason := target_action.get_start_impossible_reason(self.parent_object): + if reason := target_action.get_start_impossible_reason(action_owner): raise AdcmEx("ACTION_ERROR", msg=reason) serializer = self.get_serializer_class()(data=request.data) @@ -232,7 +241,7 @@ def run(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, A adcm_meta = configuration["adcm_meta"] if target_action.config_jinja: - prototype_configs, _ = get_jinja_config(action=target_action, cluster_relative_object=self.parent_object) + prototype_configs, _ = get_jinja_config(action=target_action, cluster_relative_object=action_owner) prototype_configs = [ prototype_config for prototype_config in prototype_configs if prototype_config.type == "json" ] diff --git a/python/api_v2/tests/bundles/cluster_action_host_group/config.yaml b/python/api_v2/tests/bundles/cluster_action_host_group/config.yaml index 9a2834a3db..d3fe25b430 100644 --- a/python/api_v2/tests/bundles/cluster_action_host_group/config.yaml +++ b/python/api_v2/tests/bundles/cluster_action_host_group/config.yaml @@ -15,6 +15,10 @@ allowed_in_group_1: &allowed <<: *job + config: + - name: val + type: integer + default: 4 allow_for_action_host_group: true - type: service diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py index 20357fe246..fec1af96c1 100644 --- a/python/api_v2/tests/test_action_host_group.py +++ b/python/api_v2/tests/test_action_host_group.py @@ -642,7 +642,9 @@ def test_run(self) -> None: TaskLog.objects.all().delete() with RunTaskMock() as run_task: - response = self.client.v2[action_run_target, "actions", action, "run"].post(data={}) + response = self.client.v2[action_run_target, "actions", action, "run"].post( + data={"configuration": {"config": {"val": 4}, "adcmMeta": {}}} + ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -897,7 +899,9 @@ def test_edit_role_on_service_access(self) -> None: self.assertEqual(response.status_code, HTTP_200_OK) with self.subTest(f"[{type_name}] RUN Action With Run Perms"), RunTaskMock(): - response = self.user_client.v2[group, "actions", action, "run"].post(data={}) + response = self.user_client.v2[group, "actions", action, "run"].post( + data={"configuration": {"config": {"val": 2}, "adcmMeta": {}}} + ) self.assertEqual(response.status_code, HTTP_200_OK) ConcernItem.objects.all().delete() @@ -974,7 +978,9 @@ def test_built_in_roles(self) -> None: action = Action.objects.get(prototype=self.component.prototype, name="allowed_from_component") with RunTaskMock(): - response = self.user_client.v2[self.group_map[self.component], "actions", action, "run"].post() + response = self.user_client.v2[self.group_map[self.component], "actions", action, "run"].post( + data={"configuration": {"config": {"val": 3}, "adcmMeta": {}}} + ) self.assertEqual(response.status_code, HTTP_200_OK) ConcernItem.objects.all().delete() From a78b866c56100599a78ea753314f61f34a33049b Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 17 Jul 2024 07:20:47 +0000 Subject: [PATCH 12/50] ADCM-5799 Change `adcm_custom_log` ansible plugin validation behavior --- python/ansible_plugin/executors/custom_log.py | 16 ++++++-- .../tests/test_adcm_custom_log.py | 41 +++++++++++++------ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/python/ansible_plugin/executors/custom_log.py b/python/ansible_plugin/executors/custom_log.py index 4cf38d83a7..63dfd40339 100644 --- a/python/ansible_plugin/executors/custom_log.py +++ b/python/ansible_plugin/executors/custom_log.py @@ -11,6 +11,7 @@ # limitations under the License. from abc import ABC, abstractmethod +from enum import Enum from pathlib import Path from typing import Callable, Collection @@ -31,18 +32,27 @@ from ansible_plugin.utils import assign_view_logstorage_permissions_by_job +class LogFormat(str, Enum): + JSON = "json" + TXT = "txt" + + class CustomLogArguments(BaseStrictModel): name: str - format: str + format: LogFormat path: Path | None = None content: str | None = None @model_validator(mode="after") - def check_either_is_specified(self) -> Self: + def check_path_and_content(self) -> Self: if self.path is None and self.content is None: message = "either `path` or `content` has to be specified" raise ValueError(message) + if self.path and self.content: + message = "`path` and `content` shouldn't be specified together" + raise ValueError(message) + return self @@ -65,7 +75,7 @@ def __call__( with atomic(): log = LogStorage.objects.create( - job_id=runtime.vars.job.id, name=arguments.name, type="custom", format=arguments.format, body=body + job_id=runtime.vars.job.id, name=arguments.name, type="custom", format=arguments.format.value, body=body ) assign_view_logstorage_permissions_by_job(log_storage=log) diff --git a/python/ansible_plugin/tests/test_adcm_custom_log.py b/python/ansible_plugin/tests/test_adcm_custom_log.py index ae473b90d1..7f60523790 100644 --- a/python/ansible_plugin/tests/test_adcm_custom_log.py +++ b/python/ansible_plugin/tests/test_adcm_custom_log.py @@ -15,6 +15,7 @@ from cm.models import LogStorage from cm.services.job.run.repo import JobRepoImpl +from ansible_plugin.errors import PluginValidationError from ansible_plugin.executors.custom_log import ADCMCustomLogPluginExecutor from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins @@ -71,28 +72,44 @@ def test_path_content(self) -> None: log = LogStorage.objects.filter(job_id=job.id, type="custom", format=format_, name=name).get() self.assertEqual(log.body, path) - def test_path_priority_over_content(self) -> None: - name = "cool name" - format_ = "txt" - content = "bestcontent ever !!!" - path = "/some/path" + def test_path_and_content_error(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=self.EXECUTOR_CLASS, + call_arguments={ + "name": "cool name", + "format": "txt", + "content": "bestcontent ever !!!", + "path": "/some/path", + }, + call_context=job, + ) + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("`path` and `content` shouldn't be specified together", result.error.message) + + def test_incorrect_format(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) executor = self.prepare_executor( executor_type=self.EXECUTOR_CLASS, - call_arguments={"name": name, "format": format_, "content": content, "path": path}, + call_arguments={ + "name": "cool name", + "format": "abababa", + "content": "bestcontent ever !!!", + }, call_context=job, ) - with patch(f"{EXECUTOR_MODULE}.assign_view_logstorage_permissions_by_job") as permissions_mock: - result = executor.execute() + result = executor.execute() - self.assertIsNone(result.error) - log = LogStorage.objects.filter(job_id=job.id, type="custom", format=format_, name=name).get() - self.assertEqual(log.body, path) - permissions_mock.assert_called_once() + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("format - Input should be", result.error.message) def test_forbidden_arg_fail(self): name = "cool name" From 70182188c9e896b43a57a144eb71423d09970dfe Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 23 Jul 2024 06:59:30 +0000 Subject: [PATCH 13/50] ADCM-5808 Add host candidate for new action host groups AND add filters for action host groups list --- python/api_v2/action_host_group/filters.py | 23 +++ python/api_v2/action_host_group/views.py | 44 ++++- python/api_v2/group_config/views.py | 52 ++++- python/api_v2/tests/test_action_host_group.py | 64 +++++++ python/api_v2/tests/test_group_config.py | 178 +++++++++++++++++- 5 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 python/api_v2/action_host_group/filters.py diff --git a/python/api_v2/action_host_group/filters.py b/python/api_v2/action_host_group/filters.py new file mode 100644 index 0000000000..6497853ed0 --- /dev/null +++ b/python/api_v2/action_host_group/filters.py @@ -0,0 +1,23 @@ +# 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. + +from cm.models import ActionHostGroup +from django_filters.rest_framework import CharFilter, FilterSet + + +class ActionHostGroupFilter(FilterSet): + name = CharFilter(field_name="name", label="Name", lookup_expr="icontains") + has_host = CharFilter(field_name="hosts", label="Group Has Host", lookup_expr="fqdn__icontains") + + class Meta: + model = ActionHostGroup + fields = ["name", "has_host"] diff --git a/python/api_v2/action_host_group/views.py b/python/api_v2/action_host_group/views.py index 5bbd4959b4..a349f5c4d9 100644 --- a/python/api_v2/action_host_group/views.py +++ b/python/api_v2/action_host_group/views.py @@ -36,6 +36,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import F, Model, QuerySet from django.db.transaction import atomic +from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, extend_schema_view from guardian.shortcuts import get_objects_for_user from rbac.models import User @@ -48,6 +49,7 @@ HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, + HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, ) @@ -55,6 +57,7 @@ from api_v2.action.serializers import ActionListSerializer, ActionRetrieveSerializer from api_v2.action.utils import has_run_perms from api_v2.action.views import ActionViewSet +from api_v2.action_host_group.filters import ActionHostGroupFilter from api_v2.action_host_group.serializers import ( ActionHostGroupCreateResultSerializer, ActionHostGroupCreateSerializer, @@ -150,6 +153,16 @@ def check_has_group_permissions(user: User, parent: CoreObjectDescriptor, dto: P description="Delete specific object's action host group.", responses={HTTP_204_NO_CONTENT: None, HTTP_404_NOT_FOUND: ErrorSerializer, HTTP_409_CONFLICT: ErrorSerializer}, ), + owner_host_candidate=extend_schema( + operation_id="getObjectActionHostGroupOwnCandidates", + summary="GET object's host candidates for new Action Host Group", + description="Return list of object's hosts that can be added to newly created action host group.", + responses={ + HTTP_200_OK: ShortHostSerializer(many=True), + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, + ), host_candidate=extend_schema( operation_id="getObjectActionHostGroupCandidates", summary="GET object's Action Host Group's host candidates", @@ -159,8 +172,10 @@ def check_has_group_permissions(user: User, parent: CoreObjectDescriptor, dto: P ) class ActionHostGroupViewSet(ADCMGenericViewSet): queryset = ActionHostGroup.objects.prefetch_related("hosts").order_by("id") - action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) - filter_backends = [] + repo = ActionHostGroupRepo() + action_host_group_service = ActionHostGroupService(repository=repo) + filter_backends = (DjangoFilterBackend,) + filterset_class = ActionHostGroupFilter def get_serializer_class(self) -> type[Serializer]: if self.action == "create": @@ -201,7 +216,10 @@ def list(self, request: Request, parent: CoreObjectDescriptor, **__) -> Response check_has_group_permissions(user=request.user, parent=parent, dto=VIEW_ONLY_PERMISSION_DENIED) serializer = self.get_serializer( - instance=self.paginate_queryset(self.filter_by_parent(qs=self.get_queryset(), parent=parent)), many=True + instance=self.paginate_queryset( + self.filter_queryset(self.filter_by_parent(qs=self.get_queryset(), parent=parent)) + ), + many=True, ) return self.get_paginated_response(serializer.data) @@ -231,6 +249,26 @@ def destroy(self, request: Request, parent: CoreObjectDescriptor, pk: str, **__) return Response(status=HTTP_204_NO_CONTENT) + @action( + methods=["get"], detail=False, url_path="host-candidates", url_name="host-candidates", pagination_class=None + ) + @with_parent_object + def owner_host_candidate(self, request: Request, parent: CoreObjectDescriptor, **__): + check_has_group_permissions(user=request.user, parent=parent, dto=VIEW_ONLY_PERMISSION_DENIED) + + match parent.type: + case ADCMCoreType.CLUSTER: + host_ids = self.repo.get_all_host_candidates_for_cluster(cluster_id=parent.id) + case ADCMCoreType.SERVICE: + host_ids = self.repo.get_all_host_candidates_for_service(service_id=parent.id) + case ADCMCoreType.COMPONENT: + host_ids = self.repo.get_all_host_candidates_for_component(component_id=parent.id) + case _: + message = f"Can't get host candidates for {parent.type}" + raise RuntimeError(message) + + return Response(data=list(Host.objects.values("id", name=F("fqdn")).filter(id__in=host_ids).order_by("fqdn"))) + @action(methods=["get"], detail=True, url_path="host-candidates", url_name="host-candidates", pagination_class=None) @with_parent_object def host_candidate(self, request: Request, parent: CoreObjectDescriptor, pk: str, **__): diff --git a/python/api_v2/group_config/views.py b/python/api_v2/group_config/views.py index 48155c62ef..f11abdaaa4 100644 --- a/python/api_v2/group_config/views.py +++ b/python/api_v2/group_config/views.py @@ -14,7 +14,7 @@ from adcm.permissions import VIEW_GROUP_CONFIG_PERM, check_config_perm from audit.utils import audit from cm.errors import AdcmEx -from cm.models import GroupConfig +from cm.models import Cluster, ClusterObject, GroupConfig, Host, HostProvider, ServiceComponent from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema, extend_schema_view @@ -31,7 +31,7 @@ from api_v2.config.utils import ConfigSchemaMixin from api_v2.group_config.permissions import GroupConfigPermissions from api_v2.group_config.serializers import GroupConfigSerializer -from api_v2.host.serializers import HostGroupConfigSerializer +from api_v2.host.serializers import HostGroupConfigSerializer, HostShortSerializer from api_v2.views import ADCMGenericViewSet @@ -66,6 +66,12 @@ description="Delete specific object's config-group.", responses={HTTP_204_NO_CONTENT: None, HTTP_404_NOT_FOUND: ErrorSerializer}, ), + owner_host_candidates=extend_schema( + operation_id="getObjectConfigGroupHostOwnCandidates", + summary="GET object's host candidates for new config group", + description="Get a list of hosts available for adding to object's new config group.", + responses={HTTP_200_OK: HostShortSerializer(many=True), HTTP_404_NOT_FOUND: ErrorSerializer}, + ), host_candidates=extend_schema( operation_id="getObjectConfigGroupHostCandidates", summary="GET object's config-group host candidates", @@ -127,6 +133,48 @@ def create(self, request: Request, *args, **kwargs): # noqa: ARG002 return Response(data=self.get_serializer(group_config).data, status=HTTP_201_CREATED) + @action( + methods=["get"], detail=False, url_path="host-candidates", url_name="host-candidates", pagination_class=None + ) + def owner_host_candidates(self, request: Request, *_, **__): # noqa: ARG002 + parent_object = self.get_parent_object() + + self._check_parent_permissions(parent_object=parent_object) + + # taken from GroupConfig.host_candidate + if isinstance(parent_object, (Cluster, HostProvider)): + hosts_qs = parent_object.host_set + elif isinstance(parent_object, ClusterObject): + hosts_qs = Host.objects.filter( + cluster_id=parent_object.cluster_id, hostcomponent__service=parent_object + ).distinct() + elif isinstance(parent_object, ServiceComponent): + hosts_qs = Host.objects.filter( + cluster_id=parent_object.cluster_id, hostcomponent__component=parent_object + ).distinct() + else: + raise AdcmEx("GROUP_CONFIG_TYPE_ERROR") + + taken_host_id_qs = ( + GroupConfig.hosts.through.objects.values_list("host_id", flat=True) + .filter( + groupconfig_id__in=( + GroupConfig.objects.values_list("id", flat=True).filter( + object_id=parent_object.id, + object_type=ContentType.objects.get_for_model(model=parent_object.__class__), + ) + ) + ) + .distinct() + ) + + return Response( + data=HostShortSerializer( + instance=hosts_qs.values("id", "fqdn").exclude(id__in=taken_host_id_qs), many=True + ).data, + status=HTTP_200_OK, + ) + @action(methods=["get"], detail=True, url_path="host-candidates", url_name="host-candidates", pagination_class=None) def host_candidates(self, request: Request, *args, **kwargs): # noqa: ARG001, ARG002 group_config: GroupConfig = self.get_object() diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py index fec1af96c1..30a6297c06 100644 --- a/python/api_v2/tests/test_action_host_group.py +++ b/python/api_v2/tests/test_action_host_group.py @@ -278,6 +278,51 @@ def test_list_success(self) -> None: ], ) + def test_filter_groups_success(self) -> None: + host_1, host_2, host_3, *_ = self.hosts + + cluster_group = self.create_action_host_group(name="Cluster Group", owner=self.cluster) + group_1 = self.create_action_host_group(name="Service Group", owner=self.service) + group_2 = self.create_action_host_group(name="Super Custom", owner=self.service) + group_3 = self.create_action_host_group(name="Service Group #2", owner=self.service) + + self.set_hostcomponent(cluster=self.cluster, entries=[(host, self.component) for host in self.hosts]) + + self.action_host_group_service.add_hosts_to_group(cluster_group.id, hosts=[host_1.id, host_2.id, host_3.id]) + self.action_host_group_service.add_hosts_to_group(group_1.id, hosts=[host_1.id, host_2.id]) + self.action_host_group_service.add_hosts_to_group(group_2.id, hosts=[host_2.id, host_3.id]) + self.action_host_group_service.add_hosts_to_group(group_3.id, hosts=[host_1.id]) + + endpoint = self.client.v2[self.service, ACTION_HOST_GROUPS] + + with self.subTest("Filter by Name"): + response = endpoint.get(query={"name": "group"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(list(map(itemgetter("id"), response.json()["results"])), [group_1.id, group_3.id]) + + response = endpoint.get(query={"name": "er c"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(list(map(itemgetter("id"), response.json()["results"])), [group_2.id]) + + with self.subTest("Filter by Host"): + response = endpoint.get(query={"hasHost": "3"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(response.json()["results"], []) + + response = endpoint.get(query={"hasHost": "0"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(list(map(itemgetter("id"), response.json()["results"])), [group_1.id, group_3.id]) + + with self.subTest("Filter by Name AND Host"): + response = endpoint.get(query={"hasHost": "0", "name": "#2"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(list(map(itemgetter("id"), response.json()["results"])), [group_3.id]) + def test_host_candidates_success(self) -> None: host_1, host_2, host_3 = self.hosts host_1_data, host_2_data, host_3_data = ({"id": host.id, "name": host.fqdn} for host in self.hosts) @@ -305,6 +350,19 @@ def test_host_candidates_success(self) -> None: self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual(response.json(), expected) + for target, expected in ( + (self.cluster, [host_1_data, host_2_data, host_3_data]), + (self.service, [host_1_data, host_2_data]), + (self.component, [host_1_data, host_2_data]), + ): + type_ = orm_object_to_core_type(target) + + with self.subTest(f"[{type_.name}] Own {target.name} Expect {len(expected)}"): + response = self.client.v2[target, ACTION_HOST_GROUPS, "host-candidates"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(response.json(), expected) + def test_adcm_5689_string_pk_500(self) -> None: response = self.client.v2[self.cluster, ACTION_HOST_GROUPS, "s"].get() @@ -827,6 +885,7 @@ def test_view_role_on_cluster_access(self) -> None: for allowed_to_view_ep in ( self.user_client.v2[target, ACTION_HOST_GROUPS], + self.user_client.v2[target, ACTION_HOST_GROUPS, "host-candidates"], self.user_client.v2[group], self.user_client.v2[group, "host-candidates"], self.user_client.v2[group, "actions"], @@ -854,6 +913,7 @@ def test_view_role_on_cluster_access(self) -> None: (self.user_client.v2[group, "host-candidates"], "get"), (self.user_client.v2[group, "actions"], "get"), (self.user_client.v2[group, "actions", action], "get"), + (self.user_client.v2[target, ACTION_HOST_GROUPS, "host-candidates"], "get"), (self.user_client.v2[target, ACTION_HOST_GROUPS], "post"), (self.user_client.v2[group, "actions", action, "run"], "post"), (self.user_client.v2[group, "hosts"], "post"), @@ -874,6 +934,7 @@ def test_edit_role_on_service_access(self) -> None: with self.subTest(f"[{type_name}] GET EPs"): for ep in ( self.user_client.v2[target, ACTION_HOST_GROUPS], + self.user_client.v2[target, ACTION_HOST_GROUPS, "host-candidates"], self.user_client.v2[group], self.user_client.v2[group, "host-candidates"], ): @@ -929,6 +990,9 @@ def test_edit_role_on_service_access(self) -> None: response = self.user_client.v2[target, ACTION_HOST_GROUPS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + response = self.user_client.v2[target, ACTION_HOST_GROUPS, "host-candidates"].get() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + response = self.user_client.v2[target, ACTION_HOST_GROUPS].post() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) diff --git a/python/api_v2/tests/test_group_config.py b/python/api_v2/tests/test_group_config.py index 990b3bbf35..729e51d152 100644 --- a/python/api_v2/tests/test_group_config.py +++ b/python/api_v2/tests/test_group_config.py @@ -10,8 +10,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from cm.models import ConfigLog, GroupConfig, Host, ServiceComponent +from operator import attrgetter, itemgetter +from typing import Iterable, NamedTuple, TypeAlias + +from adcm.tests.client import WithID +from cm.converters import orm_object_to_core_type +from cm.models import Cluster, ClusterObject, ConfigLog, GroupConfig, Host, HostProvider, ServiceComponent from django.contrib.contenttypes.models import ContentType +from rest_framework.response import Response from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -24,6 +30,9 @@ from api_v2.tests.base import BaseAPITestCase CONFIG_GROUPS = "config-groups" +HOST_CANDIDATES = "host-candidates" + +ObjectWithConfigHostGroup: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider class BaseClusterGroupConfigTestCase(BaseAPITestCase): @@ -207,11 +216,30 @@ def test_add_host_from_another_group_config_fail(self): ) def test_host_candidates(self): - response = self.client.v2[self.cluster_1_group_config, "host-candidates"].get() + with self.subTest("Group"): + response = self.client.v2[self.cluster_1_group_config, "host-candidates"].get() - self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.json()), 1) - self.assertEqual(response.json()[0]["name"], self.new_host.name) + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["name"], self.new_host.name) + + with self.subTest("Own Group"): + response = self.client.v2[self.cluster_1, CONFIG_GROUPS, "host-candidates"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["name"], self.new_host.name) + + new_host = self.add_host(provider=self.provider, fqdn="new-host", cluster=self.cluster_1) + response = self.client.v2[self.cluster_1_group_config, "hosts"].post(data={"hostId": self.new_host.pk}) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + with self.subTest("Own Group When One Added To Group"): + response = self.client.v2[self.cluster_1, CONFIG_GROUPS, "host-candidates"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["name"], new_host.name) def test_delete_host_success(self): response = self.client.v2[self.cluster_1_group_config, "hosts", self.host].delete() @@ -1043,3 +1071,143 @@ def test_permissions_cluster_another_object_role_list_denied(self): response = self.client.v2[self.cluster_1, CONFIG_GROUPS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + +class TestHostCandidateForConfigHostsGroups(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + + self.cluster = self.cluster_1 + self.service = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1).get() + self.component_1 = self.service.servicecomponent_set.get(prototype__name="component_1") + self.component_2 = self.service.servicecomponent_set.get(prototype__name="component_2") + + self.hostprovider = self.provider + self.hosts = tuple(self.add_host(provider=self.hostprovider, fqdn=f"host-{i}") for i in range(4)) + + class Case(NamedTuple): + target: ObjectWithConfigHostGroup + # hosts to group + add_to_first: tuple[Host, ...] + add_to_second: tuple[Host, ...] + # candidates + at_start: tuple[Host, ...] + after_one_added: tuple[Host, ...] + after_second_added: tuple[Host, ...] + + @staticmethod + def extract_ids(source: Response | Iterable[WithID]) -> list[int]: + if isinstance(source, Response): + data = source.json() + getter = itemgetter + else: + data = source + getter = attrgetter + + return list(map(getter("id"), data)) + + def create_group_and_add_hosts_via_api( + self, object_: ObjectWithConfigHostGroup, name: str, hosts: Iterable[Host] + ) -> GroupConfig: + response = self.client.v2[object_, CONFIG_GROUPS].post(data={"name": name, "description": ""}) + self.assertEqual(response.status_code, HTTP_201_CREATED) + group_id = response.json()["id"] + + for host in hosts: + self.assertEqual( + self.client.v2[object_, CONFIG_GROUPS, group_id, "hosts"].post(data={"hostId": host.id}).status_code, + HTTP_201_CREATED, + ) + + return GroupConfig.objects.get(id=group_id) + + def check_host_candidates_of_object(self, object_: ObjectWithConfigHostGroup, hosts: Iterable[Host]) -> None: + response = self.client.v2[object_, CONFIG_GROUPS, HOST_CANDIDATES].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(self.extract_ids(response), self.extract_ids(hosts)) + + def check_host_candidates_of_group(self, group: GroupConfig, hosts: Iterable[Host]) -> None: + response = self.client.v2[group, HOST_CANDIDATES].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(self.extract_ids(response), self.extract_ids(hosts)) + + def test_host_candidates(self) -> None: + # prepare + host_1, host_2, host_3, host_4 = self.hosts + for host in self.hosts: + self.add_host_to_cluster(cluster=self.cluster, host=host) + self.set_hostcomponent( + cluster=self.cluster, + entries=[ + (host_1, self.component_1), + (host_2, self.component_1), + (host_3, self.component_1), + (host_2, self.component_2), + (host_4, self.component_2), + ], + ) + + # cases + cases = ( + self.Case( + target=self.cluster, + at_start=self.hosts, + add_to_first=(host_1,), + after_one_added=(host_2, host_3, host_4), + add_to_second=(host_2, host_3), + after_second_added=(host_4,), + ), + self.Case( + target=self.service, + at_start=self.hosts, + add_to_first=(host_2, host_4), + after_one_added=(host_1, host_3), + add_to_second=(host_1, host_3), + after_second_added=(), + ), + self.Case( + target=self.component_1, + at_start=(host_1, host_2, host_3), + add_to_first=(), + after_one_added=(host_1, host_2, host_3), + add_to_second=(host_2,), + after_second_added=(host_1, host_3), + ), + self.Case( + target=self.hostprovider, + at_start=self.hosts, + add_to_first=(host_1, host_3, host_4), + after_one_added=(host_2,), + add_to_second=(), + after_second_added=(host_2,), + ), + ) + + # test + for case in cases: + target_desc = orm_object_to_core_type(case.target).name + with self.subTest(f"[{target_desc}] No Groups"): + self.check_host_candidates_of_object(object_=case.target, hosts=case.at_start) + + group_1 = self.create_group_and_add_hosts_via_api( + object_=case.target, name=f"{target_desc} 1", hosts=case.add_to_first + ) + + with self.subTest( + f"[{target_desc}] One Group | {len(case.add_to_first)} Hosts | {len(case.after_one_added)} Candidates" + ): + self.check_host_candidates_of_object(object_=case.target, hosts=case.after_one_added) + self.check_host_candidates_of_group(group=group_1, hosts=case.after_one_added) + + group_2 = self.create_group_and_add_hosts_via_api( + object_=case.target, name=f"{target_desc} 2", hosts=case.add_to_second + ) + + with self.subTest( + f"[{target_desc}] One Group | {len(case.add_to_first)} Hosts | {len(case.after_one_added)} Candidates" + ): + self.check_host_candidates_of_object(object_=case.target, hosts=case.after_second_added) + self.check_host_candidates_of_group(group=group_1, hosts=case.after_second_added) + self.check_host_candidates_of_group(group=group_2, hosts=case.after_second_added) From 1844e86a43620bc50276926be91450a7fc757d9d Mon Sep 17 00:00:00 2001 From: Dmitriy Bardin Date: Wed, 24 Jul 2024 18:51:43 +0000 Subject: [PATCH 14/50] ADCM-5728 - [UI] Rework Configuration groups tab https://tracker.yandex.ru/ADCM-5728 Only 1 point and background change --- .../ConfigGroupCreateDialog.module.scss | 9 +++++++ .../ConfigGroupCreateDialog.tsx | 2 ++ .../ConfigGroupMappingDialog.module.scss | 7 ++++++ .../HostProviderConfigurationGroups.tsx | 25 +++++++++++-------- .../ClusterConfigGroups.tsx | 25 +++++++++++-------- .../ServiceConfigurationGroups.tsx | 24 ++++++++++-------- .../ServiceComponentConfigurationGroups.tsx | 24 ++++++++++-------- 7 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 adcm-web/app/src/components/common/configGroups/ConfigGroupCreateDialog/ConfigGroupCreateDialog.module.scss diff --git a/adcm-web/app/src/components/common/configGroups/ConfigGroupCreateDialog/ConfigGroupCreateDialog.module.scss b/adcm-web/app/src/components/common/configGroups/ConfigGroupCreateDialog/ConfigGroupCreateDialog.module.scss new file mode 100644 index 0000000000..a9a5b385e8 --- /dev/null +++ b/adcm-web/app/src/components/common/configGroups/ConfigGroupCreateDialog/ConfigGroupCreateDialog.module.scss @@ -0,0 +1,9 @@ +:global { + body.theme-dark { + --dialog-create-background: var(--color-xblack); + } +} + +.configGroupCreateDialog { + background: var(--dialog-create-background); +} diff --git a/adcm-web/app/src/components/common/configGroups/ConfigGroupCreateDialog/ConfigGroupCreateDialog.tsx b/adcm-web/app/src/components/common/configGroups/ConfigGroupCreateDialog/ConfigGroupCreateDialog.tsx index d323e575ae..b748c5507a 100644 --- a/adcm-web/app/src/components/common/configGroups/ConfigGroupCreateDialog/ConfigGroupCreateDialog.tsx +++ b/adcm-web/app/src/components/common/configGroups/ConfigGroupCreateDialog/ConfigGroupCreateDialog.tsx @@ -3,6 +3,7 @@ import { Dialog, FormField, FormFieldsContainer, Input } from '@uikit'; import { AdcmClusterConfigGroupCreateData } from '@api/adcm/clusterGroupConfig'; import { useForm } from '@hooks'; import { required } from '@utils/validationsUtils'; +import s from './ConfigGroupCreateDialog.module.scss'; interface ConfigGroupCreateDialogProps { isCreating: boolean; @@ -51,6 +52,7 @@ const ConfigGroupCreateDialog: React.FC = ({ isCre actionButtonLabel="Next" onCancel={onClose} onAction={handleSubmit} + className={s.configGroupCreateDialog} > diff --git a/adcm-web/app/src/components/common/configGroups/ConfigGroupMappingDialog/ConfigGroupMappingDialog.module.scss b/adcm-web/app/src/components/common/configGroups/ConfigGroupMappingDialog/ConfigGroupMappingDialog.module.scss index 5a71043ed1..64c3a564fe 100644 --- a/adcm-web/app/src/components/common/configGroups/ConfigGroupMappingDialog/ConfigGroupMappingDialog.module.scss +++ b/adcm-web/app/src/components/common/configGroups/ConfigGroupMappingDialog/ConfigGroupMappingDialog.module.scss @@ -1,4 +1,11 @@ +:global { + body.theme-dark { + --dialog-mapping-background: var(--color-xblack); + } +} + .configGroupMappingDialog { + background: var(--dialog-mapping-background); max-height: 100%; display: grid; // dialog title - min-content diff --git a/adcm-web/app/src/components/pages/HostProviderPage/HostProviderConfigurationGroups/HostProviderConfigurationGroups.tsx b/adcm-web/app/src/components/pages/HostProviderPage/HostProviderConfigurationGroups/HostProviderConfigurationGroups.tsx index 7723e12393..caaef98f04 100644 --- a/adcm-web/app/src/components/pages/HostProviderPage/HostProviderConfigurationGroups/HostProviderConfigurationGroups.tsx +++ b/adcm-web/app/src/components/pages/HostProviderPage/HostProviderConfigurationGroups/HostProviderConfigurationGroups.tsx @@ -45,17 +45,20 @@ const HostProviderConfigurationGroups: React.FC = () => { return ( - - - + {hostProviderConfigGroups.length > 0 && ( + <> + + + + )} ); diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigGroups/ClusterConfigGroups.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigGroups/ClusterConfigGroups.tsx index 0147ec6a55..f30186ea0e 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigGroups/ClusterConfigGroups.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigGroups/ClusterConfigGroups.tsx @@ -62,17 +62,20 @@ const ClusterConfigGroups: React.FC = () => {
- - - + {clusterConfigGroups.length > 0 && ( + <> + + + + )}
diff --git a/adcm-web/app/src/components/pages/cluster/service/ServiceConfiguration/ServiceConfigurationGroups/ServiceConfigurationGroups.tsx b/adcm-web/app/src/components/pages/cluster/service/ServiceConfiguration/ServiceConfigurationGroups/ServiceConfigurationGroups.tsx index 6d24c019aa..e7f08141dd 100644 --- a/adcm-web/app/src/components/pages/cluster/service/ServiceConfiguration/ServiceConfigurationGroups/ServiceConfigurationGroups.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/ServiceConfiguration/ServiceConfigurationGroups/ServiceConfigurationGroups.tsx @@ -46,16 +46,20 @@ const ServiceConfigurationGroups: React.FC = () => { return ( - - + {clusterServiceConfigGroups.length > 0 && ( + <> + + + + )} diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ServiceComponentConfiguration/ServiceComponentConfigGroups/ServiceComponentConfigurationGroups.tsx b/adcm-web/app/src/components/pages/cluster/service/component/ServiceComponentConfiguration/ServiceComponentConfigGroups/ServiceComponentConfigurationGroups.tsx index 404eafa43f..86613ca571 100644 --- a/adcm-web/app/src/components/pages/cluster/service/component/ServiceComponentConfiguration/ServiceComponentConfigGroups/ServiceComponentConfigurationGroups.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/component/ServiceComponentConfiguration/ServiceComponentConfigGroups/ServiceComponentConfigurationGroups.tsx @@ -68,16 +68,20 @@ const ServiceComponentConfigurationGroups: React.FC = () => { return ( - - + {clusterServiceConfigGroups.length > 0 && ( + <> + + + + )} From 706e3a81c4b033d86ac2ac489f8460e26a6200f5 Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Mon, 5 Aug 2024 09:05:23 +0000 Subject: [PATCH 15/50] ADCM-5827 [UI] Fix concern routes https://tracker.yandex.ru/ADCM-5827 https://tracker.yandex.ru/ADCM-5824 --- adcm-web/app/src/utils/concernUtils.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/adcm-web/app/src/utils/concernUtils.ts b/adcm-web/app/src/utils/concernUtils.ts index 0dea6cbfec..314ceb0b1f 100644 --- a/adcm-web/app/src/utils/concernUtils.ts +++ b/adcm-web/app/src/utils/concernUtils.ts @@ -8,7 +8,7 @@ export interface ConcernObjectPathsData { const concernTypeUrlDict: Record = { [AdcmConcernType.AdcmConfig]: '', - [AdcmConcernType.ClusterConfig]: '/clusters/:clusterId/primary-configuration/', + [AdcmConcernType.ClusterConfig]: '/clusters/:clusterId/configuration/primary-configuration', [AdcmConcernType.ClusterImport]: '/clusters/:clusterId/import/', [AdcmConcernType.ServiceConfig]: '/clusters/:clusterId/services/:serviceId/primary-configuration/', [AdcmConcernType.ComponentConfig]: @@ -41,9 +41,10 @@ export const getConcernLinkObjectPathsDataArray = ( Object.entries(concern.reason.placeholder).forEach(([key, placeholderItem]) => { const generatedPath = generatePath(concernTypeUrlDict[placeholderItem.type], placeholderItem.params); + const clusterServiceId = (placeholderItem as AdcmConcernServicePlaceholder).params.serviceId; const path = - placeholderItem.type === AdcmConcernType.ClusterImport - ? `${generatedPath}/services/?serviceId=${(placeholderItem as AdcmConcernServicePlaceholder).params.serviceId}` + placeholderItem.type === AdcmConcernType.ClusterImport && clusterServiceId + ? `${generatedPath}/services/?serviceId=${clusterServiceId}` : generatedPath; linksDataMap.set(key, { path, From bed06cd6751c4606f141b55ee236ea7bef03d84b Mon Sep 17 00:00:00 2001 From: Pavel Nesterovkiy Date: Mon, 5 Aug 2024 09:35:21 +0000 Subject: [PATCH 16/50] bugfix/ADCM-5825 cluster import wrong error condition https://tracker.yandex.ru/ADCM-5825 --- .../ClusterImport/ClusterImportCard/ClusterImportCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportCard/ClusterImportCard.tsx b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportCard/ClusterImportCard.tsx index cb659f13f0..3068101ffc 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportCard/ClusterImportCard.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportCard/ClusterImportCard.tsx @@ -83,7 +83,7 @@ const ClusterImportCard = ({ // If cluster required for import and there is not selected cluster with same name const isClusterRequired = clusterImport.importCluster?.isRequired && - isItemSelected([...selectedImports.clusters.values()], clusterImport.importCluster.prototype.name); + !isItemSelected([...selectedImports.clusters.values()], clusterImport.importCluster.prototype.name); const isClusterSelected = !!(clusterImport.importCluster && selectedImports.clusters.has(clusterImport.cluster.id)); const clusterCheckHandler = () => { From 09a41bf3ecf7df415947dd92c1087054947f8faa Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 6 Aug 2024 11:40:56 +0000 Subject: [PATCH 17/50] ADCM-5823 Disable pipelining --- python/cm/services/job/run/_target_factories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index 444ea5c99b..800dea3bdd 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -305,7 +305,7 @@ def prepare_ansible_cfg(task: Task) -> ConfigParser: "callback_whitelist": "profile_tasks", "stdout_callback": "yaml", } - config_parser["ssh_connection"] = {"retries": "3", "pipelining": True} + config_parser["ssh_connection"] = {"retries": "3"} if task.owner.type in {ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT}: cluster_id = task.owner.id if task.owner.type == ADCMCoreType.CLUSTER else task.owner.related_objects.cluster.id From 37d5638b9cebd37ec6aa86ad38f5f37a3a3a03de Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Wed, 7 Aug 2024 17:25:11 +0500 Subject: [PATCH 18/50] ADCM-5846 Remove flags from hosts on cluster delete --- python/api/host/serializers.py | 4 ++-- python/api_v2/cluster/utils.py | 4 ++-- python/cm/api.py | 10 +++++----- python/cm/issue.py | 6 +++--- python/cm/services/maintenance_mode.py | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/python/api/host/serializers.py b/python/api/host/serializers.py index 91c127eb0d..e490c88c11 100644 --- a/python/api/host/serializers.py +++ b/python/api/host/serializers.py @@ -13,7 +13,7 @@ from adcm.serializers import EmptySerializer from cm.adcm_config.config import get_main_info from cm.api import add_host -from cm.issue import update_hierarchy_issues, update_issue_after_deleting +from cm.issue import update_hierarchy_issues, update_issues_and_flags_after_deleting from cm.models import ( MAINTENANCE_MODE_BOTH_CASES_CHOICES, Action, @@ -120,7 +120,7 @@ def update(self, instance, validated_data): update_hierarchy_issues(instance.cluster) update_hierarchy_issues(instance.provider) - update_issue_after_deleting() + update_issues_and_flags_after_deleting() return instance diff --git a/python/api_v2/cluster/utils.py b/python/api_v2/cluster/utils.py index 776e202677..8c7cc88dea 100644 --- a/python/api_v2/cluster/utils.py +++ b/python/api_v2/cluster/utils.py @@ -31,7 +31,7 @@ check_components_mapping_contraints, remove_concern_from_object, update_hierarchy_issues, - update_issue_after_deleting, + update_issues_and_flags_after_deleting, ) from cm.models import ( Cluster, @@ -283,7 +283,7 @@ def _save_mapping(mapping_data: MappingData) -> QuerySet[HostComponent]: update_hierarchy_issues(obj=mapping_data.orm_objects["cluster"]) for provider_id in {host.provider_id for host in mapping_data.hosts.values()}: update_hierarchy_issues(obj=mapping_data.orm_objects["providers"][provider_id]) - update_issue_after_deleting() + update_issues_and_flags_after_deleting() _handle_mapping_policies(mapping_data=mapping_data) send_host_component_map_update_event(cluster=mapping_data.orm_objects["cluster"]) diff --git a/python/cm/api.py b/python/cm/api.py index 8747bab8bb..b5f1b08252 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -42,7 +42,7 @@ check_service_requires, remove_concern_from_object, update_hierarchy_issues, - update_issue_after_deleting, + update_issues_and_flags_after_deleting, ) from cm.logger import logger from cm.models import ( @@ -216,7 +216,7 @@ def delete_host(host: Host, cancel_tasks: bool = True) -> None: host.delete() reset_hc_map() reset_objects_in_mm() - update_issue_after_deleting() + update_issues_and_flags_after_deleting() logger.info("host #%s is deleted", host_pk) @@ -224,7 +224,7 @@ def delete_service(service: ClusterObject) -> None: service_pk = service.pk service.delete() - update_issue_after_deleting() + update_issues_and_flags_after_deleting() update_hierarchy_issues(service.cluster) keep_objects = defaultdict(set) @@ -260,7 +260,7 @@ def delete_cluster(cluster: Cluster) -> None: ", ".join(host_pks), ) cluster.delete() - update_issue_after_deleting() + update_issues_and_flags_after_deleting() reset_hc_map() reset_objects_in_mm() @@ -575,7 +575,7 @@ def save_hc( for provider in {host.provider for host in Host.objects.filter(cluster=cluster)}: update_hierarchy_issues(provider) - update_issue_after_deleting() + update_issues_and_flags_after_deleting() reset_hc_map() reset_objects_in_mm() diff --git a/python/cm/issue.py b/python/cm/issue.py index 65e516fffc..c4036ca1e2 100755 --- a/python/cm/issue.py +++ b/python/cm/issue.py @@ -490,9 +490,9 @@ def update_hierarchy_issues(obj: ADCMEntity) -> None: recheck_issues(obj=node.value) -def update_issue_after_deleting() -> None: - """Remove issues which have no owners after object deleting""" - for concern in ConcernItem.objects.filter(type=ConcernType.ISSUE): +def update_issues_and_flags_after_deleting() -> None: + """Remove issues and flags which have no owners after object deleting""" + for concern in ConcernItem.objects.filter(type__in=(ConcernType.ISSUE, ConcernType.FLAG)): tree = Tree(obj=concern.owner) affected = {node.value for node in tree.get_directly_affected(node=tree.built_from)} related = set(concern.related_objects) diff --git a/python/cm/services/maintenance_mode.py b/python/cm/services/maintenance_mode.py index 82b776b165..7a44fbc29e 100644 --- a/python/cm/services/maintenance_mode.py +++ b/python/cm/services/maintenance_mode.py @@ -15,7 +15,7 @@ from rest_framework.serializers import Serializer from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_409_CONFLICT -from cm.issue import update_hierarchy_issues, update_issue_after_deleting +from cm.issue import update_hierarchy_issues, update_issues_and_flags_after_deleting from cm.models import ( Action, ClusterObject, @@ -60,7 +60,7 @@ def _update_mm_hierarchy_issues(obj: Host | ClusterObject | ServiceComponent) -> update_hierarchy_issues(provider) update_hierarchy_issues(obj.cluster) - update_issue_after_deleting() + update_issues_and_flags_after_deleting() _update_flags() reset_objects_in_mm() From 537b0f125626e7394caf40a6ab038dc700bae8d0 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 9 Aug 2024 07:36:18 +0000 Subject: [PATCH 19/50] ADCM-5854 Added tests on `bound_to` --- .../tests/bundles/cluster_one/config.yaml | 8 +++ .../bundles/cluster_one_upgrade/config.yaml | 12 ++++ python/api_v2/tests/test_cluster.py | 4 +- python/api_v2/tests/test_mapping.py | 69 +++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/python/api_v2/tests/bundles/cluster_one/config.yaml b/python/api_v2/tests/bundles/cluster_one/config.yaml index 4f78bc5969..2286f99266 100644 --- a/python/api_v2/tests/bundles/cluster_one/config.yaml +++ b/python/api_v2/tests/bundles/cluster_one/config.yaml @@ -259,3 +259,11 @@ - name: listofstuff type: list required: false + +- name: service_with_bound_to + type: service + version: "hehe" + + components: + will_have_bound_to: + description: This component will have `bound_to` constraint after upgrade diff --git a/python/api_v2/tests/bundles/cluster_one_upgrade/config.yaml b/python/api_v2/tests/bundles/cluster_one_upgrade/config.yaml index c2db7cdfd7..cef3e898a9 100644 --- a/python/api_v2/tests/bundles/cluster_one_upgrade/config.yaml +++ b/python/api_v2/tests/bundles/cluster_one_upgrade/config.yaml @@ -123,3 +123,15 @@ type: service version: *version config: *config + + +- name: service_with_bound_to + type: service + version: "hehe" + + components: + will_have_bound_to: + description: This component will have `bound_to` constraint after upgrade + bound_to: + service: service_1 + component: component_1 diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index a58a30dd02..e3bf49a717 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -305,7 +305,6 @@ def test_service_prototypes_success(self): response = (self.client.v2[self.cluster_1] / "service-prototypes").get() self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.json()), 8) self.assertListEqual( [prototype["displayName"] for prototype in response.json()], [ @@ -317,6 +316,7 @@ def test_service_prototypes_success(self): "service_4_save_config_without_required_field", "service_5_variant_type_without_values", "service_6_delete_with_action", + "service_with_bound_to", ], ) @@ -326,7 +326,6 @@ def test_service_candidates_success(self): response = (self.client.v2[self.cluster_1] / "service-candidates").get() self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.json()), 7) self.assertListEqual( [prototype["displayName"] for prototype in response.json()], [ @@ -337,6 +336,7 @@ def test_service_candidates_success(self): "service_4_save_config_without_required_field", "service_5_variant_type_without_values", "service_6_delete_with_action", + "service_with_bound_to", ], ) diff --git a/python/api_v2/tests/test_mapping.py b/python/api_v2/tests/test_mapping.py index d3626efae7..a5b5e327c3 100644 --- a/python/api_v2/tests/test_mapping.py +++ b/python/api_v2/tests/test_mapping.py @@ -11,18 +11,24 @@ # limitations under the License. from cm.models import ( + Bundle, Cluster, ClusterObject, + ConcernCause, + ConcernItem, GroupConfig, Host, HostComponent, MaintenanceMode, + Prototype, ServiceComponent, + Upgrade, ) from rest_framework.response import Response from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, + HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_409_CONFLICT, @@ -1144,6 +1150,69 @@ def test_host_in_mm_fail(self): self.assertEqual(HostComponent.objects.count(), 0) +class TestBoundTo(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + + self.old_bundle = Bundle.objects.get(name="cluster_one", version="1.0") + self.new_bundle = self.add_bundle(self.test_bundles_dir / "cluster_one_upgrade") + Prototype.objects.filter(license="unaccepted").update(license="accepted") + + def test_concern_appear_after_upgrade_success(self) -> None: + cluster_old = self.add_cluster(bundle=self.old_bundle, name="Created Old") + + service = self.add_services_to_cluster(["service_1"], cluster=cluster_old).get() + component = service.servicecomponent_set.get(prototype__name="component_1") + service_with_dependency = self.add_services_to_cluster(["service_with_bound_to"], cluster=cluster_old).get() + dependent_component = service_with_dependency.servicecomponent_set.get(prototype__name="will_have_bound_to") + + host_1 = self.add_host(provider=self.provider, fqdn="h1", cluster=cluster_old) + host_2 = self.add_host(provider=self.provider, fqdn="h2", cluster=cluster_old) + + self.set_hostcomponent( + cluster=cluster_old, entries=((host_1, component), (host_2, component), (host_2, dependent_component)) + ) + + self.assertFalse(ConcernItem.objects.filter(cause=ConcernCause.HOSTCOMPONENT).exists()) + + upgrade = Upgrade.objects.get(name="upgrade") + + response = self.client.v2[cluster_old, "upgrades", upgrade, "run"].post() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + cluster_old.refresh_from_db() + + concern = ConcernItem.objects.filter(cause=ConcernCause.HOSTCOMPONENT).first() + + self.assertIsNotNone(concern) + self.assertEqual(concern.owner, cluster_old) + + def test_save_mapping_with_unsatisfied_bound_to_fail(self) -> None: + cluster_new = self.add_cluster(bundle=self.new_bundle, name="Created New") + + service = self.add_services_to_cluster(["service_1"], cluster=cluster_new).get() + component = service.servicecomponent_set.get(prototype__name="component_1") + service_with_dependency = self.add_services_to_cluster(["service_with_bound_to"], cluster=cluster_new).get() + dependent_component = service_with_dependency.servicecomponent_set.get(prototype__name="will_have_bound_to") + + host_1 = self.add_host(provider=self.provider, fqdn="h1", cluster=cluster_new) + host_2 = self.add_host(provider=self.provider, fqdn="h2", cluster=cluster_new) + + response = self.client.v2[cluster_new, "mapping"].post( + data=[ + {"hostId": host.id, "componentId": component.id} + for host, component in ((host_1, component), (host_2, component), (host_2, dependent_component)) + ] + ) + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertIn( + 'No component "will_have_bound_to" of service "service_with_bound_to" on host "h1" ' + 'for component "component_1" of service "service_1"', + response.json()["desc"], + ) + + class GroupConfigRelatedTests(BaseAPITestCase): def setUp(self) -> None: super().setUp() From 98fc223111ac62bd124f7a4c3bc43398f8975e51 Mon Sep 17 00:00:00 2001 From: Alexey Latunov Date: Mon, 12 Aug 2024 10:51:50 +0000 Subject: [PATCH 20/50] [UI] ADCM-5835: Fix possibility to delete hosts on action dialog https://tracker.yandex.ru/ADCM-5835 --- .../ClusterMapping/ClusterMapping.utils.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts index 4754848aea..82647de24f 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts @@ -384,25 +384,24 @@ export const checkComponentMappingAvailability = ( component: AdcmMappingComponent, allowActions?: Set, ): ComponentAvailabilityErrors => { - const isAvailable = - component.service.state === AdcmEntitySystemState.Created && component.maintenanceMode === AdcmMaintenanceMode.Off; - - const result: ComponentAvailabilityErrors = { - componentNotAvailableError: - !isAvailable && !allowActions - ? 'Service of this component must have "Created" state. Maintenance mode on the components must be Off' - : undefined, - }; - + const result: ComponentAvailabilityErrors = {}; if (allowActions) { result.componentNotAvailableError = - allowActions.size === 0 ? 'Mapping is not allowed in action configuration' : result.componentNotAvailableError; + allowActions.size === 0 ? 'Mapping is not allowed in action configuration' : undefined; result.addingHostsNotAllowedError = !allowActions.has(AdcmHostComponentMapRuleAction.Add) ? 'Adding hosts is not allowed in the action configuration' : undefined; result.removingHostsNotAllowedError = !allowActions.has(AdcmHostComponentMapRuleAction.Remove) ? 'Removing hosts is not allowed in the action configuration' : undefined; + } else { + const isAvailable = + component.service.state === AdcmEntitySystemState.Created && + component.maintenanceMode === AdcmMaintenanceMode.Off; + + result.componentNotAvailableError = !isAvailable + ? 'Service of this component must have "Created" state. Maintenance mode on the components must be Off' + : undefined; } return result; @@ -413,13 +412,11 @@ export const checkHostMappingAvailability = ( allowActions?: Set, disabledHosts?: Set, ): string | undefined => { - const isAvailable = host.maintenanceMode === AdcmMaintenanceMode.Off; - let result = !isAvailable ? 'Maintenance mode on the host must be Off' : undefined; - if (allowActions && disabledHosts) { const isDisabled = !allowActions.has(AdcmHostComponentMapRuleAction.Remove) && disabledHosts.has(host.id); - result = isDisabled ? 'Removing host is not allowed in the action configuration' : result; + return isDisabled ? 'Removing host is not allowed in the action configuration' : undefined; + } else { + const isAvailable = host.maintenanceMode === AdcmMaintenanceMode.Off; + return !isAvailable ? 'Maintenance mode on the host must be Off' : undefined; } - - return result; }; From cd4027bb69a1b75470406726621cfda590ec8449 Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Tue, 13 Aug 2024 15:02:02 +0300 Subject: [PATCH 21/50] feature/ADCM-5784 + action host groups --- adcm-web/app/.eslintrc.json | 1 + adcm-web/app/src/App.tsx | 10 + .../src/api/adcm/clusterActionHostGroups.ts | 138 ++++---- .../adcm/clusterServiceActionHostGroups.ts | 161 ++++----- ...clusterServiceComponentActionHostGroups.ts | 183 +++++------ .../ActionHostGroupDialogForm.constants.ts | 10 + .../ActionHostGroupDialogForm.module.scss | 18 ++ .../ActionHostGroupDialogForm.tsx | 81 +++++ .../useActionHostGroupDialogForm.ts | 42 +++ .../CreateActionHostGroupDialog.tsx | 52 +++ .../DeleteActionHostGroupDialog.tsx | 28 ++ .../EditActionHostGroupDialog.tsx | 54 ++++ ...ctionHostGroupDynamicActionsIconButton.tsx | 28 ++ .../ActionHostGroupsTable.constants.ts} | 0 .../ActionHostGroupsTable.tsx | 79 +++++ ...ostGroupsTableExpandedContent.module.scss} | 2 +- .../ActionHostGroupsTableExpandedContent.tsx} | 6 +- .../ActionHostGroupsTableFilters.tsx | 44 +++ .../ActionHostGroupsTableToolbar.tsx | 23 ++ .../ActionHostGroups/useActionHostGroups.ts | 211 ++++++++++++ .../useRequestActionHostGroups.ts | 43 +++ .../DynamicActionDialog.tsx | 2 +- .../DynamicActionHostMapping.tsx | 6 +- .../ClusterServiceNavigation.tsx | 1 + .../ClusterActionHostGroups.tsx | 19 +- .../ClusterActionHostGroupsTable.tsx | 88 ----- .../useClusterActionHostGroups.ts | 13 + .../useRequestClusterActionHostGroups.ts | 28 -- .../ServiceActionHostGroups.tsx | 25 ++ .../useServiceActionHostGroups.ts | 15 + .../ComponentActionHostGroups.tsx | 25 ++ .../useComponentActionHostGroups.ts | 16 + .../ComponentConfigurationsNavigation.tsx | 1 + adcm-web/app/src/hooks/useForm.ts | 4 +- .../app/src/models/adcm/actionHostGroup.ts | 305 +++++++++++++++++- adcm-web/app/src/models/adcm/dynamicAction.ts | 2 + .../clusters/clustersDynamicActionsSlice.ts | 63 +--- .../actionHostGroups.types.ts | 163 ++++++++++ .../actionHostGroupsActionsSlice.ts | 287 ++++++++++++++++ .../actionHostGroupsActionsSlice.utils.ts | 19 ++ .../actionHostGroupsSlice.constants.ts | 12 + .../actionHostGroupsSlice.ts | 66 ++-- .../actionHostGroupsTableSlice.ts | 31 ++ .../dynamicActionsMappingSlice.ts | 78 +++++ .../dynamicActionsSlice.ts | 144 +++++++++ adcm-web/app/src/store/store.ts | 12 +- 46 files changed, 2170 insertions(+), 469 deletions(-) create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.constants.ts create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.module.scss create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm.ts create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog.tsx create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog.tsx create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupDynamicActionsIconButton.tsx rename adcm-web/app/src/components/{pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.constants.ts => common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.constants.ts} (100%) create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx rename adcm-web/app/src/components/{pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.module.scss => common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.module.scss} (79%) rename adcm-web/app/src/components/{pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.tsx => common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.tsx} (84%) create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters.tsx create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/useRequestActionHostGroups.ts delete mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useClusterActionHostGroups.ts delete mode 100644 adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useRequestClusterActionHostGroups.ts create mode 100644 adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/ServiceActionHostGroups.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/useServiceActionHostGroups.ts create mode 100644 adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/useComponentActionHostGroups.ts create mode 100644 adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroups.types.ts create mode 100644 adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts create mode 100644 adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.utils.ts create mode 100644 adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.constants.ts create mode 100644 adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsTableSlice.ts create mode 100644 adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsMappingSlice.ts create mode 100644 adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts diff --git a/adcm-web/app/.eslintrc.json b/adcm-web/app/.eslintrc.json index 8874909e06..dd8a732c64 100644 --- a/adcm-web/app/.eslintrc.json +++ b/adcm-web/app/.eslintrc.json @@ -151,6 +151,7 @@ "refractor", "rp", "req", + "runnable", "schemas", "searchable", "sql", diff --git a/adcm-web/app/src/App.tsx b/adcm-web/app/src/App.tsx index c3f68d382f..d6e7c6875b 100644 --- a/adcm-web/app/src/App.tsx +++ b/adcm-web/app/src/App.tsx @@ -63,6 +63,8 @@ import HostProviderConfigurationGroupSingle from '@pages/HostProviderPage/HostPr import NotFoundPage from '@pages/NotFoundPage/NotFoundPage'; import ClusterAnsibleSettings from '@pages/cluster/ClusterConfiguration/ClusterAnsibleSettings/ClusterAnsibleSettings'; import ClusterActionHostGroups from '@pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups'; +import ServiceActionHostGroups from '@pages/cluster/service/ServiceActionHostGroups/ServiceActionHostGroups'; +import ComponentActionHostGroups from '@pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups'; function App() { return ( @@ -108,6 +110,10 @@ function App() { path="/clusters/:clusterId/services/:serviceId/configuration-groups/:configGroupId/" element={} /> + } + /> } @@ -125,6 +131,10 @@ function App() { path="/clusters/:clusterId/services/:serviceId/components/:componentId/configuration-groups" element={} /> + } + /> } diff --git a/adcm-web/app/src/api/adcm/clusterActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterActionHostGroups.ts index 8e828d4602..39b38524dc 100644 --- a/adcm-web/app/src/api/adcm/clusterActionHostGroups.ts +++ b/adcm-web/app/src/api/adcm/clusterActionHostGroups.ts @@ -1,25 +1,30 @@ import { httpClient } from '@api/httpClient'; -import type { PaginationParams, SortParams } from '@models/table'; +import type { AdcmDynamicAction, AdcmDynamicActionDetails, AdcmJob, Batch } from '@models/adcm'; import type { - AdcmDynamicAction, - AdcmDynamicActionDetails, - AdcmDynamicActionRunConfig, - AdcmJob, - Batch, -} from '@models/adcm'; -import type { - AdcmActionHostGroupHost, AdcmActionHostGroup, - AdcmActionHostGroupsActionsFilter, - AddAdcmActionHostGroupHostPayload, - CreateAdcmActionHostGroupPayload, + GetAdcmClusterActionHostGroupsArgs, + GetAdcmClusterActionHostGroupArgs, + CreateAdcmClusterActionHostGroupArgs, + DeleteAdcmClusterActionHostGroupArgs, + GetAdcmClusterActionHostGroupActionsArgs, + GetAdcmClusterActionHostGroupActionArgs, + RunAdcmClusterActionHostGroupActionArgs, + AdcmActionHostGroupHost, + GetAdcmClusterActionHostGroupsHostCandidatesArgs, + GetAdcmClusterActionHostGroupHostCandidatesArgs, + GetAdcmClusterActionHostGroupHostsArgs, + AddAdcmClusterActionHostGroupHostArgs, + DeleteAdcmClusterActionHostGroupHostArgs, } from '@models/adcm/actionHostGroup'; import { prepareQueryParams } from '@utils/apiUtils'; import qs from 'qs'; export class AdcmClusterActionHostGroupsApi { - public static async getActionHostGroups(clusterId: number, paginationParams: PaginationParams) { - const queryParams = prepareQueryParams(undefined, undefined, paginationParams); + // CRUD + + public static async getActionHostGroups(args: GetAdcmClusterActionHostGroupsArgs) { + const { clusterId, filter, paginationParams } = args; + const queryParams = prepareQueryParams(filter, undefined, paginationParams); const query = qs.stringify(queryParams); const response = await httpClient.get>( @@ -29,45 +34,46 @@ export class AdcmClusterActionHostGroupsApi { return response.data; } - public static async postActionHostGroup(clusterId: number, newActionGroup: CreateAdcmActionHostGroupPayload) { - const response = await httpClient.post( - `/api/v2/clusters/${clusterId}/action-host-groups/`, - newActionGroup, + public static async getActionHostGroup(args: GetAdcmClusterActionHostGroupArgs) { + const { clusterId, actionHostGroupId } = args; + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/`, ); return response.data; } - public static async getActionHostGroup(clusterId: number, actionHostGroupId: number) { - await httpClient.get(`/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/`); - } - - public static async deleteActionHostGroup(clusterId: number, actionHostGroupId: number) { - const response = await httpClient.delete( - `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/`, + public static async postActionHostGroup(args: CreateAdcmClusterActionHostGroupArgs) { + const { clusterId, actionHostGroup } = args; + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/action-host-groups/`, + actionHostGroup, ); return response.data; } - public static async getActionHostGroupActions( - clusterId: number, - actionHostGroupId: number, - sortParams: SortParams, - paginationParams: PaginationParams, - filter: AdcmActionHostGroupsActionsFilter, - ) { + public static async deleteActionHostGroup(args: DeleteAdcmClusterActionHostGroupArgs) { + const { clusterId, actionHostGroupId } = args; + await httpClient.delete(`/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/`); + } + + // Actions + + public static async getActionHostGroupActions(args: GetAdcmClusterActionHostGroupActionsArgs) { + const { clusterId, actionHostGroupId, sortParams, filter, paginationParams } = args; const queryParams = prepareQueryParams(filter, sortParams, paginationParams); const query = qs.stringify(queryParams); - const response = await httpClient.get>( + const response = await httpClient.get( `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/actions/?${query}`, ); return response.data; } - public static async getActionHostGroupAction(clusterId: number, actionHostGroupId: number, actionId: number) { + public static async getActionHostGroupAction(args: GetAdcmClusterActionHostGroupActionArgs) { + const { clusterId, actionHostGroupId, actionId } = args; const response = await httpClient.get( `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/`, ); @@ -75,12 +81,8 @@ export class AdcmClusterActionHostGroupsApi { return response.data; } - public static async postActionHostGroupAction( - clusterId: number, - actionHostGroupId: number, - actionId: number, - actionRunConfig: AdcmDynamicActionRunConfig, - ) { + public static async postActionHostGroupAction(args: RunAdcmClusterActionHostGroupActionArgs) { + const { clusterId, actionHostGroupId, actionId, actionRunConfig } = args; const response = await httpClient.post( `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/run/`, actionRunConfig, @@ -89,30 +91,58 @@ export class AdcmClusterActionHostGroupsApi { return response.data; } - public static async postActionHostGroupHost( - clusterId: number, - actionHostGroupId: number, - host: AddAdcmActionHostGroupHostPayload, - ) { - const response = await httpClient.post( - `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/hosts/`, - host, + // Host candidates + + public static async getActionHostGroupsHostCandidates(args: GetAdcmClusterActionHostGroupsHostCandidatesArgs) { + const { clusterId, filter } = args; + const queryParams = prepareQueryParams(filter); + const query = qs.stringify(queryParams); + + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/action-host-groups/host-candidates/?${query}`, ); return response.data; } - public static async deleteActionHostGroupHost(clusterId: number, actionHostGroupId: number, hostId: number) { - await httpClient.delete( - `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`, + public static async getActionHostGroupHostCandidates(args: GetAdcmClusterActionHostGroupHostCandidatesArgs) { + const { clusterId, actionHostGroupId, filter } = args; + const queryParams = prepareQueryParams(filter); + const query = qs.stringify(queryParams); + + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/host-candidates/?${query}`, ); + + return response.data; } - public static async getActionHostGroupHostCandidates(clusterId: number, actionHostGroupId: number) { - const response = await httpClient.get( - `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/host-candidates/`, + // Hosts + + public static async getActionHostGroupHosts(args: GetAdcmClusterActionHostGroupHostsArgs) { + const { clusterId, actionHostGroupId, sortParams, paginationParams } = args; + const queryParams = prepareQueryParams(undefined, sortParams, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/hosts/?${query}`, ); return response.data; } + + public static async postActionHostGroupHost(args: AddAdcmClusterActionHostGroupHostArgs) { + const { clusterId, actionHostGroupId, hostId } = args; + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/hosts/`, + { hostId }, + ); + + return response.data; + } + + public static async deleteActionHostGroupHost(args: DeleteAdcmClusterActionHostGroupHostArgs) { + const { clusterId, actionHostGroupId, hostId } = args; + await httpClient.delete(`/api/v2/clusters/${clusterId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`); + } } diff --git a/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts index fd17b93244..874093a84d 100644 --- a/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts +++ b/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts @@ -1,25 +1,30 @@ import { httpClient } from '@api/httpClient'; -import type { PaginationParams, SortParams } from '@models/table'; +import type { AdcmDynamicAction, AdcmDynamicActionDetails, AdcmJob, Batch } from '@models/adcm'; import type { - AdcmDynamicAction, - AdcmDynamicActionDetails, - AdcmDynamicActionRunConfig, - AdcmJob, - Batch, -} from '@models/adcm'; -import type { - AdcmActionHostGroupHost, AdcmActionHostGroup, - AdcmActionHostGroupsActionsFilter, - AddAdcmActionHostGroupHostPayload, - CreateAdcmActionHostGroupPayload, + GetAdcmServiceActionHostGroupsArgs, + GetAdcmServiceActionHostGroupArgs, + CreateAdcmServiceActionHostGroupArgs, + DeleteAdcmServiceActionHostGroupArgs, + GetAdcmServiceActionHostGroupActionsArgs, + GetAdcmServiceActionHostGroupActionArgs, + RunAdcmServiceActionHostGroupActionArgs, + GetAdcmServiceActionHostGroupsHostCandidatesArgs, + AdcmActionHostGroupHost, + GetAdcmServiceActionHostGroupHostCandidatesArgs, + GetAdcmServiceActionHostGroupHostsArgs, + AddAdcmServiceActionHostGroupHostArgs, + DeleteAdcmServiceActionHostGroupHostArgs, } from '@models/adcm/actionHostGroup'; import { prepareQueryParams } from '@utils/apiUtils'; import qs from 'qs'; export class AdcmClusterServiceActionHostGroupsApi { - public static async getActionHostGroups(clusterId: number, serviceId: number, paginationParams: PaginationParams) { - const queryParams = prepareQueryParams(undefined, undefined, paginationParams); + // CRUD + + public static async getActionHostGroups(args: GetAdcmServiceActionHostGroupsArgs) { + const { clusterId, serviceId, filter, paginationParams } = args; + const queryParams = prepareQueryParams(filter, undefined, paginationParams); const query = qs.stringify(queryParams); const response = await httpClient.get>( @@ -29,57 +34,48 @@ export class AdcmClusterServiceActionHostGroupsApi { return response.data; } - public static async postActionHostGroup( - clusterId: number, - serviceId: number, - newActionGroup: CreateAdcmActionHostGroupPayload, - ) { - const response = await httpClient.post( - `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/`, - newActionGroup, + public static async getActionHostGroup(args: GetAdcmServiceActionHostGroupArgs) { + const { clusterId, serviceId, actionHostGroupId } = args; + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/serviceId/${serviceId}/action-host-groups/${actionHostGroupId}/`, ); return response.data; } - public static async getActionHostGroup(clusterId: number, serviceId: number, actionHostGroupId: number) { - await httpClient.get( - `/api/v2/clusters/${clusterId}/service/${serviceId}/action-host-groups/${actionHostGroupId}/`, + public static async postActionHostGroup(args: CreateAdcmServiceActionHostGroupArgs) { + const { clusterId, serviceId, actionHostGroup } = args; + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/`, + actionHostGroup, ); + + return response.data; } - public static async deleteActionHostGroup(clusterId: number, serviceId: number, actionHostGroupId: number) { - const response = await httpClient.delete( + public static async deleteActionHostGroup(args: DeleteAdcmServiceActionHostGroupArgs) { + const { clusterId, serviceId, actionHostGroupId } = args; + await httpClient.delete( `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/`, ); - - return response.data; } - public static async getActionHostGroupActions( - clusterId: number, - serviceId: number, - actionHostGroupId: number, - sortParams: SortParams, - paginationParams: PaginationParams, - filter: AdcmActionHostGroupsActionsFilter, - ) { + // Actions + + public static async getActionHostGroupActions(args: GetAdcmServiceActionHostGroupActionsArgs) { + const { clusterId, serviceId, actionHostGroupId, sortParams, filter, paginationParams } = args; const queryParams = prepareQueryParams(filter, sortParams, paginationParams); const query = qs.stringify(queryParams); - const response = await httpClient.get>( + const response = await httpClient.get( `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/actions/?${query}`, ); return response.data; } - public static async getActionHostGroupAction( - clusterId: number, - serviceId: number, - actionHostGroupId: number, - actionId: number, - ) { + public static async getActionHostGroupAction(args: GetAdcmServiceActionHostGroupActionArgs) { + const { clusterId, serviceId, actionHostGroupId, actionId } = args; const response = await httpClient.get( `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/`, ); @@ -87,13 +83,8 @@ export class AdcmClusterServiceActionHostGroupsApi { return response.data; } - public static async postActionHostGroupAction( - clusterId: number, - serviceId: number, - actionHostGroupId: number, - actionId: number, - actionRunConfig: AdcmDynamicActionRunConfig, - ) { + public static async postActionHostGroupAction(args: RunAdcmServiceActionHostGroupActionArgs) { + const { clusterId, serviceId, actionHostGroupId, actionId, actionRunConfig } = args; const response = await httpClient.post( `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/run/`, actionRunConfig, @@ -102,40 +93,60 @@ export class AdcmClusterServiceActionHostGroupsApi { return response.data; } - public static async postActionHostGroupHost( - clusterId: number, - serviceId: number, - actionHostGroupId: number, - host: AddAdcmActionHostGroupHostPayload, - ) { - const response = await httpClient.post( - `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/hosts/`, - host, + // Host candidates + + public static async getActionHostGroupsHostCandidates(args: GetAdcmServiceActionHostGroupsHostCandidatesArgs) { + const { clusterId, serviceId, filter } = args; + const queryParams = prepareQueryParams(filter); + const query = qs.stringify(queryParams); + + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/host-candidates/?${query}`, ); return response.data; } - public static async deleteActionHostGroupHost( - clusterId: number, - serviceId: number, - actionHostGroupId: number, - hostId: number, - ) { - await httpClient.delete( - `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`, + public static async getActionHostGroupHostCandidates(args: GetAdcmServiceActionHostGroupHostCandidatesArgs) { + const { clusterId, serviceId, actionHostGroupId, filter } = args; + const queryParams = prepareQueryParams(filter); + const query = qs.stringify(queryParams); + + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/host-candidates/?${query}`, ); + + return response.data; } - public static async getActionHostGroupHostCandidates( - clusterId: number, - serviceId: number, - actionHostGroupId: number, - ) { - const response = await httpClient.get( - `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/host-candidates/`, + // Hosts + + public static async getActionHostGroupHosts(args: GetAdcmServiceActionHostGroupHostsArgs) { + const { clusterId, serviceId, actionHostGroupId, sortParams, paginationParams } = args; + const queryParams = prepareQueryParams(undefined, sortParams, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/hosts/?${query}`, + ); + + return response.data; + } + + public static async postActionHostGroupHost(args: AddAdcmServiceActionHostGroupHostArgs) { + const { clusterId, serviceId, actionHostGroupId, hostId } = args; + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/hosts/`, + { hostId }, ); return response.data; } + + public static async deleteActionHostGroupHost(args: DeleteAdcmServiceActionHostGroupHostArgs) { + const { clusterId, serviceId, actionHostGroupId, hostId } = args; + await httpClient.delete( + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`, + ); + } } diff --git a/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts index 24ebaa17b2..1a06d74ffc 100644 --- a/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts +++ b/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts @@ -1,30 +1,30 @@ import { httpClient } from '@api/httpClient'; -import type { PaginationParams, SortParams } from '@models/table'; +import type { AdcmDynamicAction, AdcmDynamicActionDetails, AdcmJob, Batch } from '@models/adcm'; import type { - AdcmDynamicAction, - AdcmDynamicActionDetails, - AdcmDynamicActionRunConfig, - AdcmJob, - Batch, -} from '@models/adcm'; -import type { - AdcmActionHostGroupHost, AdcmActionHostGroup, - AdcmActionHostGroupsActionsFilter, - AddAdcmActionHostGroupHostPayload, - CreateAdcmActionHostGroupPayload, + GetAdcmComponentActionHostGroupsArgs, + GetAdcmComponentActionHostGroupArgs, + CreateAdcmComponentActionHostGroupArgs, + DeleteAdcmComponentActionHostGroupArgs, + GetAdcmComponentActionHostGroupActionsArgs, + GetAdcmComponentActionHostGroupActionArgs, + RunAdcmComponentActionHostGroupActionArgs, + GetAdcmComponentActionHostGroupsHostCandidatesArgs, + AdcmActionHostGroupHost, + GetAdcmComponentActionHostGroupHostCandidatesArgs, + GetAdcmComponentActionHostGroupHostsArgs, + AddAdcmComponentActionHostGroupHostArgs, + DeleteAdcmComponentActionHostGroupHostArgs, } from '@models/adcm/actionHostGroup'; import { prepareQueryParams } from '@utils/apiUtils'; import qs from 'qs'; export class AdcmClusterServiceComponentActionHostGroupsApi { - public static async getActionHostGroups( - clusterId: number, - serviceId: number, - componentId: number, - paginationParams: PaginationParams, - ) { - const queryParams = prepareQueryParams(undefined, undefined, paginationParams); + // CRUD + + public static async getActionHostGroups(args: GetAdcmComponentActionHostGroupsArgs) { + const { clusterId, serviceId, componentId, filter, paginationParams } = args; + const queryParams = prepareQueryParams(filter, undefined, paginationParams); const query = qs.stringify(queryParams); const response = await httpClient.get>( @@ -34,70 +34,48 @@ export class AdcmClusterServiceComponentActionHostGroupsApi { return response.data; } - public static async postActionHostGroup( - clusterId: number, - serviceId: number, - componentId: number, - newActionGroup: CreateAdcmActionHostGroupPayload, - ) { - const response = await httpClient.post( - `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/`, - newActionGroup, + public static async getActionHostGroup(args: GetAdcmComponentActionHostGroupArgs) { + const { clusterId, serviceId, actionHostGroupId } = args; + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/serviceId/${serviceId}/action-host-groups/${actionHostGroupId}/`, ); return response.data; } - public static async getActionHostGroup( - clusterId: number, - serviceId: number, - componentId: number, - actionHostGroupId: number, - ) { - await httpClient.get( - `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/`, + public static async postActionHostGroup(args: CreateAdcmComponentActionHostGroupArgs) { + const { clusterId, serviceId, componentId, actionHostGroup } = args; + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/`, + actionHostGroup, ); + + return response.data; } - public static async deleteActionHostGroup( - clusterId: number, - serviceId: number, - componentId: number, - actionHostGroupId: number, - ) { - const response = await httpClient.delete( + public static async deleteActionHostGroup(args: DeleteAdcmComponentActionHostGroupArgs) { + const { clusterId, serviceId, componentId, actionHostGroupId } = args; + await httpClient.delete( `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/`, ); - - return response.data; } - public static async getActionHostGroupActions( - clusterId: number, - serviceId: number, - componentId: number, - actionHostGroupId: number, - sortParams: SortParams, - paginationParams: PaginationParams, - filter: AdcmActionHostGroupsActionsFilter, - ) { + // Actions + + public static async getActionHostGroupActions(args: GetAdcmComponentActionHostGroupActionsArgs) { + const { clusterId, serviceId, componentId, actionHostGroupId, sortParams, filter, paginationParams } = args; const queryParams = prepareQueryParams(filter, sortParams, paginationParams); const query = qs.stringify(queryParams); - const response = await httpClient.get>( + const response = await httpClient.get( `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/actions/?${query}`, ); return response.data; } - public static async getActionHostGroupAction( - clusterId: number, - serviceId: number, - componentId: number, - actionHostGroupId: number, - actionId: number, - ) { + public static async getActionHostGroupAction(args: GetAdcmComponentActionHostGroupActionArgs) { + const { clusterId, serviceId, componentId, actionHostGroupId, actionId } = args; const response = await httpClient.get( `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/`, ); @@ -105,14 +83,8 @@ export class AdcmClusterServiceComponentActionHostGroupsApi { return response.data; } - public static async postActionHostGroupAction( - clusterId: number, - serviceId: number, - componentId: number, - actionHostGroupId: number, - actionId: number, - actionRunConfig: AdcmDynamicActionRunConfig, - ) { + public static async postActionHostGroupAction(args: RunAdcmComponentActionHostGroupActionArgs) { + const { clusterId, serviceId, componentId, actionHostGroupId, actionId, actionRunConfig } = args; const response = await httpClient.post( `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/actions/${actionId}/run/`, actionRunConfig, @@ -121,43 +93,60 @@ export class AdcmClusterServiceComponentActionHostGroupsApi { return response.data; } - public static async postActionHostGroupHost( - clusterId: number, - serviceId: number, - componentId: number, - actionHostGroupId: number, - host: AddAdcmActionHostGroupHostPayload, - ) { - const response = await httpClient.post( - `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/hosts/`, - host, + // Host candidates + + public static async getActionHostGroupsHostCandidates(args: GetAdcmComponentActionHostGroupsHostCandidatesArgs) { + const { clusterId, serviceId, componentId, filter } = args; + const queryParams = prepareQueryParams(filter); + const query = qs.stringify(queryParams); + + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/host-candidates/?${query}`, ); return response.data; } - public static async deleteActionHostGroupHost( - clusterId: number, - serviceId: number, - componentId: number, - actionHostGroupId: number, - hostId: number, - ) { - await httpClient.delete( - `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`, + public static async getActionHostGroupHostCandidates(args: GetAdcmComponentActionHostGroupHostCandidatesArgs) { + const { clusterId, serviceId, componentId, actionHostGroupId, filter } = args; + const queryParams = prepareQueryParams(filter); + const query = qs.stringify(queryParams); + + const response = await httpClient.get( + `/api/v2/clusters/${clusterId}/services/${serviceId}/component/${componentId}/action-host-groups/${actionHostGroupId}/host-candidates/?${query}`, ); + + return response.data; } - public static async getActionHostGroupHostCandidates( - clusterId: number, - serviceId: number, - componentId: number, - actionHostGroupId: number, - ) { - const response = await httpClient.get( - `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/host-candidates/`, + // Hosts + + public static async getActionHostGroupHosts(args: GetAdcmComponentActionHostGroupHostsArgs) { + const { clusterId, serviceId, componentId, actionHostGroupId, sortParams, paginationParams } = args; + const queryParams = prepareQueryParams(undefined, sortParams, paginationParams); + const query = qs.stringify(queryParams); + + const response = await httpClient.get>( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/hosts/?${query}`, + ); + + return response.data; + } + + public static async postActionHostGroupHost(args: AddAdcmComponentActionHostGroupHostArgs) { + const { clusterId, serviceId, componentId, actionHostGroupId, hostId } = args; + const response = await httpClient.post( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/hosts/`, + { hostId }, ); return response.data; } + + public static async deleteActionHostGroupHost(args: DeleteAdcmComponentActionHostGroupHostArgs) { + const { clusterId, serviceId, componentId, actionHostGroupId, hostId } = args; + await httpClient.delete( + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/hosts/${hostId}/`, + ); + } } diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.constants.ts b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.constants.ts new file mode 100644 index 0000000000..c39135466d --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.constants.ts @@ -0,0 +1,10 @@ +export const availableHostsPanel = { + title: 'All available hosts', + searchPlaceholder: 'Search hosts', +}; + +export const selectedHostsPanel = { + title: 'Selected hosts', + searchPlaceholder: 'Search hosts', + actionButtonLabel: 'Remove selected', +}; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.module.scss b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.module.scss new file mode 100644 index 0000000000..caa25e34ce --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.module.scss @@ -0,0 +1,18 @@ +.actionHostGroupDialogForm { + display: grid; + height: 100%; + grid-template-rows: min-content 1fr; + + &__column { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 20px; + margin-bottom: 24px; + } +} + +.actionHostGroupDialog { + display: grid; + grid-template-rows: min-content min-content 1fr; + max-height: 100%; +} diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx new file mode 100644 index 0000000000..1c7385052f --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; +import type { FormErrors } from '@hooks/useForm'; +import { FormField, FormFieldsContainer, Input } from '@uikit'; +import { AdcmActionHostGroupHost } from '@models/adcm'; +import type { AdcmActionHostGroupFormData } from './useActionHostGroupDialogForm'; +import ListTransfer from '@uikit/ListTransfer/ListTransfer'; +import { availableHostsPanel, selectedHostsPanel } from './ActionHostGroupDialogForm.constants'; +import s from './ActionHostGroupDialogForm.module.scss'; + +export interface ActionHostGroupDialogFormProps { + formData: AdcmActionHostGroupFormData; + hostCandidates: AdcmActionHostGroupHost[]; + errors: FormErrors; + onChangeFormData: (changes: Partial) => void; +} + +const ActionHostGroupDialogForm = ({ + formData, + hostCandidates, + errors, + onChangeFormData, +}: ActionHostGroupDialogFormProps) => { + const allHosts = useMemo(() => { + return hostCandidates.map((host) => { + return { + key: host.id, + label: host.name, + }; + }); + }, [hostCandidates]); + + const handleNameChange = (event: React.ChangeEvent) => { + onChangeFormData({ name: event.target.value }); + }; + + const handleDescriptionChange = (event: React.ChangeEvent) => { + onChangeFormData({ description: event.target.value }); + }; + + const handleHostsChange = (hostIds: Set) => { + onChangeFormData({ hosts: hostIds }); + }; + + return ( + +
+ + + + + + +
+ + +
+ ); +}; + +export default ActionHostGroupDialogForm; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm.ts b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm.ts new file mode 100644 index 0000000000..8f45b0560a --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm.ts @@ -0,0 +1,42 @@ +import { useEffect, useMemo } from 'react'; +import { useForm } from '@hooks'; +import { AdcmActionHostGroup } from '@models/adcm'; +import { required } from '@utils/validationsUtils'; + +export type AdcmActionHostGroupFormData = Omit & { + hosts: Set; +}; + +const emptyFormData: AdcmActionHostGroupFormData = { + name: '', + description: '', + hosts: new Set(), +}; + +export const useActionHostGroupDialogForm = (actionHostGroup?: AdcmActionHostGroup) => { + const initialFormData = useMemo(() => { + return actionHostGroup + ? { + name: actionHostGroup.name, + description: actionHostGroup.description, + hosts: new Set(actionHostGroup.hosts.map((host) => host.id)), + } + : emptyFormData; + }, [actionHostGroup]); + + const { formData, handleChangeFormData, errors, setErrors, isValid } = + useForm(initialFormData); + + useEffect(() => { + setErrors({ + name: required(formData.name) ? undefined : 'Action host group name field is required', + }); + }, [formData, setErrors]); + + return { + formData, + isValid, + errors, + onChangeFormData: handleChangeFormData, + }; +}; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx new file mode 100644 index 0000000000..f89a32a214 --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx @@ -0,0 +1,52 @@ +import { Dialog } from '@uikit'; +import ActionHostGroupDialogForm from '../ActionHostGroupDialogForm/ActionHostGroupDialogForm'; +import { + type AdcmActionHostGroupFormData, + useActionHostGroupDialogForm, +} from '../ActionHostGroupDialogForm/useActionHostGroupDialogForm'; +import s from '../ActionHostGroupDialogForm/ActionHostGroupDialogForm.module.scss'; +import { AdcmActionHostGroupHost } from '@models/adcm'; + +export interface CreateActionHostGroupDialogProps { + isOpen: boolean; + hostCandidates: AdcmActionHostGroupHost[]; + onCreate: (formData: AdcmActionHostGroupFormData) => void; + onClose: () => void; +} + +const CreateActionHostGroupDialog = ({ + isOpen, + hostCandidates, + onCreate, + onClose, +}: CreateActionHostGroupDialogProps) => { + const { isValid, formData, onChangeFormData, errors } = useActionHostGroupDialogForm(); + + const handleAction = () => { + onCreate(formData); + }; + + return ( + + + + ); +}; + +export default CreateActionHostGroupDialog; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog.tsx new file mode 100644 index 0000000000..a79c9ce906 --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog.tsx @@ -0,0 +1,28 @@ +import { Dialog } from '@uikit'; +import { AdcmActionHostGroup } from '@models/adcm'; + +export interface DeleteActionHostGroupDialogProps { + isOpen: boolean; + actionHostGroup: AdcmActionHostGroup; + onDelete: () => void; + onClose: () => void; +} + +const DeleteActionHostGroupDialog = ({ + isOpen, + actionHostGroup, + onDelete, + onClose, +}: DeleteActionHostGroupDialogProps) => ( + + Action host group {actionHostGroup.name} will be deleted + +); + +export default DeleteActionHostGroupDialog; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog.tsx new file mode 100644 index 0000000000..524fe8d81f --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog.tsx @@ -0,0 +1,54 @@ +import { Dialog } from '@uikit'; +import ActionHostGroupDialogForm from '../ActionHostGroupDialogForm/ActionHostGroupDialogForm'; +import { + type AdcmActionHostGroupFormData, + useActionHostGroupDialogForm, +} from '../ActionHostGroupDialogForm/useActionHostGroupDialogForm'; +import type { AdcmActionHostGroup, AdcmActionHostGroupHost } from '@models/adcm'; +import s from '../ActionHostGroupDialogForm/ActionHostGroupDialogForm.module.scss'; + +export interface EditActionHostGroupDialogProps { + isOpen: boolean; + actionHostGroup: AdcmActionHostGroup; + hostCandidates: AdcmActionHostGroupHost[]; + onEdit: (formData: AdcmActionHostGroupFormData) => void; + onClose: () => void; +} + +const EditActionHostGroupDialog = ({ + isOpen, + actionHostGroup, + hostCandidates, + onEdit, + onClose, +}: EditActionHostGroupDialogProps) => { + const { isValid, formData, onChangeFormData, errors } = useActionHostGroupDialogForm(actionHostGroup); + + const handleAction = () => { + onEdit(formData); + }; + + return ( + + + + ); +}; + +export default EditActionHostGroupDialog; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupDynamicActionsIconButton.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupDynamicActionsIconButton.tsx new file mode 100644 index 0000000000..3b45065901 --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupDynamicActionsIconButton.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { AdcmDynamicAction } from '@models/adcm'; +import { DynamicActionsButton, DynamicActionsIcon } from '@commonComponents/DynamicActionsButton/DynamicActionsButton'; +import { IconProps } from '@uikit/Icon/Icon'; + +interface ActionHostGroupDynamicActionsIconButtonProps { + isDisabled?: boolean; + dynamicActions: AdcmDynamicAction[]; + size?: IconProps['size']; + type?: 'button' | 'icon'; + onActionSelect: (actionId: number) => void; +} + +const ActionHostGroupDynamicActionsIconButton: React.FC = ({ + isDisabled, + dynamicActions, + type = 'icon', + size, + onActionSelect, +}) => { + const DynamicActionsTrigger = type === 'icon' ? DynamicActionsIcon : DynamicActionsButton; + + return ( + + ); +}; + +export default ActionHostGroupDynamicActionsIconButton; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.constants.ts b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.constants.ts similarity index 100% rename from adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.constants.ts rename to adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.constants.ts diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx new file mode 100644 index 0000000000..8729106757 --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx @@ -0,0 +1,79 @@ +import { ExpandableRowComponent, IconButton, Table, TableCell } from '@uikit'; +import ActionHostGroupDynamicActionsIconButton from './ActionHostGroupDynamicActionsIconButton'; +import ActionHostGroupsTableExpandedContent from './ActionHostGroupsTableExpandedContent'; +import { columns } from './ActionHostGroupsTable.constants'; +import type { EntitiesDynamicActions } from '@models/adcm'; +import type { AdcmActionHostGroup } from '@models/adcm/actionHostGroup'; +import { useState } from 'react'; +import ExpandDetailsCell from '@commonComponents/ExpandDetailsCell/ExpandDetailsCell'; + +export interface ClusterActionHostGroupsTableProps { + actionHostGroups: AdcmActionHostGroup[]; + dynamicActions: EntitiesDynamicActions; + isLoading: boolean; + onOpenDynamicActionDialog: (actionHostGroup: AdcmActionHostGroup, actionId: number) => void; + onOpenEditDialog: (actionHostGroup: AdcmActionHostGroup) => void; + onOpenDeleteDialog: (actionHostGroup: AdcmActionHostGroup) => void; +} + +const ClusterActionHostGroupsTable = ({ + actionHostGroups, + dynamicActions, + isLoading, + onOpenDynamicActionDialog, + onOpenEditDialog, + onOpenDeleteDialog, +}: ClusterActionHostGroupsTableProps) => { + const [expandableRows, setExpandableRows] = useState>({}); + + const handleExpandClick = (id: number) => { + setExpandableRows({ + ...expandableRows, + [id]: expandableRows[id] === undefined ? true : !expandableRows[id], + }); + }; + + return ( + + {actionHostGroups.map((actionHostGroup: AdcmActionHostGroup) => { + return ( + } + > + {actionHostGroup.name} + {actionHostGroup.description} + handleExpandClick(actionHostGroup.id)}> + {actionHostGroup.hosts.length} + + + onOpenDynamicActionDialog(actionHostGroup, actionId)} + /> + onOpenEditDialog(actionHostGroup)} + tooltipProps={{ placement: 'bottom-start' }} + /> + onOpenDeleteDialog(actionHostGroup)} + tooltipProps={{ placement: 'bottom-start' }} + /> + + + ); + })} +
+ ); +}; + +export default ClusterActionHostGroupsTable; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.module.scss b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.module.scss similarity index 79% rename from adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.module.scss rename to adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.module.scss index 6a645f9e80..86482126e2 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.module.scss +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.module.scss @@ -1,4 +1,4 @@ -.clusterActionHostGroupsTableExpandedContent { +.actionHostGroupsTableExpandedContent { margin-top: 20px; &__tags { diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.tsx similarity index 84% rename from adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.tsx rename to adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.tsx index 6a4a24d762..c3d6eb6869 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTableExpandedContent.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState } from 'react'; import { SearchInput, Tag, Tags } from '@uikit'; import type { AdcmActionHostGroupHost } from '@models/adcm/actionHostGroup'; -import s from './ClusterActionHostGroupsTableExpandedContent.module.scss'; +import s from './ActionHostGroupsTableExpandedContent.module.scss'; export interface ServiceComponentsTableExpandedContentProps { children: AdcmActionHostGroupHost[]; @@ -21,10 +21,10 @@ const ClusterActionHostGroupsTableExpandedContent = ({ children }: ServiceCompon }; return ( -
+
{childrenFiltered.length > 0 && ( - + {childrenFiltered.map((child) => ( ))} diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters.tsx new file mode 100644 index 0000000000..a3dbf53754 --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Button, SearchInput } from '@uikit'; +import TableFilters from '@commonComponents/Table/TableFilters/TableFilters'; +import { AdcmActionHostGroupsFilter } from '@models/adcm'; + +export interface ClusterActionHostGroupsTableFiltersProps { + filter: AdcmActionHostGroupsFilter; + onFilterChange: (changes: Partial) => void; + onFilterReset: () => void; +} + +const ClusterActionHostGroupsTableFilters = ({ + filter, + onFilterChange, + onFilterReset, +}: ClusterActionHostGroupsTableFiltersProps) => { + const handleGroupNameChange = (event: React.ChangeEvent) => { + onFilterChange({ name: event.target.value }); + }; + + const handleHostNameChange = (event: React.ChangeEvent) => { + onFilterChange({ hasHost: event.target.value }); + }; + + return ( + + + + + + +); + +export default ClusterActionHostGroupsTableToolbar; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts b/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts new file mode 100644 index 0000000000..eed41b0976 --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts @@ -0,0 +1,211 @@ +import { useStore, useDispatch } from '@hooks'; +import { + openCreateDialog, + createActionHostGroupWithUpdate, + closeCreateDialog, + openEditDialog, + updateActionHostGroupWithUpdate, + closeEditDialog, + openDeleteDialog, + deleteActionHostGroupWithUpdate, + closeDeleteDialog, +} from '@store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice'; +import { + openDynamicActionDialog, + closeDynamicActionDialog, + runDynamicAction, +} from '@store/adcm/entityDynamicActions/dynamicActionsSlice'; +import { setFilter, resetFilter } from '@store/adcm/entityActionHostGroups/actionHostGroupsTableSlice'; +import type { AdcmActionHostGroupFormData } from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm'; +import type { ClusterActionHostGroupsTableToolbarProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar'; +import type { ClusterActionHostGroupsTableFiltersProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters'; +import type { ClusterActionHostGroupsTableProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable'; +import type { CreateActionHostGroupDialogProps } from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog'; +import type { DynamicActionDialogProps } from '@commonComponents/DynamicActionDialog/DynamicActionDialog'; +import type { EditActionHostGroupDialogProps } from './ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog'; +import type { DeleteActionHostGroupDialogProps } from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog'; +import type { ActionHostGroupOwner, EntityArgs } from '@store/adcm/entityActionHostGroups/actionHostGroups.types'; +import type { AdcmActionHostGroup, AdcmActionHostGroupsFilter, AdcmDynamicActionRunConfig } from '@models/adcm'; +import { useRequestActionHostGroups } from './useRequestActionHostGroups'; +import { isShowSpinner } from '@uikit/Table/Table.utils'; + +type UseActionHostGroupsResult = { + entityArgs: EntityArgs; + toolbarProps: ClusterActionHostGroupsTableToolbarProps & ClusterActionHostGroupsTableFiltersProps; + tableProps: ClusterActionHostGroupsTableProps; + createDialogProps: CreateActionHostGroupDialogProps; + dynamicActionDialogProps?: DynamicActionDialogProps; + editDialogProps?: EditActionHostGroupDialogProps; + deleteDialogProps?: DeleteActionHostGroupDialogProps; +}; + +export const useActionHostGroups = ( + entityType: T, + entityArgs: EntityArgs, +): UseActionHostGroupsResult => { + const dispatch = useDispatch(); + + useRequestActionHostGroups(entityType, entityArgs); + + const isActionHostGroupsLoading = useStore(({ adcm }) => isShowSpinner(adcm.actionHostGroups.loadState)); + const actionHostGroups = useStore(({ adcm }) => adcm.actionHostGroups.actionHostGroups); + + const runnableActionHostGroupId = useStore(({ adcm }) => adcm.dynamicActions.actionHostGroupId); + const dynamicActions = useStore(({ adcm }) => adcm.dynamicActions.dynamicActions); + const dynamicActionDetails = useStore(({ adcm }) => adcm.dynamicActions.actionDetails); + + const isCreateDialogOpen = useStore(({ adcm }) => adcm.actionHostGroupsActions.createDialog.isOpen); + const allHostCandidates = useStore( + ({ adcm }) => adcm.actionHostGroupsActions.createDialog.relatedData.hostCandidates, + ); + const editableActionHostGroup = useStore(({ adcm }) => adcm.actionHostGroupsActions.editDialog.actionHostGroup); + const actionHostGroupHostCandidates = useStore( + ({ adcm }) => adcm.actionHostGroupsActions.editDialog.relatedData.hostCandidates, + ); + const deletableActionHostGroup = useStore(({ adcm }) => adcm.actionHostGroupsActions.deleteDialog.actionHostGroup); + const filter = useStore(({ adcm }) => adcm.actionHostGroupsTable.filter); + + // Create dialog + + const handleOpenCreateDialog = () => { + dispatch(openCreateDialog({ entityType, entityArgs })); + }; + + const handleCreate = (formData: AdcmActionHostGroupFormData) => { + dispatch( + createActionHostGroupWithUpdate({ + entityType, + entityArgs, + actionHostGroup: { + name: formData.name, + description: formData.description, + }, + hostIds: formData.hosts, + }), + ); + }; + + const handleCloseCreateDialog = () => { + dispatch(closeCreateDialog()); + }; + + // Dynamic action dialog + + const handleOpenDynamicActionDialog = (actionHostGroup: AdcmActionHostGroup, actionId: number) => { + dispatch(openDynamicActionDialog({ entityType, entityArgs, actionHostGroupId: actionHostGroup.id, actionId })); + }; + + const handleSubmitDynamicAction = (actionRunConfig: AdcmDynamicActionRunConfig) => { + dispatch( + runDynamicAction({ + entityType, + entityArgs, + actionHostGroupId: runnableActionHostGroupId!, + actionId: dynamicActionDetails!.id, + actionRunConfig, + }), + ); + }; + + const handleCloseDynamicActionDialog = () => { + dispatch(closeDynamicActionDialog()); + }; + + // Edit dialog + + const handleOpenEditDialog = (actionHostGroup: AdcmActionHostGroup) => { + dispatch(openEditDialog({ entityType, entityArgs, actionHostGroup })); + }; + + const handleEdit = (formData: AdcmActionHostGroupFormData) => { + if (editableActionHostGroup) { + dispatch( + updateActionHostGroupWithUpdate({ + entityType, + entityArgs, + actionHostGroup: editableActionHostGroup, + hostIds: formData.hosts, + }), + ); + } + }; + + const handleCloseEditDialog = () => { + dispatch(closeEditDialog()); + }; + + // Delete dialog + + const handleOpenDeleteDialog = (actionHostGroup: AdcmActionHostGroup) => { + dispatch(openDeleteDialog({ actionHostGroup })); + }; + + const handleDelete = () => { + if (deletableActionHostGroup) { + dispatch(deleteActionHostGroupWithUpdate({ entityType, entityArgs, actionHostGroup: deletableActionHostGroup })); + } + }; + + const handleCloseDeleteDialog = () => { + dispatch(closeDeleteDialog()); + }; + + // filters + + const handleFilterChange = (changes: Partial) => { + dispatch(setFilter(changes)); + }; + + const handleFilterReset = () => { + dispatch(resetFilter()); + }; + + return { + entityArgs, + toolbarProps: { + filter, + onFilterChange: handleFilterChange, + onFilterReset: handleFilterReset, + onOpenCreateDialog: handleOpenCreateDialog, + }, + tableProps: { + actionHostGroups, + dynamicActions, + isLoading: isActionHostGroupsLoading, + onOpenDynamicActionDialog: handleOpenDynamicActionDialog, + onOpenEditDialog: handleOpenEditDialog, + onOpenDeleteDialog: handleOpenDeleteDialog, + }, + createDialogProps: { + isOpen: isCreateDialogOpen, + hostCandidates: allHostCandidates, + onClose: handleCloseCreateDialog, + onCreate: handleCreate, + }, + dynamicActionDialogProps: dynamicActionDetails + ? { + actionDetails: dynamicActionDetails, + clusterId: entityArgs.clusterId, + onSubmit: handleSubmitDynamicAction, + onCancel: handleCloseDynamicActionDialog, + } + : undefined, + editDialogProps: editableActionHostGroup + ? { + isOpen: true, + actionHostGroup: editableActionHostGroup, + hostCandidates: [...actionHostGroupHostCandidates, ...editableActionHostGroup.hosts], + onClose: handleCloseEditDialog, + onEdit: handleEdit, + } + : undefined, + deleteDialogProps: deletableActionHostGroup + ? { + isOpen: true, + actionHostGroup: deletableActionHostGroup!, + onDelete: handleDelete, + onClose: handleCloseDeleteDialog, + } + : undefined, + }; +}; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/useRequestActionHostGroups.ts b/adcm-web/app/src/components/common/ActionHostGroups/useRequestActionHostGroups.ts new file mode 100644 index 0000000000..a90fe9036c --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/useRequestActionHostGroups.ts @@ -0,0 +1,43 @@ +import { useDebounce, useDispatch, useRequestTimer, useStore } from '@hooks'; +import { useEffect } from 'react'; +import { getActionHostGroups, cleanupActionHostGroups } from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; +import { cleanupActions } from '@store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice'; +import { cleanupList } from '@store/adcm/entityActionHostGroups/actionHostGroupsTableSlice'; +import { cleanupDynamicActions, loadDynamicActions } from '@store/adcm/entityDynamicActions/dynamicActionsSlice'; +import { cleanupDynamicActionsMapping } from '@store/adcm/entityDynamicActions/dynamicActionsMappingSlice'; +import { defaultDebounceDelay } from '@constants'; +import type { ActionHostGroupOwner, EntityArgs } from '@store/adcm/entityActionHostGroups/actionHostGroups.types'; + +export const useRequestActionHostGroups = ( + entityType: T, + entityArgs: EntityArgs, +) => { + const dispatch = useDispatch(); + + const actionHostGroups = useStore(({ adcm }) => adcm.actionHostGroups.actionHostGroups); + const filter = useStore(({ adcm }) => adcm.actionHostGroupsTable.filter); + const paginationParams = useStore(({ adcm }) => adcm.actionHostGroupsTable.paginationParams); + + useEffect(() => { + if (actionHostGroups.length) { + const actionHostGroupIds = actionHostGroups.map(({ id }) => id); + dispatch(loadDynamicActions({ entityType, entityArgs, actionHostGroupIds })); + } + }, [dispatch, entityType, entityArgs, actionHostGroups]); + + useEffect(() => { + return () => { + dispatch(cleanupActionHostGroups()); + dispatch(cleanupActions()); + dispatch(cleanupList()); + dispatch(cleanupDynamicActions()); + dispatch(cleanupDynamicActionsMapping()); + }; + }, [dispatch]); + + const debounceGetClusterActionHostGroups = useDebounce(() => { + dispatch(getActionHostGroups({ entityType, entityArgs })); + }, defaultDebounceDelay); + + useRequestTimer(debounceGetClusterActionHostGroups, () => {}, 0, [entityArgs, filter, paginationParams]); +}; diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.tsx index 9c782d1940..15618ae420 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.tsx @@ -10,7 +10,7 @@ import { AdcmDynamicActionRunConfig } from '@models/adcm/dynamicAction'; import DynamicActionConfirm from '@commonComponents/DynamicActionDialog/DynamicActionConfirm/DynamicActionConfirm'; import CustomDialogControls from '@commonComponents/Dialog/CustomDialogControls/CustomDialogControls'; -interface DynamicActionDialogProps extends Omit { +export interface DynamicActionDialogProps extends Omit { clusterId: number | null; onSubmit: (data: AdcmDynamicActionRunConfig) => void; } diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx index 691986e6c8..fcc11a8516 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx @@ -5,7 +5,7 @@ import { DynamicActionCommonOptions } from '@commonComponents/DynamicActionDialo import s from '@commonComponents/DynamicActionDialog/DynamicActionDialog.module.scss'; import { useClusterMapping } from '@pages/cluster/ClusterMapping/useClusterMapping'; import ComponentContainer from '@pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer'; -import { getMappings } from '@store/adcm/clusters/clustersDynamicActionsSlice'; +import { getMappings } from '@store/adcm/entityDynamicActions/dynamicActionsMappingSlice'; import { getComponentMapActions, getDisabledMappings } from './DynamicActionHostMapping.utils'; import { Link } from 'react-router-dom'; import { LoadState } from '@models/loadState'; @@ -30,9 +30,7 @@ const DynamicActionHostMapping: React.FC = ({ } }, [clusterId, dispatch]); - const { - dialog: { hosts, components, mapping, loadState }, - } = useStore(({ adcm }) => adcm.clustersDynamicActions); + const { hosts, components, mapping, loadState } = useStore(({ adcm }) => adcm.dynamicActionsMapping); const notAddedServicesDictionary = useStore(({ adcm }) => adcm.clusterMapping.relatedData.notAddedServicesDictionary); diff --git a/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx b/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx index 113be455fe..523101aebe 100644 --- a/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx +++ b/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx @@ -39,6 +39,7 @@ const ClusterServiceNavigation: React.FC = () => { Primary configuration Configuration groups + Action host groups Components Info diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx index 3ccbc8a83c..e3e623849c 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx @@ -1,12 +1,23 @@ -import { useRequestClusterActionHostGroups } from './useRequestClusterActionHostGroups'; -import ClusterActionHostGroupsTable from './ClusterActionHostGroupsTable/ClusterActionHostGroupsTable'; +import ActionHostGroupsTableToolbar from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar'; +import ActionHostGroupsTable from '@commonComponents/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable'; +import CreateActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog'; +import DynamicActionDialog from '@commonComponents/DynamicActionDialog/DynamicActionDialog'; +import EditActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog'; +import DeleteActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog'; +import { useClusterActionHostGroups } from './useClusterActionHostGroups'; const ClusterActionHostGroups = () => { - useRequestClusterActionHostGroups(); + const { toolbarProps, tableProps, createDialogProps, dynamicActionDialogProps, editDialogProps, deleteDialogProps } = + useClusterActionHostGroups(); return ( <> - + + + + {dynamicActionDialogProps && } + {editDialogProps && } + {deleteDialogProps && } ); }; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.tsx deleted file mode 100644 index 59b64bc2af..0000000000 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroupsTable/ClusterActionHostGroupsTable.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useStore, useDispatch } from '@hooks'; -import { ExpandableRowComponent, IconButton, Table, TableCell } from '@uikit'; -import ClusterActionHostGroupsTableExpandedContent from './ClusterActionHostGroupsTableExpandedContent'; -import { columns } from './ClusterActionHostGroupsTable.constants'; -import { isShowSpinner } from '@uikit/Table/Table.utils'; -import { AdcmActionHostGroup } from '@models/adcm/actionHostGroup'; -import { deleteClusterActionHostGroup } from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; -import { useState } from 'react'; -import ExpandDetailsCell from '@commonComponents/ExpandDetailsCell/ExpandDetailsCell'; - -const ClusterActionHostGroupsTable = () => { - const dispatch = useDispatch(); - - const cluster = useStore(({ adcm }) => adcm.cluster.cluster); - const actionHostGroups = useStore(({ adcm }) => adcm.clusterActionHostGroups.actionHostGroups); - const isLoading = useStore(({ adcm }) => isShowSpinner(adcm.clusterActionHostGroups.loadState)); - - const [expandableRows, setExpandableRows] = useState>({}); - - const handleExpandClick = (id: number) => { - setExpandableRows({ - ...expandableRows, - [id]: expandableRows[id] === undefined ? true : !expandableRows[id], - }); - }; - - const handleRunClick = () => { - console.info('run'); - }; - - const handleEditClick = () => { - console.info('edit'); - }; - - const handleDeleteClick = (group: AdcmActionHostGroup) => { - if (cluster) { - // Confirm dialog? - dispatch(deleteClusterActionHostGroup({ clusterId: cluster.id, actionHostGroupId: group.id })); - } - }; - - return ( - - {actionHostGroups.map((group: AdcmActionHostGroup) => { - return ( - } - > - {group.name} - {group.description} - handleExpandClick(group.id)}> - {group.hosts.length} - - - - - handleDeleteClick(group)} - tooltipProps={{ placement: 'bottom-start' }} - /> - - - ); - })} -
- ); -}; - -export default ClusterActionHostGroupsTable; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useClusterActionHostGroups.ts b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useClusterActionHostGroups.ts new file mode 100644 index 0000000000..11b5b2fbb8 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useClusterActionHostGroups.ts @@ -0,0 +1,13 @@ +import { useParams } from 'react-router-dom'; +import { useActionHostGroups } from '@commonComponents/ActionHostGroups/useActionHostGroups'; +import { useMemo } from 'react'; + +export const useClusterActionHostGroups = () => { + const { clusterId: clusterIdFromUrl } = useParams(); + const clusterId = Number(clusterIdFromUrl); + + const entityArgs = useMemo(() => ({ clusterId }), [clusterId]); + + const props = useActionHostGroups('cluster', entityArgs); + return props; +}; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useRequestClusterActionHostGroups.ts b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useRequestClusterActionHostGroups.ts deleted file mode 100644 index 867749cf32..0000000000 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useRequestClusterActionHostGroups.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useDebounce, useDispatch, useRequestTimer } from '@hooks'; -import { useEffect } from 'react'; -import { - getClusterActionHostGroups, - cleanupClusterActionHostGroups, -} from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; -import { useParams } from 'react-router-dom'; -import { defaultDebounceDelay } from '@constants'; - -export const useRequestClusterActionHostGroups = () => { - const dispatch = useDispatch(); - const { clusterId: clusterIdFromUrl } = useParams(); - const clusterId = Number(clusterIdFromUrl); - - useEffect(() => { - return () => { - dispatch(cleanupClusterActionHostGroups()); - }; - }, [dispatch]); - - const debounceGetClusterActionHostGroups = useDebounce(() => { - if (clusterId) { - dispatch(getClusterActionHostGroups({ clusterId })); - } - }, defaultDebounceDelay); - - useRequestTimer(debounceGetClusterActionHostGroups, () => {}, 0, [clusterId]); -}; diff --git a/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/ServiceActionHostGroups.tsx b/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/ServiceActionHostGroups.tsx new file mode 100644 index 0000000000..fd78b36328 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/ServiceActionHostGroups.tsx @@ -0,0 +1,25 @@ +import ActionHostGroupsTableToolbar from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar'; +import ActionHostGroupsTable from '@commonComponents/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable'; +import CreateActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog'; +import DynamicActionDialog from '@commonComponents/DynamicActionDialog/DynamicActionDialog'; +import EditActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog'; +import DeleteActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog'; +import { useServiceActionHostGroups } from './useServiceActionHostGroups'; + +const ServiceActionHostGroups = () => { + const { toolbarProps, tableProps, createDialogProps, dynamicActionDialogProps, editDialogProps, deleteDialogProps } = + useServiceActionHostGroups(); + + return ( + <> + + + + {dynamicActionDialogProps && } + {editDialogProps && } + {deleteDialogProps && } + + ); +}; + +export default ServiceActionHostGroups; diff --git a/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/useServiceActionHostGroups.ts b/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/useServiceActionHostGroups.ts new file mode 100644 index 0000000000..ae22433deb --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/useServiceActionHostGroups.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { useActionHostGroups } from '@commonComponents/ActionHostGroups/useActionHostGroups'; + +export const useServiceActionHostGroups = () => { + const { clusterId: clusterIdFromUrl, serviceId: serviceIdFromUrl } = useParams(); + const clusterId = Number(clusterIdFromUrl); + const serviceId = Number(serviceIdFromUrl); + + const entityArgs = useMemo(() => ({ clusterId, serviceId }), [clusterId, serviceId]); + + const props = useActionHostGroups('service', entityArgs); + + return props; +}; diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx new file mode 100644 index 0000000000..d0d16a7c7e --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx @@ -0,0 +1,25 @@ +import ActionHostGroupsTableToolbar from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar'; +import ActionHostGroupsTable from '@commonComponents/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable'; +import CreateActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog'; +import DynamicActionDialog from '@commonComponents/DynamicActionDialog/DynamicActionDialog'; +import EditActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog'; +import DeleteActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog'; +import { useComponentActionHostGroups } from './useComponentActionHostGroups'; + +const ComponentActionHostGroups = () => { + const { toolbarProps, tableProps, createDialogProps, dynamicActionDialogProps, editDialogProps, deleteDialogProps } = + useComponentActionHostGroups(); + + return ( + <> + + + + {dynamicActionDialogProps && } + {editDialogProps && } + {deleteDialogProps && } + + ); +}; + +export default ComponentActionHostGroups; diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/useComponentActionHostGroups.ts b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/useComponentActionHostGroups.ts new file mode 100644 index 0000000000..9c41e8d497 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/useComponentActionHostGroups.ts @@ -0,0 +1,16 @@ +import { useParams } from 'react-router-dom'; +import { useActionHostGroups } from '@commonComponents/ActionHostGroups/useActionHostGroups'; +import { useMemo } from 'react'; + +export const useComponentActionHostGroups = () => { + const { clusterId: clusterIdFromUrl, serviceId: serviceIdFromUrl, componentId: componentIdFromUrl } = useParams(); + const clusterId = Number(clusterIdFromUrl); + const serviceId = Number(serviceIdFromUrl); + const componentId = Number(componentIdFromUrl); + + const entityArgs = useMemo(() => ({ clusterId, serviceId, componentId }), [clusterId, serviceId, componentId]); + + const props = useActionHostGroups('component', entityArgs); + + return props; +}; diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx index 82edc83f9d..4999dba9f8 100644 --- a/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx @@ -10,6 +10,7 @@ const ComponentConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups + Action host groups diff --git a/adcm-web/app/src/hooks/useForm.ts b/adcm-web/app/src/hooks/useForm.ts index ded3e4e698..7e546b77ca 100644 --- a/adcm-web/app/src/hooks/useForm.ts +++ b/adcm-web/app/src/hooks/useForm.ts @@ -1,6 +1,8 @@ import { useMemo, useState } from 'react'; -export const useForm = >>( +export type FormErrors = Partial>; + +export const useForm = >( initialFormData: FormData, initialErrorsData: ErrorsData = {} as ErrorsData, ) => { diff --git a/adcm-web/app/src/models/adcm/actionHostGroup.ts b/adcm-web/app/src/models/adcm/actionHostGroup.ts index 0426d04e55..cf733adde1 100644 --- a/adcm-web/app/src/models/adcm/actionHostGroup.ts +++ b/adcm-web/app/src/models/adcm/actionHostGroup.ts @@ -1,11 +1,5 @@ -export interface AdcmActionHostGroupHost { - id: number; - name: string; -} - -export interface AddAdcmActionHostGroupHostPayload { - hostId: number; -} +import type { PaginationParams, SortParams } from '@models/table'; +import { AdcmDynamicActionRunConfig } from './dynamicAction'; export interface AdcmActionHostGroup { id: number; @@ -14,14 +8,301 @@ export interface AdcmActionHostGroup { hosts: AdcmActionHostGroupHost[]; } -export interface CreateAdcmActionHostGroupPayload { - name: string; - description?: string; +export interface AdcmActionHostGroupsFilter { + name?: string; + hasHost?: string; } export interface AdcmActionHostGroupsActionsFilter { - displayName?: string; + hasHost?: string; isHostOwnAction?: boolean; name?: string; + displayName?: string; prototypeId?: number; } + +export interface AdcmActionHostGroupsHostCandidatesFilter { + name?: string; + hasHost?: string; +} + +export interface AdcmActionHostGroupHost { + id: number; + name: string; +} + +export interface NewAdcmActionHostGroup { + name: string; + description?: string; +} + +// Cluster Action Host Groups + +// CRUD +export interface GetAdcmClusterActionHostGroupsArgs { + clusterId: number; + filter: AdcmActionHostGroupsFilter; + paginationParams: PaginationParams; +} + +export interface GetAdcmClusterActionHostGroupArgs { + clusterId: number; + actionHostGroupId: number; +} + +export interface CreateAdcmClusterActionHostGroupArgs { + clusterId: number; + actionHostGroup: NewAdcmActionHostGroup; +} + +export interface DeleteAdcmClusterActionHostGroupArgs { + clusterId: number; + actionHostGroupId: number; +} + +// Actions +export interface GetAdcmClusterActionHostGroupActionsArgs { + clusterId: number; + actionHostGroupId: number; + sortParams?: SortParams; + paginationParams?: PaginationParams; + filter?: AdcmActionHostGroupsActionsFilter; +} + +export interface GetAdcmClusterActionHostGroupActionArgs { + clusterId: number; + actionHostGroupId: number; + actionId: number; +} + +export interface RunAdcmClusterActionHostGroupActionArgs { + clusterId: number; + actionHostGroupId: number; + actionId: number; + actionRunConfig: AdcmDynamicActionRunConfig; +} + +// Hosts candidates +export interface GetAdcmClusterActionHostGroupsHostCandidatesArgs { + clusterId: number; + filter: AdcmActionHostGroupsHostCandidatesFilter; +} + +export interface GetAdcmClusterActionHostGroupHostCandidatesArgs { + clusterId: number; + actionHostGroupId: number; + filter: AdcmActionHostGroupsHostCandidatesFilter; +} + +//Hosts +export interface GetAdcmClusterActionHostGroupHostsArgs { + clusterId: number; + actionHostGroupId: number; + paginationParams?: PaginationParams; + sortParams?: SortParams; +} + +export interface AddAdcmClusterActionHostGroupHostArgs { + clusterId: number; + actionHostGroupId: number; + hostId: number; +} + +export interface DeleteAdcmClusterActionHostGroupHostArgs { + clusterId: number; + actionHostGroupId: number; + hostId: number; +} + +// Service Action Host Groups + +// CRUD +export interface GetAdcmServiceActionHostGroupsArgs { + clusterId: number; + serviceId: number; + filter: AdcmActionHostGroupsFilter; + paginationParams: PaginationParams; +} + +export interface GetAdcmServiceActionHostGroupArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; +} + +export interface CreateAdcmServiceActionHostGroupArgs { + clusterId: number; + serviceId: number; + actionHostGroup: NewAdcmActionHostGroup; +} + +export interface DeleteAdcmServiceActionHostGroupArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; +} + +// Actions +export interface GetAdcmServiceActionHostGroupActionsArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; + sortParams: SortParams; + paginationParams: PaginationParams; + filter: AdcmActionHostGroupsActionsFilter; +} + +export interface GetAdcmServiceActionHostGroupActionArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; + actionId: number; +} + +export interface RunAdcmServiceActionHostGroupActionArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; + actionId: number; + actionRunConfig: AdcmDynamicActionRunConfig; +} + +// Hosts candidates +export interface GetAdcmServiceActionHostGroupsHostCandidatesArgs { + clusterId: number; + serviceId: number; + filter: AdcmActionHostGroupsHostCandidatesFilter; +} + +export interface GetAdcmServiceActionHostGroupHostCandidatesArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; + filter: AdcmActionHostGroupsHostCandidatesFilter; +} + +// Hosts +export interface GetAdcmServiceActionHostGroupHostsArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; + paginationParams?: PaginationParams; + sortParams?: SortParams; +} + +export interface AddAdcmServiceActionHostGroupHostArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; + hostId: number; +} + +export interface DeleteAdcmServiceActionHostGroupHostArgs { + clusterId: number; + serviceId: number; + actionHostGroupId: number; + hostId: number; +} + +// Component Action Host Groups + +// CRUD +export interface GetAdcmComponentActionHostGroupsArgs { + clusterId: number; + serviceId: number; + componentId: number; + filter: AdcmActionHostGroupsFilter; + paginationParams: PaginationParams; +} + +export interface GetAdcmComponentActionHostGroupArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; +} + +export interface CreateAdcmComponentActionHostGroupArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroup: NewAdcmActionHostGroup; +} + +export interface DeleteAdcmComponentActionHostGroupArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; +} + +// Actions +export interface GetAdcmComponentActionHostGroupActionsArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; + sortParams: SortParams; + paginationParams: PaginationParams; + filter: AdcmActionHostGroupsActionsFilter; +} + +export interface GetAdcmComponentActionHostGroupActionArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; + actionId: number; +} + +export interface RunAdcmComponentActionHostGroupActionArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; + actionId: number; + actionRunConfig: AdcmDynamicActionRunConfig; +} + +// Hosts candidates +export interface GetAdcmComponentActionHostGroupsHostCandidatesArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; + filter: AdcmActionHostGroupsHostCandidatesFilter; +} + +export interface GetAdcmComponentActionHostGroupHostCandidatesArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; + filter: AdcmActionHostGroupsHostCandidatesFilter; +} + +// Hosts +export interface GetAdcmComponentActionHostGroupHostsArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; + paginationParams?: PaginationParams; + sortParams?: SortParams; +} + +export interface AddAdcmComponentActionHostGroupHostArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; + hostId: number; +} + +export interface DeleteAdcmComponentActionHostGroupHostArgs { + clusterId: number; + serviceId: number; + componentId: number; + actionHostGroupId: number; + hostId: number; +} diff --git a/adcm-web/app/src/models/adcm/dynamicAction.ts b/adcm-web/app/src/models/adcm/dynamicAction.ts index 108520c90e..4843945567 100644 --- a/adcm-web/app/src/models/adcm/dynamicAction.ts +++ b/adcm-web/app/src/models/adcm/dynamicAction.ts @@ -38,3 +38,5 @@ export interface AdcmDynamicActionRunConfig { isVerbose: boolean; configuration: Pick | null; } + +export type EntitiesDynamicActions = Record; diff --git a/adcm-web/app/src/store/adcm/clusters/clustersDynamicActionsSlice.ts b/adcm-web/app/src/store/adcm/clusters/clustersDynamicActionsSlice.ts index 55439bffce..a81eee6dbd 100644 --- a/adcm-web/app/src/store/adcm/clusters/clustersDynamicActionsSlice.ts +++ b/adcm-web/app/src/store/adcm/clusters/clustersDynamicActionsSlice.ts @@ -1,25 +1,12 @@ import { createSlice } from '@reduxjs/toolkit'; -import { - AdcmCluster, - AdcmHostShortView, - AdcmMapping, - AdcmMappingComponent, - NotAddedServicesDictionary, -} from '@models/adcm'; +import { AdcmCluster } from '@models/adcm'; import { createAsyncThunk } from '@store/redux'; -import { AdcmClusterMappingApi, AdcmClustersApi, RequestError } from '@api'; +import { AdcmClustersApi, RequestError } from '@api'; import { fulfilledFilter } from '@utils/promiseUtils'; import { showError, showSuccess } from '@store/notificationsSlice'; import { AdcmDynamicAction, AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm/dynamicAction'; import { getErrorMessage } from '@utils/httpResponseUtils'; import { ActionStatuses } from '@constants'; -import { LoadState } from '@models/loadState'; -import { AdcmClusterServicesApi } from '@api/adcm/clusterServices'; -import { arrayToHash } from '@utils/arrayUtils'; - -type GetClusterMappingArg = { - clusterId: number; -}; const loadClustersDynamicActions = createAsyncThunk( 'adcm/clustersDynamicActions/loadClustersDynamicActions', @@ -76,28 +63,6 @@ const openClusterDynamicActionDialog = createAsyncThunk( }, ); -const loadMappings = createAsyncThunk( - 'adcm/clustersDynamicActions/loadMappings', - async ({ clusterId }: GetClusterMappingArg, thunkAPI) => { - try { - const mapping = await AdcmClusterMappingApi.getMapping(clusterId); - const hosts = await AdcmClusterMappingApi.getMappingHosts(clusterId); - const components = await AdcmClusterMappingApi.getMappingComponents(clusterId); - const notAddedServices = await AdcmClusterServicesApi.getClusterServiceCandidates(clusterId); - return { mapping, components, hosts, notAddedServices }; - } catch (error) { - return thunkAPI.rejectWithValue(error); - } - }, -); - -const getMappings = createAsyncThunk( - 'adcm/clustersDynamicActions/getMappings', - async (arg: GetClusterMappingArg, thunkAPI) => { - await thunkAPI.dispatch(loadMappings(arg)); - }, -); - interface RunClusterDynamicActionPayload { cluster: AdcmCluster; actionId: number; @@ -125,11 +90,6 @@ type AdcmClustersDynamicActionsState = { dialog: { actionDetails: AdcmDynamicActionDetails | null; cluster: AdcmCluster | null; - mapping: AdcmMapping[]; - hosts: AdcmHostShortView[]; - components: AdcmMappingComponent[]; - notAddedServicesDictionary: NotAddedServicesDictionary; - loadState: LoadState; }; clusterDynamicActions: Record; }; @@ -138,11 +98,6 @@ const createInitialState = (): AdcmClustersDynamicActionsState => ({ dialog: { actionDetails: null, cluster: null, - mapping: [], - hosts: [], - components: [], - notAddedServicesDictionary: {}, - loadState: LoadState.NotLoaded, }, clusterDynamicActions: {}, }); @@ -161,18 +116,6 @@ const clustersDynamicActionsSlice = createSlice({ }, }, extraReducers: (builder) => { - builder.addCase(loadMappings.fulfilled, (state, action) => { - state.dialog.mapping = action.payload.mapping; - state.dialog.hosts = action.payload.hosts; - state.dialog.components = action.payload.components; - state.dialog.notAddedServicesDictionary = arrayToHash(action.payload.notAddedServices, (s) => s.id); - }); - builder.addCase(getMappings.pending, (state) => { - state.dialog.loadState = LoadState.Loading; - }); - builder.addCase(getMappings.fulfilled, (state) => { - state.dialog.loadState = LoadState.Loaded; - }); builder.addCase(loadClustersDynamicActions.fulfilled, (state, action) => { state.clusterDynamicActions = action.payload; }); @@ -193,6 +136,6 @@ const clustersDynamicActionsSlice = createSlice({ }); export const { cleanupClusterDynamicActions, closeClusterDynamicActionDialog } = clustersDynamicActionsSlice.actions; -export { loadClustersDynamicActions, openClusterDynamicActionDialog, getMappings, runClusterDynamicAction }; +export { loadClustersDynamicActions, openClusterDynamicActionDialog, runClusterDynamicAction }; export default clustersDynamicActionsSlice.reducer; diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroups.types.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroups.types.ts new file mode 100644 index 0000000000..556dda178c --- /dev/null +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroups.types.ts @@ -0,0 +1,163 @@ +import type { + Batch, + AdcmDynamicAction, + AdcmDynamicActionDetails, + AdcmJob, + AdcmActionHostGroup, + AdcmActionHostGroupHost, + GetAdcmClusterActionHostGroupsArgs, + GetAdcmClusterActionHostGroupArgs, + CreateAdcmClusterActionHostGroupArgs, + DeleteAdcmClusterActionHostGroupArgs, + GetAdcmClusterActionHostGroupActionsArgs, + GetAdcmClusterActionHostGroupActionArgs, + RunAdcmClusterActionHostGroupActionArgs, + GetAdcmClusterActionHostGroupsHostCandidatesArgs, + GetAdcmClusterActionHostGroupHostCandidatesArgs, + GetAdcmClusterActionHostGroupHostsArgs, + AddAdcmClusterActionHostGroupHostArgs, + DeleteAdcmClusterActionHostGroupHostArgs, + NewAdcmActionHostGroup, + AdcmDynamicActionRunConfig, +} from '@models/adcm'; + +export type ActionHostGroupOwner = 'cluster' | 'service' | 'component'; + +type ClusterArgs = { + clusterId: number; +}; + +type ServiceArgs = { + clusterId: number; + serviceId: number; +}; + +type ComponentArgs = { + clusterId: number; + serviceId: number; + componentId: number; +}; + +type SomeEntityArgs = ClusterArgs | ServiceArgs | ComponentArgs; + +export type EntityArgs = T extends 'cluster' + ? ClusterArgs + : T extends 'service' + ? ServiceArgs + : T extends 'component' + ? ComponentArgs + : never; + +// Slice Actions Payloads +export type GetActionHostGroupsActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; +}; + +export type OpenCreateDialogActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; +}; + +export type LoadCreateDialogRelatedDataActionPayload = OpenCreateDialogActionPayload; + +export type CreateActionHostGroupActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; + actionHostGroup: NewAdcmActionHostGroup; + hostIds: Set; +}; + +export type OpenEditDialogActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; + actionHostGroup: AdcmActionHostGroup; +}; + +export type LoadEditDialogRelatedDataActionPayload = OpenEditDialogActionPayload; + +export type UpdateActionHostGroupActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; + actionHostGroup: AdcmActionHostGroup; + hostIds: Set; +}; + +export type OpenDeleteDialogActionPayload = { + actionHostGroup: AdcmActionHostGroup; +}; + +export type DeleteActionHostGroupActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; + actionHostGroup: AdcmActionHostGroup; +}; + +export type GetActionHostGroupDynamicActionsActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; + actionHostGroupIds: number[]; +}; + +export type GetActionHostGroupDynamicActionDetailsActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; + actionHostGroupId: number; + actionId: number; +}; + +export type OpenDynamicActionActionPayload = GetActionHostGroupDynamicActionDetailsActionPayload; + +export type RunActionHostGroupDynamicActionActionPayload = { + entityType: ActionHostGroupOwner; + entityArgs: SomeEntityArgs; + actionHostGroupId: number; + actionId: number; + actionRunConfig: AdcmDynamicActionRunConfig; +}; + +// Api Args +type SomeEntityApiArgs = SomeEntityArgs & Omit; + +// CRUD +export type GetAdcmActionHostGroupsArgs = SomeEntityApiArgs; +export type GetAdcmActionHostGroupArgs = SomeEntityApiArgs; +export type CreateAdcmActionHostGroupArgs = SomeEntityApiArgs; +export type DeleteAdcmActionHostGroupArgs = SomeEntityApiArgs; + +// Actions +export type GetAdcmActionHostGroupActionsArgs = SomeEntityApiArgs; +export type GetAdcmActionHostGroupActionArgs = SomeEntityApiArgs; +export type RunAdcmActionHostGroupActionArgs = SomeEntityApiArgs; + +// Hosts candidates +export type GetAdcmActionHostGroupsHostCandidatesArgs = + SomeEntityApiArgs; +export type GetAdcmActionHostGroupHostCandidatesArgs = + SomeEntityApiArgs; + +// Hosts +export type GetAdcmActionHostGroupHostsArgs = SomeEntityApiArgs; +export type AddAdcmActionHostGroupHostArgs = SomeEntityApiArgs; +export type DeleteAdcmActionHostGroupHostArgs = SomeEntityApiArgs; + +export interface ActionHostGroupApi { + // CRUD + getActionHostGroups(args: GetAdcmActionHostGroupsArgs): Promise>; + getActionHostGroup(args: GetAdcmActionHostGroupArgs): Promise; + postActionHostGroup(args: CreateAdcmActionHostGroupArgs): Promise; + deleteActionHostGroup(args: DeleteAdcmActionHostGroupArgs): Promise; + // Actions + getActionHostGroupActions(args: GetAdcmActionHostGroupActionsArgs): Promise; + getActionHostGroupAction(args: GetAdcmActionHostGroupActionArgs): Promise; + postActionHostGroupAction(args: RunAdcmActionHostGroupActionArgs): Promise; + // Host candidates + getActionHostGroupsHostCandidates( + args: GetAdcmActionHostGroupsHostCandidatesArgs, + ): Promise; + getActionHostGroupHostCandidates(args: GetAdcmActionHostGroupHostCandidatesArgs): Promise; + // Hosts + getActionHostGroupHosts(args: GetAdcmActionHostGroupHostsArgs): Promise>; + postActionHostGroupHost(args: AddAdcmActionHostGroupHostArgs): Promise; + deleteActionHostGroupHost(args: DeleteAdcmActionHostGroupHostArgs): Promise; +} diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts new file mode 100644 index 0000000000..bf46c5ac24 --- /dev/null +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts @@ -0,0 +1,287 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { createAsyncThunk } from '@store/redux'; +import { getActionHostGroups } from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; +import { showError, showInfo, showSuccess } from '@store/notificationsSlice'; +import { getErrorMessage } from '@utils/httpResponseUtils'; +import { RequestError } from '@api'; +import { ActionState } from '@models/loadState'; +import { services } from './actionHostGroupsSlice.constants'; +import type { + OpenCreateDialogActionPayload, + LoadCreateDialogRelatedDataActionPayload, + CreateActionHostGroupActionPayload, + OpenEditDialogActionPayload, + LoadEditDialogRelatedDataActionPayload, + UpdateActionHostGroupActionPayload, + OpenDeleteDialogActionPayload, + DeleteActionHostGroupActionPayload, +} from './actionHostGroups.types'; +import type { AdcmActionHostGroup, AdcmActionHostGroupHost } from '@models/adcm'; +import { splitHosts } from './actionHostGroupsActionsSlice.utils'; + +const openCreateDialog = createAsyncThunk( + 'adcm/actionHostsGroupsActions/openCreateDialog', + async (args: OpenCreateDialogActionPayload, thunkAPI) => { + try { + thunkAPI.dispatch(loadCreateRelatedData(args)); + } catch (error) { + return thunkAPI.rejectWithValue(error); + } + }, +); + +const loadCreateRelatedData = createAsyncThunk( + 'adcm/actionHostsGroupsActions/loadCreateRelatedData', + async ({ entityType, entityArgs }: LoadCreateDialogRelatedDataActionPayload) => { + const service = services[entityType]; + const hostCandidates = await service.getActionHostGroupsHostCandidates({ ...entityArgs, filter: {} }); + return { hostCandidates }; + }, +); + +const createActionHostGroup = createAsyncThunk( + 'adcm/actionHostsGroupsActions/createActionHostGroup', + async ({ entityType, entityArgs, actionHostGroup, hostIds }: CreateActionHostGroupActionPayload, thunkAPI) => { + try { + const service = services[entityType]; + thunkAPI.dispatch(setCreatingState('in-progress')); + const host = await service.postActionHostGroup({ ...entityArgs, actionHostGroup }); + + const promises = []; + for (const id of hostIds.values()) { + promises.push( + service.postActionHostGroupHost({ + ...entityArgs, + actionHostGroupId: host.id, + hostId: id, + }), + ); + } + + return host; + } catch (error) { + thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); + return thunkAPI.rejectWithValue(error); + } finally { + thunkAPI.dispatch(setCreatingState('completed')); + } + }, +); + +const createActionHostGroupWithUpdate = createAsyncThunk( + 'adcm/actionHostsGroupsActions/createHostWithUpdate', + async (args: CreateActionHostGroupActionPayload, thunkAPI) => { + const { entityType, entityArgs } = args; + await thunkAPI.dispatch(createActionHostGroup(args)).unwrap(); + await thunkAPI.dispatch(getActionHostGroups({ entityType, entityArgs })); + }, +); + +const openEditDialog = createAsyncThunk( + 'adcm/actionHostsGroupsActions/openEditDialog', + async (args: OpenEditDialogActionPayload, thunkAPI) => { + try { + thunkAPI.dispatch(loadEditRelatedData(args)); + } catch (error) { + return thunkAPI.rejectWithValue(error); + } + }, +); + +const loadEditRelatedData = createAsyncThunk( + 'adcm/actionHostsGroupsActions/loadEditRelatedData', + async ({ entityType, entityArgs, actionHostGroup }: LoadEditDialogRelatedDataActionPayload) => { + const service = services[entityType]; + const hostCandidates = await service.getActionHostGroupHostCandidates({ + ...entityArgs, + filter: {}, + actionHostGroupId: actionHostGroup.id, + }); + return { hostCandidates }; + }, +); + +const updateActionHostGroup = createAsyncThunk( + 'adcm/actionHostsGroupsActions/updateActionHostGroup', + async ({ entityType, entityArgs, actionHostGroup, hostIds }: UpdateActionHostGroupActionPayload, thunkAPI) => { + try { + const service = services[entityType]; + thunkAPI.dispatch(setEditingState('in-progress')); + + const addPromises = []; + const deletePromises = []; + + const { toAdd, toDelete } = splitHosts(actionHostGroup.hosts, hostIds); + + for (const id of toAdd.values()) { + addPromises.push( + service.postActionHostGroupHost({ + ...entityArgs, + actionHostGroupId: actionHostGroup?.id, + hostId: id, + }), + ); + } + + for (const id of toDelete.values()) { + deletePromises.push( + service.deleteActionHostGroupHost({ + ...entityArgs, + actionHostGroupId: actionHostGroup?.id, + hostId: id, + }), + ); + } + } catch (error) { + thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); + return thunkAPI.rejectWithValue(error); + } finally { + thunkAPI.dispatch(setEditingState('completed')); + } + }, +); + +const updateActionHostGroupWithUpdate = createAsyncThunk( + 'adcm/actionHostsGroupsActions/createHostWithUpdate', + async (args: UpdateActionHostGroupActionPayload, thunkAPI) => { + const { entityType, entityArgs } = args; + await thunkAPI.dispatch(updateActionHostGroup(args)).unwrap(); + await thunkAPI.dispatch(getActionHostGroups({ entityType, entityArgs })); + }, +); + +const deleteActionHostGroup = createAsyncThunk( + 'adcm/actionHostsGroupsActions/deleteActionHostGroup', + async ({ entityType, entityArgs, actionHostGroup }: DeleteActionHostGroupActionPayload, thunkAPI) => { + try { + const service = services[entityType]; + await service.deleteActionHostGroup({ ...entityArgs, actionHostGroupId: actionHostGroup.id }); + thunkAPI.dispatch(showSuccess({ message: 'The action host group has been deleted' })); + } catch (error) { + thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); + return thunkAPI.rejectWithValue(error); + } + }, +); + +const deleteActionHostGroupWithUpdate = createAsyncThunk( + 'adcm/actionHostsGroupsActions/deleteActionHostGroupWithUpdate', + async (args: DeleteActionHostGroupActionPayload, thunkAPI) => { + const { entityType, entityArgs } = args; + await thunkAPI.dispatch(deleteActionHostGroup(args)).unwrap(); + await thunkAPI.dispatch(getActionHostGroups({ entityType, entityArgs })); + }, +); + +interface AdcmActionHostGroupsActionsState { + createDialog: { + isOpen: boolean; + creatingState: ActionState; + relatedData: { + hostCandidates: AdcmActionHostGroupHost[]; + }; + }; + editDialog: { + actionHostGroup: AdcmActionHostGroup | null; + editingState: ActionState; + relatedData: { + hostCandidates: AdcmActionHostGroupHost[]; + }; + }; + deleteDialog: { + actionHostGroup: AdcmActionHostGroup | null; + deletingState: ActionState; + }; +} + +const createInitialState = (): AdcmActionHostGroupsActionsState => ({ + createDialog: { + isOpen: false, + creatingState: 'not-started', + relatedData: { + hostCandidates: [], + }, + }, + editDialog: { + actionHostGroup: null, + editingState: 'not-started', + relatedData: { + hostCandidates: [], + }, + }, + deleteDialog: { + actionHostGroup: null, + deletingState: 'not-started', + }, +}); + +const actionHostGroupsActionsSlice = createSlice({ + name: 'adcm/actionHostsGroupsActions', + initialState: createInitialState(), + reducers: { + cleanupActions() { + return createInitialState(); + }, + closeCreateDialog() { + return createInitialState(); + }, + setCreatingState(state, action: PayloadAction) { + state.createDialog.creatingState = action.payload; + }, + setEditingState(state, action: PayloadAction) { + state.editDialog.editingState = action.payload; + }, + closeEditDialog() { + return createInitialState(); + }, + openDeleteDialog(state, action: PayloadAction) { + state.deleteDialog.actionHostGroup = action.payload.actionHostGroup; + }, + closeDeleteDialog() { + return createInitialState(); + }, + }, + extraReducers: (builder) => { + builder.addCase(openCreateDialog.fulfilled, (state) => { + state.createDialog.isOpen = true; + }); + builder.addCase(loadCreateRelatedData.fulfilled, (state, action) => { + state.createDialog.relatedData = action.payload; + }); + builder.addCase(createActionHostGroup.fulfilled, () => { + return createInitialState(); + }); + builder.addCase(getActionHostGroups.pending, () => { + return createInitialState(); + }); + builder.addCase(openEditDialog.fulfilled, (state, action) => { + state.editDialog.actionHostGroup = action.meta.arg.actionHostGroup; + }); + builder.addCase(loadEditRelatedData.fulfilled, (state, action) => { + state.editDialog.relatedData = action.payload; + }); + builder.addCase(updateActionHostGroup.fulfilled, () => { + return createInitialState(); + }); + }, +}); + +export const { + closeCreateDialog, + setCreatingState, + setEditingState, + closeEditDialog, + openDeleteDialog, + closeDeleteDialog, + cleanupActions, +} = actionHostGroupsActionsSlice.actions; + +export { + openCreateDialog, + createActionHostGroupWithUpdate, + openEditDialog, + updateActionHostGroupWithUpdate, + deleteActionHostGroupWithUpdate, +}; + +export default actionHostGroupsActionsSlice.reducer; diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.utils.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.utils.ts new file mode 100644 index 0000000000..dbe4a8f4e8 --- /dev/null +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.utils.ts @@ -0,0 +1,19 @@ +import { AdcmActionHostGroupHost } from '@models/adcm'; + +export const splitHosts = (currentHosts: AdcmActionHostGroupHost[], newHostsIds: Set) => { + const toDelete = new Set(); + const toAdd = new Set(newHostsIds); + + for (const host of currentHosts) { + if (newHostsIds.has(host.id)) { + toAdd.delete(host.id); + } else { + toDelete.add(host.id); + } + } + + return { + toDelete, + toAdd, + }; +}; diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.constants.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.constants.ts new file mode 100644 index 0000000000..ca128ce02d --- /dev/null +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.constants.ts @@ -0,0 +1,12 @@ +import { + AdcmClusterActionHostGroupsApi, + AdcmClusterServiceActionHostGroupsApi, + AdcmClusterServiceComponentActionHostGroupsApi, +} from '@api'; +import type { ActionHostGroupApi, ActionHostGroupOwner } from './actionHostGroups.types'; + +export const services: { [owner in ActionHostGroupOwner]: ActionHostGroupApi } = { + cluster: AdcmClusterActionHostGroupsApi, + service: AdcmClusterServiceActionHostGroupsApi, + component: AdcmClusterServiceComponentActionHostGroupsApi, +}; diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.ts index f980ee01cd..299b44d9cb 100644 --- a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.ts +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsSlice.ts @@ -1,31 +1,30 @@ import { createSlice } from '@reduxjs/toolkit'; -import { AdcmClusterActionHostGroupsApi } from '@api'; import { createAsyncThunk } from '@store/redux'; import { executeWithMinDelay } from '@utils/requestUtils'; import { defaultSpinnerDelay } from '@constants'; import { LoadState } from '@models/loadState'; import type { AdcmActionHostGroup } from '@models/adcm/actionHostGroup'; +import { GetActionHostGroupsActionPayload } from './actionHostGroups.types'; +import { services } from './actionHostGroupsSlice.constants'; -type AdcmClusterActionHostGroupsState = { +type AdcmActionHostGroupsState = { actionHostGroups: AdcmActionHostGroup[]; totalCount: number; loadState: LoadState; }; -type GetClusterActionHostGroupsArgs = { - clusterId: number; -}; - -type DeleteClusterActionHostsGroupArgs = { - clusterId: number; - actionHostGroupId: number; -}; - -const loadClusterActionHostGroupsFromBackend = createAsyncThunk( - 'adcm/clusterActionHostGroups/loadClusterActionHostGroupsFromBackend', - async (clusterId: number, thunkAPI) => { +const loadActionHostGroupsFromBackend = createAsyncThunk( + 'adcm/actionHostGroups/loadActionHostGroupsFromBackend', + async ({ entityType, entityArgs }: GetActionHostGroupsActionPayload, thunkAPI) => { try { - const batch = await AdcmClusterActionHostGroupsApi.getActionHostGroups(clusterId, { pageNumber: 0, perPage: 10 }); + const service = services[entityType]; + const { + adcm: { + actionHostGroupsTable: { filter, paginationParams }, + }, + } = thunkAPI.getState(); + + const batch = await service.getActionHostGroups({ ...entityArgs, filter, paginationParams }); return batch; } catch (error) { return thunkAPI.rejectWithValue(error); @@ -33,13 +32,13 @@ const loadClusterActionHostGroupsFromBackend = createAsyncThunk( }, ); -const getClusterActionHostGroups = createAsyncThunk( - 'adcm/clusterActionHostGroups/getClusterActionHostGroups', - async ({ clusterId }: GetClusterActionHostGroupsArgs, thunkAPI) => { +const getActionHostGroups = createAsyncThunk( + 'adcm/actionHostGroups/getActionHostGroups', + async (args: GetActionHostGroupsActionPayload, thunkAPI) => { thunkAPI.dispatch(setLoadState(LoadState.Loading)); const startDate = new Date(); - await thunkAPI.dispatch(loadClusterActionHostGroupsFromBackend(clusterId)); + await thunkAPI.dispatch(loadActionHostGroupsFromBackend(args)); executeWithMinDelay({ startDate, @@ -51,45 +50,34 @@ const getClusterActionHostGroups = createAsyncThunk( }, ); -const deleteClusterActionHostGroup = createAsyncThunk( - 'adcm/clusterActionHostGroups/deleteClusterActionHostGroup', - async (args: DeleteClusterActionHostsGroupArgs, thunkAPI) => { - try { - await AdcmClusterActionHostGroupsApi.deleteActionHostGroup(args.clusterId, args.actionHostGroupId); - } catch (error) { - return thunkAPI.rejectWithValue(error); - } - }, -); - -const createInitialState = (): AdcmClusterActionHostGroupsState => ({ +const createInitialState = (): AdcmActionHostGroupsState => ({ actionHostGroups: [], totalCount: 0, loadState: LoadState.NotLoaded, }); -const clusterHostsSlice = createSlice({ - name: 'adcm/clusterActionHostGroups', +const actionHostGroupsSlice = createSlice({ + name: 'adcm/actionHostGroups', initialState: createInitialState(), reducers: { setLoadState(state, action) { state.loadState = action.payload; }, - cleanupClusterActionHostGroups() { + cleanupActionHostGroups() { return createInitialState(); }, }, extraReducers: (builder) => { - builder.addCase(loadClusterActionHostGroupsFromBackend.fulfilled, (state, action) => { + builder.addCase(loadActionHostGroupsFromBackend.fulfilled, (state, action) => { state.actionHostGroups = action.payload.results; state.totalCount = action.payload.count; }); - builder.addCase(loadClusterActionHostGroupsFromBackend.rejected, (state) => { + builder.addCase(loadActionHostGroupsFromBackend.rejected, (state) => { state.actionHostGroups = []; }); }, }); -const { setLoadState, cleanupClusterActionHostGroups } = clusterHostsSlice.actions; -export { getClusterActionHostGroups, cleanupClusterActionHostGroups, deleteClusterActionHostGroup }; -export default clusterHostsSlice.reducer; +const { setLoadState, cleanupActionHostGroups } = actionHostGroupsSlice.actions; +export { getActionHostGroups, cleanupActionHostGroups }; +export default actionHostGroupsSlice.reducer; diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsTableSlice.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsTableSlice.ts new file mode 100644 index 0000000000..0aa3175883 --- /dev/null +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsTableSlice.ts @@ -0,0 +1,31 @@ +import { ListState } from '@models/table'; +import { createListSlice } from '@store/redux'; +import { AdcmActionHostGroupsFilter } from '@models/adcm'; + +type AdcmActionHostGroupsTableState = ListState; + +const createInitialState = (): AdcmActionHostGroupsTableState => ({ + filter: { + name: undefined, + hasHost: undefined, + }, + paginationParams: { + perPage: 10, + pageNumber: 0, + }, + requestFrequency: 0, + sortParams: { + sortBy: 'name', + sortDirection: 'asc', + }, +}); + +const actionHostGroupsTableSlice = createListSlice({ + name: 'adcm/actionHostsGroupsTable', + createInitialState, + reducers: {}, +}); + +export const { setPaginationParams, setRequestFrequency, cleanupList, setFilter, resetFilter } = + actionHostGroupsTableSlice.actions; +export default actionHostGroupsTableSlice.reducer; diff --git a/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsMappingSlice.ts b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsMappingSlice.ts new file mode 100644 index 0000000000..03d8edd948 --- /dev/null +++ b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsMappingSlice.ts @@ -0,0 +1,78 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { AdcmHostShortView, AdcmMapping, AdcmMappingComponent, NotAddedServicesDictionary } from '@models/adcm'; +import { createAsyncThunk } from '@store/redux'; +import { AdcmClusterMappingApi } from '@api'; +import { LoadState } from '@models/loadState'; +import { AdcmClusterServicesApi } from '@api/adcm/clusterServices'; +import { arrayToHash } from '@utils/arrayUtils'; + +type GetClusterMappingArg = { + clusterId: number; +}; + +const loadMappings = createAsyncThunk( + 'adcm/dynamicActionsMapping/loadMappings', + async ({ clusterId }: GetClusterMappingArg, thunkAPI) => { + try { + const mapping = await AdcmClusterMappingApi.getMapping(clusterId); + const hosts = await AdcmClusterMappingApi.getMappingHosts(clusterId); + const components = await AdcmClusterMappingApi.getMappingComponents(clusterId); + const notAddedServices = await AdcmClusterServicesApi.getClusterServiceCandidates(clusterId); + return { mapping, components, hosts, notAddedServices }; + } catch (error) { + return thunkAPI.rejectWithValue(error); + } + }, +); + +const getMappings = createAsyncThunk( + 'adcm/dynamicActionsMapping/getMappings', + async (arg: GetClusterMappingArg, thunkAPI) => { + await thunkAPI.dispatch(loadMappings(arg)); + }, +); + +type AdcmDynamicActionsState = { + components: AdcmMappingComponent[]; + mapping: AdcmMapping[]; + hosts: AdcmHostShortView[]; + notAddedServicesDictionary: NotAddedServicesDictionary; + loadState: LoadState; +}; + +const createInitialState = (): AdcmDynamicActionsState => ({ + mapping: [], + hosts: [], + components: [], + notAddedServicesDictionary: {}, + loadState: LoadState.NotLoaded, +}); + +const dynamicActionsMappingSlice = createSlice({ + name: 'adcm/dynamicActionsMapping', + initialState: createInitialState(), + reducers: { + cleanupDynamicActionsMapping() { + return createInitialState(); + }, + }, + extraReducers: (builder) => { + builder.addCase(loadMappings.fulfilled, (state, action) => { + state.mapping = action.payload.mapping; + state.hosts = action.payload.hosts; + state.components = action.payload.components; + state.notAddedServicesDictionary = arrayToHash(action.payload.notAddedServices, (s) => s.id); + }); + builder.addCase(getMappings.pending, (state) => { + state.loadState = LoadState.Loading; + }); + builder.addCase(getMappings.fulfilled, (state) => { + state.loadState = LoadState.Loaded; + }); + }, +}); + +export const { cleanupDynamicActionsMapping } = dynamicActionsMappingSlice.actions; +export { getMappings }; + +export default dynamicActionsMappingSlice.reducer; diff --git a/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts new file mode 100644 index 0000000000..96417ef29a --- /dev/null +++ b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts @@ -0,0 +1,144 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { createAsyncThunk } from '@store/redux'; +import { RequestError } from '@api'; +import { fulfilledFilter } from '@utils/promiseUtils'; +import { showError, showSuccess } from '@store/notificationsSlice'; +import type { AdcmDynamicActionDetails, EntitiesDynamicActions } from '@models/adcm/dynamicAction'; +import { getErrorMessage } from '@utils/httpResponseUtils'; +import { ActionStatuses } from '@constants'; +import { services } from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice.constants'; +import type { + GetActionHostGroupDynamicActionsActionPayload, + OpenDynamicActionActionPayload, + RunActionHostGroupDynamicActionActionPayload, +} from '../entityActionHostGroups/actionHostGroups.types'; +import { Statusable } from '@uikit'; + +const loadDynamicActions = createAsyncThunk( + 'adcm/dynamicActions/loadDynamicActions', + async ({ entityType, entityArgs, actionHostGroupIds }: GetActionHostGroupDynamicActionsActionPayload, thunkAPI) => { + try { + const service = services[entityType]; + const actionsPromises = await Promise.allSettled( + actionHostGroupIds.map(async (actionHostGroupId) => ({ + actionHostGroupId, + dynamicActions: await service.getActionHostGroupActions({ ...entityArgs, actionHostGroupId }), + })), + ); + + const dynamicActionsResponses = fulfilledFilter(actionsPromises); + + if (dynamicActionsResponses.length === 0 && actionHostGroupIds.length > 0) { + throw new Error('All action host groups can not get those actions'); + } + + if (dynamicActionsResponses.length < actionHostGroupIds.length) { + throw new Error('Some action host groups can not get those actions'); + } + + const r = dynamicActionsResponses.reduce((res, { actionHostGroupId, dynamicActions }) => { + res[actionHostGroupId] = dynamicActions; + + return res; + }, {} as EntitiesDynamicActions); + return r; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + thunkAPI.dispatch(showError({ message: error.message })); + return thunkAPI.rejectWithValue([]); + } + }, +); + +const openDynamicActionDialog = createAsyncThunk( + 'adcm/dynamicActions/openDynamicActionDialog', + async ({ entityType, entityArgs, actionHostGroupId, actionId }: OpenDynamicActionActionPayload, thunkAPI) => { + try { + const service = services[entityType]; + const actionDetails = await service.getActionHostGroupAction({ ...entityArgs, actionHostGroupId, actionId }); + + return actionDetails; + } catch (error) { + thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); + return thunkAPI.rejectWithValue(null); + } + }, +); + +const runDynamicAction = createAsyncThunk( + 'adcm/clustersDynamicActions/runClusterDynamicAction', + async ( + { + entityType, + entityArgs, + actionHostGroupId, + actionId, + actionRunConfig, + }: RunActionHostGroupDynamicActionActionPayload, + thunkAPI, + ) => { + try { + const service = services[entityType]; + await service.postActionHostGroupAction({ ...entityArgs, actionHostGroupId, actionId, actionRunConfig }); + + thunkAPI.dispatch(showSuccess({ message: ActionStatuses.SuccessRun })); + + return null; + } catch (error) { + thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); + return thunkAPI.rejectWithValue(null); + } + }, +); + +type AdcmDynamicActionsState = { + actionHostGroupId: number | null; + actionDetails: AdcmDynamicActionDetails | null; + dynamicActions: EntitiesDynamicActions; +}; + +const createInitialState = (): AdcmDynamicActionsState => ({ + actionHostGroupId: null, + actionDetails: null, + dynamicActions: {}, +}); + +const dynamicActionsSlice = createSlice({ + name: 'adcm/dynamicActions', + initialState: createInitialState(), + reducers: { + cleanupDynamicActions() { + return createInitialState(); + }, + closeDynamicActionDialog(state) { + state.actionDetails = null; + state.actionHostGroupId = null; + }, + }, + extraReducers: (builder) => { + builder.addCase(loadDynamicActions.fulfilled, (state, action) => { + state.dynamicActions = action.payload; + }); + builder.addCase(loadDynamicActions.rejected, (state) => { + state.dynamicActions = []; + }); + builder.addCase(openDynamicActionDialog.fulfilled, (state, action) => { + state.actionDetails = action.payload; + state.actionHostGroupId = action.meta.arg.actionHostGroupId; + }); + builder.addCase(openDynamicActionDialog.rejected, (state) => { + state.actionDetails = null; + state.actionHostGroupId = null; + }); + builder.addCase(runDynamicAction.pending, (state) => { + state.actionDetails = null; + state.actionHostGroupId = null; + }); + }, +}); + +export const { cleanupDynamicActions, closeDynamicActionDialog } = dynamicActionsSlice.actions; +export { loadDynamicActions, openDynamicActionDialog, runDynamicAction }; + +export default dynamicActionsSlice.reducer; diff --git a/adcm-web/app/src/store/store.ts b/adcm-web/app/src/store/store.ts index 4b67d20d25..36af22215a 100644 --- a/adcm-web/app/src/store/store.ts +++ b/adcm-web/app/src/store/store.ts @@ -4,7 +4,11 @@ import authSlice from '@store/authSlice'; import notificationsSlice from '@store/notificationsSlice'; import clustersSlice from '@store/adcm/clusters/clustersSlice'; import clustersDynamicActionsSlice from '@store/adcm/clusters/clustersDynamicActionsSlice'; -import clusterActionHostGroupsSlice from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; +import actionHostGroupsSlice from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice'; +import actionHostGroupsTableSlice from '@store/adcm/entityActionHostGroups/actionHostGroupsTableSlice'; +import actionHostGroupsActionsSlice from '@store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice'; +import dynamicActionsSlice from '@store/adcm/entityDynamicActions/dynamicActionsSlice'; +import dynamicActionsMappingSlice from '@store/adcm/entityDynamicActions/dynamicActionsMappingSlice'; import clusterUpgradesSlice from '@store/adcm/clusters/clusterUpgradesSlice'; import clusterHostsSlice from '@store/adcm/cluster/hosts/hostsSlice'; import clusterHostsTableSlice from '@store/adcm/cluster/hosts/hostsTableSlice'; @@ -115,7 +119,9 @@ const rootReducer = combineReducers({ clusterHost: clusterHostSlice, clusterHostsActions: clusterHostsActionsSlice, clusterHostsDynamicActions: clusterHostsDynamicActionsSlice, - clusterActionHostGroups: clusterActionHostGroupsSlice, + actionHostGroups: actionHostGroupsSlice, + actionHostGroupsTable: actionHostGroupsTableSlice, + actionHostGroupsActions: actionHostGroupsActionsSlice, hostComponentsDynamicActions: hostComponentsDynamicActionsSlice, clusterHostsTable: clusterHostsTableSlice, clusterMapping: clusterMappingSlice, @@ -198,6 +204,8 @@ const rootReducer = combineReducers({ adcmSettingsDynamicActions: adcmSettingsDynamicActionsSlice, entityConfigurationCompare: adcmEntityConfigurationCompareSlice, entityConfiguration: adcmEntityConfigurationSlice, + dynamicActions: dynamicActionsSlice, + dynamicActionsMapping: dynamicActionsMappingSlice, }), }); From cd3cb5bddba34b0b0294057257cb8e6a8315dbfd Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Tue, 13 Aug 2024 15:22:17 +0300 Subject: [PATCH 22/50] feature/ADCM-5784 show alerts when hosts cann't mapped to host group --- .../actionHostGroupsActionsSlice.ts | 42 ++++++++++++++++++- .../dynamicActionsSlice.ts | 2 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts index bf46c5ac24..c017f9758b 100644 --- a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts @@ -18,6 +18,7 @@ import type { } from './actionHostGroups.types'; import type { AdcmActionHostGroup, AdcmActionHostGroupHost } from '@models/adcm'; import { splitHosts } from './actionHostGroupsActionsSlice.utils'; +import { fulfilledFilter } from '@utils/promiseUtils'; const openCreateDialog = createAsyncThunk( 'adcm/actionHostsGroupsActions/openCreateDialog', @@ -58,7 +59,25 @@ const createActionHostGroup = createAsyncThunk( ); } - return host; + const totalRequestsCount = hostIds.size; + const allPromises = await Promise.allSettled(promises); + + const fullfilledPromises = fulfilledFilter(allPromises); + if (fullfilledPromises.length === 0 && totalRequestsCount > 0) { + // throw exception because full crash + throw new Error('All hosts can not mapped on this group'); + } + + if (fullfilledPromises.length < totalRequestsCount) { + thunkAPI.dispatch(showInfo({ message: 'Some hosts were successfully mapped on this group' })); + thunkAPI.dispatch(showError({ message: 'Some hosts can not mapped on this group' })); + + // return false because process done with partly success + return false; + } + + thunkAPI.dispatch(showSuccess({ message: 'All hosts were successfully mapped on this group' })); + return true; } catch (error) { thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); return thunkAPI.rejectWithValue(error); @@ -132,6 +151,27 @@ const updateActionHostGroup = createAsyncThunk( }), ); } + + const allPromises = await Promise.allSettled([...addPromises, ...deletePromises]); + + const totalRequestsCount = toAdd.size + toDelete.size; + + const fullfilledPromises = fulfilledFilter(allPromises); + if (fullfilledPromises.length === 0 && totalRequestsCount > 0) { + // throw exception because full crash + throw new Error('All hosts can not mapped on this group'); + } + + if (fullfilledPromises.length < totalRequestsCount) { + thunkAPI.dispatch(showInfo({ message: 'Some hosts were successfully mapped on this group' })); + thunkAPI.dispatch(showError({ message: 'Some hosts can not mapped on this group' })); + + // return false because process done with partly success + return false; + } + + thunkAPI.dispatch(showSuccess({ message: 'All hosts were successfully mapped on this group' })); + return true; } catch (error) { thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); return thunkAPI.rejectWithValue(error); diff --git a/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts index 96417ef29a..fc37a9c387 100644 --- a/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts +++ b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts @@ -12,7 +12,6 @@ import type { OpenDynamicActionActionPayload, RunActionHostGroupDynamicActionActionPayload, } from '../entityActionHostGroups/actionHostGroups.types'; -import { Statusable } from '@uikit'; const loadDynamicActions = createAsyncThunk( 'adcm/dynamicActions/loadDynamicActions', @@ -124,6 +123,7 @@ const dynamicActionsSlice = createSlice({ state.dynamicActions = []; }); builder.addCase(openDynamicActionDialog.fulfilled, (state, action) => { + // @ts-expect-error 'ignore Type instantiation is excessively deep and possibly infinite error' state.actionDetails = action.payload; state.actionHostGroupId = action.meta.arg.actionHostGroupId; }); From 56b02d5a1b77b8f7eaafb8a6b673cebfb7e37d7e Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Tue, 13 Aug 2024 15:27:21 +0300 Subject: [PATCH 23/50] feature/ADCM-5784 fix typo --- .../actionHostGroupsActionsSlice.ts | 12 ++++++------ .../adcm/entityDynamicActions/dynamicActionsSlice.ts | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts index c017f9758b..8814f8130a 100644 --- a/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts +++ b/adcm-web/app/src/store/adcm/entityActionHostGroups/actionHostGroupsActionsSlice.ts @@ -62,13 +62,13 @@ const createActionHostGroup = createAsyncThunk( const totalRequestsCount = hostIds.size; const allPromises = await Promise.allSettled(promises); - const fullfilledPromises = fulfilledFilter(allPromises); - if (fullfilledPromises.length === 0 && totalRequestsCount > 0) { + const fulfilledPromises = fulfilledFilter(allPromises); + if (fulfilledPromises.length === 0 && totalRequestsCount > 0) { // throw exception because full crash throw new Error('All hosts can not mapped on this group'); } - if (fullfilledPromises.length < totalRequestsCount) { + if (fulfilledPromises.length < totalRequestsCount) { thunkAPI.dispatch(showInfo({ message: 'Some hosts were successfully mapped on this group' })); thunkAPI.dispatch(showError({ message: 'Some hosts can not mapped on this group' })); @@ -156,13 +156,13 @@ const updateActionHostGroup = createAsyncThunk( const totalRequestsCount = toAdd.size + toDelete.size; - const fullfilledPromises = fulfilledFilter(allPromises); - if (fullfilledPromises.length === 0 && totalRequestsCount > 0) { + const fulfilledPromises = fulfilledFilter(allPromises); + if (fulfilledPromises.length === 0 && totalRequestsCount > 0) { // throw exception because full crash throw new Error('All hosts can not mapped on this group'); } - if (fullfilledPromises.length < totalRequestsCount) { + if (fulfilledPromises.length < totalRequestsCount) { thunkAPI.dispatch(showInfo({ message: 'Some hosts were successfully mapped on this group' })); thunkAPI.dispatch(showError({ message: 'Some hosts can not mapped on this group' })); diff --git a/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts index fc37a9c387..ca52ad02d6 100644 --- a/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts +++ b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts @@ -123,7 +123,8 @@ const dynamicActionsSlice = createSlice({ state.dynamicActions = []; }); builder.addCase(openDynamicActionDialog.fulfilled, (state, action) => { - // @ts-expect-error 'ignore Type instantiation is excessively deep and possibly infinite error' + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore state.actionDetails = action.payload; state.actionHostGroupId = action.meta.arg.actionHostGroupId; }); From 39d8ef2166b3991056adc9c1ce9836b83f5aac41 Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Thu, 15 Aug 2024 10:05:40 +0000 Subject: [PATCH 24/50] ADCM-5870 [UI] Fix typos on the Action hosts group tab https://tracker.yandex.ru/ADCM-5870 --- adcm-web/app/src/App.tsx | 2 +- .../ActionHostGroupsTable.tsx | 4 ++-- .../ActionHostGroupsTableToolbar.tsx | 2 +- .../ClusterServiceNavigation.tsx | 3 ++- adcm-web/app/src/routes/routes.ts | 24 +++++++++++++++++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/adcm-web/app/src/App.tsx b/adcm-web/app/src/App.tsx index d6e7c6875b..54032ef77e 100644 --- a/adcm-web/app/src/App.tsx +++ b/adcm-web/app/src/App.tsx @@ -111,7 +111,7 @@ function App() { element={} /> } /> onOpenEditDialog(actionHostGroup)} tooltipProps={{ placement: 'bottom-start' }} /> onOpenDeleteDialog(actionHostGroup)} tooltipProps={{ placement: 'bottom-start' }} /> diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx index bc46e63a85..f82584cde0 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx @@ -15,7 +15,7 @@ const ClusterActionHostGroupsTableToolbar = ({ - + ); diff --git a/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx b/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx index 523101aebe..7d790db70c 100644 --- a/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx +++ b/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx @@ -8,6 +8,7 @@ import { useLocation } from 'react-router-dom'; const tabsNavigationDictionary: { [key: string]: string } = { 'primary-configuration': 'Primary configuration', 'configuration-groups': 'Configuration groups', + 'action-hosts-groups': 'Action hosts groups', components: 'Components', info: 'Info', }; @@ -39,7 +40,7 @@ const ClusterServiceNavigation: React.FC = () => { Primary configuration Configuration groups - Action host groups + Action hosts groups Components Info diff --git a/adcm-web/app/src/routes/routes.ts b/adcm-web/app/src/routes/routes.ts index 6dc6e71f5d..6dced1acdc 100644 --- a/adcm-web/app/src/routes/routes.ts +++ b/adcm-web/app/src/routes/routes.ts @@ -134,6 +134,30 @@ const routes: RoutesConfigs = { }, ], }, + '/clusters/:clusterId/services/:serviceId/action-hosts-groups': { + pageTitle: 'Clusters', + breadcrumbs: [ + { + href: '/clusters', + label: 'Clusters', + }, + { + href: '/clusters/:clusterId', + label: ':clusterId', + }, + { + href: '/clusters/:clusterId/services', + label: 'Services', + }, + { + href: '/clusters/:clusterId/services/:serviceId', + label: ':serviceId', + }, + { + label: 'Action hosts groups', + }, + ], + }, '/clusters/:clusterId/services/:serviceId/components': { pageTitle: 'Clusters', breadcrumbs: [ From 31fc1ee0f5cb7fc4df294e8b357216545dbd2e8c Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Fri, 16 Aug 2024 08:49:23 +0000 Subject: [PATCH 25/50] ADCM-5874 [UI] Fix two simultaneosly highlighted tabs https://tracker.yandex.ru/ADCM-5874 --- adcm-web/app/src/App.tsx | 2 +- .../ClusterServiceNavigation.tsx | 4 +-- .../ComponentActionHostGroups.tsx | 27 ++++++++++++++++ .../ComponentConfigurationsNavigation.tsx | 2 +- .../app/src/components/uikit/Tabs/Tab.tsx | 5 +-- adcm-web/app/src/routes/routes.ts | 32 +++++++++++++++++++ 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/adcm-web/app/src/App.tsx b/adcm-web/app/src/App.tsx index 54032ef77e..f0e59c06f0 100644 --- a/adcm-web/app/src/App.tsx +++ b/adcm-web/app/src/App.tsx @@ -132,7 +132,7 @@ function App() { element={} /> } /> { return ( - Primary configuration - Configuration groups + Primary configuration + Configuration groups Action hosts groups Components Info diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx index d0d16a7c7e..4284874635 100644 --- a/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx @@ -5,11 +5,38 @@ import DynamicActionDialog from '@commonComponents/DynamicActionDialog/DynamicAc import EditActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog'; import DeleteActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog'; import { useComponentActionHostGroups } from './useComponentActionHostGroups'; +import { useDispatch, useStore } from '@hooks'; +import { setBreadcrumbs } from '@store/adcm/breadcrumbs/breadcrumbsSlice'; +import { useEffect } from 'react'; const ComponentActionHostGroups = () => { const { toolbarProps, tableProps, createDialogProps, dynamicActionDialogProps, editDialogProps, deleteDialogProps } = useComponentActionHostGroups(); + const dispatch = useDispatch(); + const cluster = useStore(({ adcm }) => adcm.cluster.cluster); + const service = useStore(({ adcm }) => adcm.service.service); + const component = useStore(({ adcm }) => adcm.serviceComponent.serviceComponent); + + useEffect(() => { + if (cluster && service && component) { + dispatch( + setBreadcrumbs([ + { href: '/clusters', label: 'Clusters' }, + { href: `/clusters/${cluster.id}`, label: cluster.name }, + { href: `/clusters/${cluster.id}/services`, label: 'Services' }, + { href: `/clusters/${cluster.id}/services/${service.id}`, label: service.displayName }, + { href: `/clusters/${cluster.id}/services/${service.id}/components`, label: 'Components' }, + { + href: `/clusters/${cluster.id}/services/${service.id}/components/${component.id}`, + label: component.displayName, + }, + { label: 'Action hosts groups' }, + ]), + ); + } + }, [cluster, service, component, dispatch]); + return ( <> diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx index 4999dba9f8..880412aef6 100644 --- a/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx @@ -10,7 +10,7 @@ const ComponentConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups - Action host groups + Action hosts groups diff --git a/adcm-web/app/src/components/uikit/Tabs/Tab.tsx b/adcm-web/app/src/components/uikit/Tabs/Tab.tsx index 504765773b..8b81e8b222 100644 --- a/adcm-web/app/src/components/uikit/Tabs/Tab.tsx +++ b/adcm-web/app/src/components/uikit/Tabs/Tab.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link, NavLink, useLocation } from 'react-router-dom'; +import { Link, NavLink, resolvePath, useLocation } from 'react-router-dom'; import s from './Tabs.module.scss'; import cn from 'classnames'; import { isCurrentPathname } from '@utils/urlUtils'; @@ -7,9 +7,10 @@ import { TabProps } from '@uikit/Tabs/Tab.types'; const Tab: React.FC = ({ children, to, subPattern, disabled = false, isActive = false, onClick }) => { const { pathname } = useLocation(); + const resolvedPathname = resolvePath(to, pathname); const tabClasses = cn(s.tab, { - [s.tab_active]: (to !== '' && isCurrentPathname(pathname, to, subPattern)) || isActive, + [s.tab_active]: (to !== '' && isCurrentPathname(pathname, resolvedPathname, subPattern)) || isActive, [s.tab_disabled]: disabled, }); diff --git a/adcm-web/app/src/routes/routes.ts b/adcm-web/app/src/routes/routes.ts index 6dced1acdc..84e8d72d4a 100644 --- a/adcm-web/app/src/routes/routes.ts +++ b/adcm-web/app/src/routes/routes.ts @@ -246,6 +246,38 @@ const routes: RoutesConfigs = { }, ], }, + '/clusters/:clusterId/services/:serviceId/components/:componentId/action-hosts-groups': { + pageTitle: 'Clusters', + breadcrumbs: [ + { + href: '/clusters', + label: 'Clusters', + }, + { + href: '/clusters/:clusterId', + label: ':clusterId', + }, + { + href: '/clusters/:clusterId/services', + label: 'Services', + }, + { + href: '/clusters/:clusterId/services/:serviceId', + label: ':serviceId', + }, + { + href: '/clusters/:clusterId/services/:serviceId/components', + label: 'Components', + }, + { + href: '/clusters/:clusterId/services/:serviceId/components/:componentId', + label: ':componentId', + }, + { + label: 'Action hosts groups', + }, + ], + }, '/clusters/:clusterId/services/:serviceId/info': { pageTitle: 'Clusters', breadcrumbs: [ From 9d42912c7a9acd83aa65256bbb71e182cc36d760 Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Tue, 20 Aug 2024 15:39:47 +0000 Subject: [PATCH 26/50] ADCM-5880 [UI] Fix routes for action hosts groups on the cluster page https://tracker.yandex.ru/ADCM-5880 https://tracker.yandex.ru/ADCM-5884 --- adcm-web/app/src/App.tsx | 2 +- ...clusterServiceComponentActionHostGroups.ts | 2 +- .../ClusterActionHostGroups.tsx | 20 +++++++++++++++++++ .../ClusterConfigurationsNavigation.tsx | 2 +- adcm-web/app/src/routes/routes.ts | 2 +- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/adcm-web/app/src/App.tsx b/adcm-web/app/src/App.tsx index f0e59c06f0..0a43dde5f0 100644 --- a/adcm-web/app/src/App.tsx +++ b/adcm-web/app/src/App.tsx @@ -173,7 +173,7 @@ function App() { element={} /> } /> diff --git a/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts index 1a06d74ffc..d482ab939c 100644 --- a/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts +++ b/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts @@ -113,7 +113,7 @@ export class AdcmClusterServiceComponentActionHostGroupsApi { const query = qs.stringify(queryParams); const response = await httpClient.get( - `/api/v2/clusters/${clusterId}/services/${serviceId}/component/${componentId}/action-host-groups/${actionHostGroupId}/host-candidates/?${query}`, + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/host-candidates/?${query}`, ); return response.data; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx index e3e623849c..de4c07cd78 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx @@ -5,11 +5,31 @@ import DynamicActionDialog from '@commonComponents/DynamicActionDialog/DynamicAc import EditActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog'; import DeleteActionHostGroupDialog from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog'; import { useClusterActionHostGroups } from './useClusterActionHostGroups'; +import { useDispatch, useStore } from '@hooks'; +import { setBreadcrumbs } from '@store/adcm/breadcrumbs/breadcrumbsSlice'; +import { useEffect } from 'react'; const ClusterActionHostGroups = () => { const { toolbarProps, tableProps, createDialogProps, dynamicActionDialogProps, editDialogProps, deleteDialogProps } = useClusterActionHostGroups(); + const cluster = useStore(({ adcm }) => adcm.cluster.cluster); + + const dispatch = useDispatch(); + + useEffect(() => { + if (cluster) { + dispatch( + setBreadcrumbs([ + { href: '/clusters', label: 'Clusters' }, + { href: `/clusters/${cluster.id}`, label: cluster.name }, + { href: `/clusters/${cluster.id}/configuration`, label: 'Configuration' }, + { label: 'Action hosts groups' }, + ]), + ); + } + }, [cluster, dispatch]); + return ( <> diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx index 9f3f6e3945..e23e42e3b8 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx @@ -10,7 +10,7 @@ const ClusterConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups Ansible settings - Action hosts groups + Action hosts groups diff --git a/adcm-web/app/src/routes/routes.ts b/adcm-web/app/src/routes/routes.ts index 84e8d72d4a..a3f8705172 100644 --- a/adcm-web/app/src/routes/routes.ts +++ b/adcm-web/app/src/routes/routes.ts @@ -450,7 +450,7 @@ const routes: RoutesConfigs = { }, ], }, - '/clusters/:clusterId/configuration/action-host-groups': { + '/clusters/:clusterId/configuration/action-hosts-groups': { pageTitle: 'Clusters', breadcrumbs: [ { From 70b66a97fbad04b1220454c24d968c3fb74c596c Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Tue, 20 Aug 2024 15:41:43 +0000 Subject: [PATCH 27/50] ADCM-5872 [UI] Add the pagination component to the action hosts groups table https://tracker.yandex.ru/ADCM-5872 --- .../useActionHostGroupDialogForm.ts | 9 +- .../CreateActionHostGroupDialog.tsx | 9 +- .../ActionHostGroupFooter.tsx | 19 ++++ .../ActionHostGroupsTable.tsx | 90 ++++++++++--------- .../ActionHostGroupsTableExpandedContent.tsx | 4 +- .../ActionHostGroupsTableFilters.tsx | 10 +-- .../ActionHostGroupsTableToolbar.tsx | 15 ++-- .../ActionHostGroups/useActionHostGroups.ts | 10 +-- 8 files changed, 96 insertions(+), 70 deletions(-) create mode 100644 adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupTableFooter/ActionHostGroupFooter.tsx diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm.ts b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm.ts index 8f45b0560a..73d275813b 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm.ts +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useForm } from '@hooks'; import { AdcmActionHostGroup } from '@models/adcm'; import { required } from '@utils/validationsUtils'; @@ -24,7 +24,7 @@ export const useActionHostGroupDialogForm = (actionHostGroup?: AdcmActionHostGro : emptyFormData; }, [actionHostGroup]); - const { formData, handleChangeFormData, errors, setErrors, isValid } = + const { setFormData, formData, handleChangeFormData, errors, setErrors, isValid } = useForm(initialFormData); useEffect(() => { @@ -33,10 +33,15 @@ export const useActionHostGroupDialogForm = (actionHostGroup?: AdcmActionHostGro }); }, [formData, setErrors]); + const resetFormData = useCallback(() => { + setFormData(initialFormData); + }, [initialFormData, setFormData]); + return { formData, isValid, errors, onChangeFormData: handleChangeFormData, + resetFormData, }; }; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx index f89a32a214..841e89670d 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx @@ -6,6 +6,7 @@ import { } from '../ActionHostGroupDialogForm/useActionHostGroupDialogForm'; import s from '../ActionHostGroupDialogForm/ActionHostGroupDialogForm.module.scss'; import { AdcmActionHostGroupHost } from '@models/adcm'; +import { useEffect } from 'react'; export interface CreateActionHostGroupDialogProps { isOpen: boolean; @@ -20,7 +21,13 @@ const CreateActionHostGroupDialog = ({ onCreate, onClose, }: CreateActionHostGroupDialogProps) => { - const { isValid, formData, onChangeFormData, errors } = useActionHostGroupDialogForm(); + const { isValid, formData, errors, onChangeFormData, resetFormData } = useActionHostGroupDialogForm(); + + useEffect(() => { + if (!isOpen) { + resetFormData(); + } + }, [isOpen, resetFormData]); const handleAction = () => { onCreate(formData); diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupTableFooter/ActionHostGroupFooter.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupTableFooter/ActionHostGroupFooter.tsx new file mode 100644 index 0000000000..099be8f980 --- /dev/null +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupTableFooter/ActionHostGroupFooter.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useDispatch, useStore } from '@hooks'; +import { Pagination, PaginationData } from '@uikit'; +import { setPaginationParams } from '@store/adcm/entityActionHostGroups/actionHostGroupsTableSlice'; + +const ActionHostGroupTableFooter: React.FC = () => { + const dispatch = useDispatch(); + + const totalCount = useStore((s) => s.adcm.actionHostGroups.totalCount); + const paginationParams = useStore((s) => s.adcm.actionHostGroupsTable.paginationParams); + + const handlePaginationChange = (params: PaginationData) => { + dispatch(setPaginationParams(params)); + }; + + return ; +}; + +export default ActionHostGroupTableFooter; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx index f3721cce07..91e464f964 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx @@ -6,8 +6,9 @@ import type { EntitiesDynamicActions } from '@models/adcm'; import type { AdcmActionHostGroup } from '@models/adcm/actionHostGroup'; import { useState } from 'react'; import ExpandDetailsCell from '@commonComponents/ExpandDetailsCell/ExpandDetailsCell'; +import ActionHostGroupTableFooter from '../ActionHostGroupTableFooter/ActionHostGroupFooter'; -export interface ClusterActionHostGroupsTableProps { +export interface ActionHostGroupsTableProps { actionHostGroups: AdcmActionHostGroup[]; dynamicActions: EntitiesDynamicActions; isLoading: boolean; @@ -16,14 +17,14 @@ export interface ClusterActionHostGroupsTableProps { onOpenDeleteDialog: (actionHostGroup: AdcmActionHostGroup) => void; } -const ClusterActionHostGroupsTable = ({ +const ActionHostGroupsTable = ({ actionHostGroups, dynamicActions, isLoading, onOpenDynamicActionDialog, onOpenEditDialog, onOpenDeleteDialog, -}: ClusterActionHostGroupsTableProps) => { +}: ActionHostGroupsTableProps) => { const [expandableRows, setExpandableRows] = useState>({}); const handleExpandClick = (id: number) => { @@ -34,46 +35,49 @@ const ClusterActionHostGroupsTable = ({ }; return ( - - {actionHostGroups.map((actionHostGroup: AdcmActionHostGroup) => { - return ( - } - > - {actionHostGroup.name} - {actionHostGroup.description} - handleExpandClick(actionHostGroup.id)}> - {actionHostGroup.hosts.length} - - - onOpenDynamicActionDialog(actionHostGroup, actionId)} - /> - onOpenEditDialog(actionHostGroup)} - tooltipProps={{ placement: 'bottom-start' }} - /> - onOpenDeleteDialog(actionHostGroup)} - tooltipProps={{ placement: 'bottom-start' }} - /> - - - ); - })} -
+ <> + + {actionHostGroups.map((actionHostGroup: AdcmActionHostGroup) => { + return ( + } + > + {actionHostGroup.name} + {actionHostGroup.description} + handleExpandClick(actionHostGroup.id)}> + {actionHostGroup.hosts.length} + + + onOpenDynamicActionDialog(actionHostGroup, actionId)} + /> + onOpenEditDialog(actionHostGroup)} + tooltipProps={{ placement: 'bottom-start' }} + /> + onOpenDeleteDialog(actionHostGroup)} + tooltipProps={{ placement: 'bottom-start' }} + /> + + + ); + })} +
+ + ); }; -export default ClusterActionHostGroupsTable; +export default ActionHostGroupsTable; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.tsx index c3d6eb6869..60e1efa1e2 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTableExpandedContent.tsx @@ -7,7 +7,7 @@ export interface ServiceComponentsTableExpandedContentProps { children: AdcmActionHostGroupHost[]; } -const ClusterActionHostGroupsTableExpandedContent = ({ children }: ServiceComponentsTableExpandedContentProps) => { +const ActionHostGroupsTableExpandedContent = ({ children }: ServiceComponentsTableExpandedContentProps) => { const [textEntered, setTextEntered] = useState(''); const childrenFiltered = useMemo(() => { @@ -34,4 +34,4 @@ const ClusterActionHostGroupsTableExpandedContent = ({ children }: ServiceCompon ); }; -export default ClusterActionHostGroupsTableExpandedContent; +export default ActionHostGroupsTableExpandedContent; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters.tsx index a3dbf53754..248bce0481 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters.tsx @@ -3,17 +3,13 @@ import { Button, SearchInput } from '@uikit'; import TableFilters from '@commonComponents/Table/TableFilters/TableFilters'; import { AdcmActionHostGroupsFilter } from '@models/adcm'; -export interface ClusterActionHostGroupsTableFiltersProps { +export interface ActionHostGroupsTableFiltersProps { filter: AdcmActionHostGroupsFilter; onFilterChange: (changes: Partial) => void; onFilterReset: () => void; } -const ClusterActionHostGroupsTableFilters = ({ - filter, - onFilterChange, - onFilterReset, -}: ClusterActionHostGroupsTableFiltersProps) => { +const ActionHostGroupsTableFilters = ({ filter, onFilterChange, onFilterReset }: ActionHostGroupsTableFiltersProps) => { const handleGroupNameChange = (event: React.ChangeEvent) => { onFilterChange({ name: event.target.value }); }; @@ -41,4 +37,4 @@ const ClusterActionHostGroupsTableFilters = ({ ); }; -export default ClusterActionHostGroupsTableFilters; +export default ActionHostGroupsTableFilters; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx index f82584cde0..27fc63cb12 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar.tsx @@ -1,23 +1,18 @@ import { Button, ButtonGroup } from '@uikit'; import TableToolbar from '@commonComponents/Table/TableToolbar/TableToolbar'; -import ClusterActionHostGroupsTableFilters, { - ClusterActionHostGroupsTableFiltersProps, -} from './ActionHostGroupsTableFilters'; +import ActionHostGroupsTableFilters, { ActionHostGroupsTableFiltersProps } from './ActionHostGroupsTableFilters'; -export interface ClusterActionHostGroupsTableToolbarProps extends ClusterActionHostGroupsTableFiltersProps { +export interface ActionHostGroupsTableToolbarProps extends ActionHostGroupsTableFiltersProps { onOpenCreateDialog: () => void; } -const ClusterActionHostGroupsTableToolbar = ({ - onOpenCreateDialog, - ...filterProps -}: ClusterActionHostGroupsTableToolbarProps) => ( +const ActionHostGroupsTableToolbar = ({ onOpenCreateDialog, ...filterProps }: ActionHostGroupsTableToolbarProps) => ( - + ); -export default ClusterActionHostGroupsTableToolbar; +export default ActionHostGroupsTableToolbar; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts b/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts index eed41b0976..8fd0e8c5b2 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts +++ b/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts @@ -17,9 +17,9 @@ import { } from '@store/adcm/entityDynamicActions/dynamicActionsSlice'; import { setFilter, resetFilter } from '@store/adcm/entityActionHostGroups/actionHostGroupsTableSlice'; import type { AdcmActionHostGroupFormData } from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/useActionHostGroupDialogForm'; -import type { ClusterActionHostGroupsTableToolbarProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar'; -import type { ClusterActionHostGroupsTableFiltersProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters'; -import type { ClusterActionHostGroupsTableProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable'; +import type { ActionHostGroupsTableToolbarProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableToolbar'; +import type { ActionHostGroupsTableFiltersProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTableToolbar/ActionHostGroupsTableFilters'; +import type { ActionHostGroupsTableProps } from '@commonComponents/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable'; import type { CreateActionHostGroupDialogProps } from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog'; import type { DynamicActionDialogProps } from '@commonComponents/DynamicActionDialog/DynamicActionDialog'; import type { EditActionHostGroupDialogProps } from './ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog'; @@ -31,8 +31,8 @@ import { isShowSpinner } from '@uikit/Table/Table.utils'; type UseActionHostGroupsResult = { entityArgs: EntityArgs; - toolbarProps: ClusterActionHostGroupsTableToolbarProps & ClusterActionHostGroupsTableFiltersProps; - tableProps: ClusterActionHostGroupsTableProps; + toolbarProps: ActionHostGroupsTableToolbarProps & ActionHostGroupsTableFiltersProps; + tableProps: ActionHostGroupsTableProps; createDialogProps: CreateActionHostGroupDialogProps; dynamicActionDialogProps?: DynamicActionDialogProps; editDialogProps?: EditActionHostGroupDialogProps; From f086ce7d89136dc41897353de6f4221be3fbb612 Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Tue, 20 Aug 2024 15:42:59 +0000 Subject: [PATCH 28/50] ADCM--5876 [UI] Fix the error message https://tracker.yandex.ru/ADCM-5875 https://tracker.yandex.ru/ADCM-5876 --- adcm-web/app/src/utils/httpResponseUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adcm-web/app/src/utils/httpResponseUtils.ts b/adcm-web/app/src/utils/httpResponseUtils.ts index 7e37eeed34..defd241f0b 100644 --- a/adcm-web/app/src/utils/httpResponseUtils.ts +++ b/adcm-web/app/src/utils/httpResponseUtils.ts @@ -10,5 +10,5 @@ export interface ResponseErrorData { export const getErrorMessage = (requestError: RequestError) => { const data = (requestError.response?.data ?? {}) as ResponseErrorData; - return data.desc ?? data.detail ?? 'Something wrong'; + return data.desc ?? data.detail ?? requestError.message ?? 'Something wrong'; }; From 1af745cee0671dfe04c023bfabb72f58486609cc Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Wed, 21 Aug 2024 07:41:53 +0000 Subject: [PATCH 29/50] ADCM-3899: Upgrade improvement and bug fix --- python/api_v2/tests/test_upgrade.py | 135 ++++++++++++------ .../test_policy/test_cluster_admin_role.py | 9 ++ .../test_policy/test_provider_admin_role.py | 3 + python/rbac/upgrade/role_spec.yaml | 19 +++ 4 files changed, 123 insertions(+), 43 deletions(-) diff --git a/python/api_v2/tests/test_upgrade.py b/python/api_v2/tests/test_upgrade.py index 61941c942d..e9a175b693 100644 --- a/python/api_v2/tests/test_upgrade.py +++ b/python/api_v2/tests/test_upgrade.py @@ -24,10 +24,10 @@ from cm.tests.mocks.task_runner import RunTaskMock from init_db import init from rbac.upgrade.role import init_roles -from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND from rest_framework.test import APITestCase +from api_v2.prototype.utils import accept_license from api_v2.tests.base import BaseAPITestCase @@ -77,13 +77,13 @@ def setUp(self) -> None: self.unauthorized_client.login(username="test_user_username", password="test_user_password") def test_cluster_list_upgrades_success(self): - response: Response = self.client.v2[self.cluster_1, "upgrades"].get() + response = self.client.v2[self.cluster_1, "upgrades"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 6) def test_upgrade_visibility_from_edition_any_success(self): - response: Response = self.client.v2[self.cluster_2, "upgrades"].get() + response = self.client.v2[self.cluster_2, "upgrades"].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -91,7 +91,7 @@ def test_upgrade_visibility_from_edition_any_success(self): self.assertListEqual(response_upgrades, ["Upgrade 99.0"]) def test_cluster_upgrade_retrieve_success(self): - response: Response = self.client.v2[self.cluster_1, "upgrades", self.cluster_upgrade].get() + response = self.client.v2[self.cluster_1, "upgrades", self.cluster_upgrade].get() self.assertEqual(response.status_code, HTTP_200_OK) upgrade_data = response.json() @@ -140,7 +140,7 @@ def test_cluster_upgrade_retrieve_success(self): ) def test_cluster_upgrade_retrieve_complex_success(self): - response: Response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex].get() + response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex].get() self.assertEqual(response.status_code, HTTP_200_OK) upgrade_data = response.json() @@ -163,12 +163,17 @@ def test_cluster_upgrade_retrieve_complex_success(self): ) def test_cluster_upgrade_run_success(self): - Prototype.objects.update(license="accepted") + accept_license( + prototype=Prototype.objects.filter( + bundle=self.upgrade_cluster_via_action_simple.bundle, + type=ObjectType.SERVICE, + name="service_1", + version=self.upgrade_cluster_via_action_simple.bundle.version, + ).first() + ) with RunTaskMock() as run_task: - response: Response = self.client.v2[ - self.cluster_1, "upgrades", self.upgrade_cluster_via_action_simple, "run" - ].post() + response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_simple, "run"].post() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -184,7 +189,14 @@ def test_cluster_upgrade_run_success(self): ) def test_cluster_upgrade_run_complex_success(self): - Prototype.objects.update(license="accepted") + accept_license( + prototype=Prototype.objects.filter( + bundle=self.upgrade_cluster_via_action_simple.bundle, + type=ObjectType.SERVICE, + name="service_1", + version=self.upgrade_cluster_via_action_simple.bundle.version, + ).first() + ) host = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="one_host") self.add_host_to_cluster(cluster=self.cluster_1, host=host) @@ -193,9 +205,7 @@ def test_cluster_upgrade_run_complex_success(self): HostComponent.objects.create(cluster=self.cluster_1, service=self.service_1, component=component_2, host=host) with RunTaskMock() as run_task: - response: Response = self.client.v2[ - self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" - ].post( + response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run"].post( data={ "hostComponentMap": [{"hostId": host.pk, "componentId": component_1.pk}], "configuration": { @@ -220,7 +230,14 @@ def test_cluster_upgrade_run_complex_success(self): ) def test_adcm_5246_cluster_upgrade_other_constraints_run_success(self): - Prototype.objects.update(license="accepted") + accept_license( + prototype=Prototype.objects.filter( + bundle=self.upgrade_cluster_via_action_simple.bundle, + type=ObjectType.SERVICE, + name="service_1", + version=self.upgrade_cluster_via_action_simple.bundle.version, + ).first() + ) host_1 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="first_host") host_2 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="second_host") @@ -233,9 +250,7 @@ def test_adcm_5246_cluster_upgrade_other_constraints_run_success(self): HostComponent.objects.create(cluster=self.cluster_1, service=self.service_1, component=component_1, host=host_2) with RunTaskMock() as run_task: - response: Response = self.client.v2[ - self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" - ].post( + response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run"].post( data={ "hostComponentMap": [ {"hostId": host_1.pk, "componentId": component_1.pk}, @@ -267,9 +282,7 @@ def test_adcm_4856_cluster_upgrade_run_complex_no_component_fail(self): self.add_host_to_cluster(cluster=self.cluster_1, host=host) with RunTaskMock() as run_task: - response: Response = self.client.v2[ - self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" - ].post( + response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run"].post( data={ "hostComponentMap": [{"hostId": host.pk, "componentId": 1000}], "configuration": { @@ -288,9 +301,7 @@ def test_adcm_4856_cluster_upgrade_run_complex_no_host_fail(self): component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") with RunTaskMock() as run_task: - response: Response = self.client.v2[ - self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" - ].post( + response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run"].post( data={ "hostComponentMap": [{"hostId": 1000, "componentId": component_1.pk}], "configuration": { @@ -307,7 +318,14 @@ def test_adcm_4856_cluster_upgrade_run_complex_no_host_fail(self): def test_adcm_4856_cluster_upgrade_run_complex_duplicated_hc_success(self): # fixme was incorrect test, probably should fix a validation? see action run - Prototype.objects.update(license="accepted") + accept_license( + prototype=Prototype.objects.filter( + bundle=self.upgrade_cluster_via_action_simple.bundle, + type=ObjectType.SERVICE, + name="service_1", + version=self.upgrade_cluster_via_action_simple.bundle.version, + ).first() + ) host = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="one_host") self.add_host_to_cluster(cluster=self.cluster_1, host=host) @@ -315,9 +333,7 @@ def test_adcm_4856_cluster_upgrade_run_complex_duplicated_hc_success(self): component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") with RunTaskMock() as run_task: - response: Response = self.client.v2[ - self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" - ].post( + response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run"].post( data={ "hostComponentMap": [ {"hostId": host.pk, "componentId": component_1.pk}, @@ -342,7 +358,14 @@ def test_adcm_4856_cluster_upgrade_run_complex_duplicated_hc_success(self): ) def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): - Prototype.objects.update(license="accepted") + accept_license( + prototype=Prototype.objects.filter( + bundle=self.upgrade_cluster_via_action_simple.bundle, + type=ObjectType.SERVICE, + name="service_1", + version=self.upgrade_cluster_via_action_simple.bundle.version, + ).first() + ) host_1 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="one_host") self.add_host_to_cluster(cluster=self.cluster_1, host=host_1) @@ -353,9 +376,7 @@ def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") with RunTaskMock() as run_task: - response: Response = self.client.v2[ - self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" - ].post( + response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run"].post( data={ "hostComponentMap": [ {"hostId": host_1.pk, "componentId": component_1.pk}, @@ -380,13 +401,13 @@ def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): ) def test_provider_list_upgrades_success(self): - response: Response = self.client.v2[self.provider, "upgrades"].get() + response = self.client.v2[self.provider, "upgrades"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 3) def test_provider_upgrade_retrieve_success(self): - response: Response = self.client.v2[self.provider, "upgrades", self.provider_upgrade].get() + response = self.client.v2[self.provider, "upgrades", self.provider_upgrade].get() self.assertEqual(response.status_code, HTTP_200_OK) upgrade_data = response.json() self.assertTrue( @@ -401,7 +422,7 @@ def test_provider_upgrade_retrieve_success(self): self.assertFalse(upgrade_data["isAllowToTerminate"]) def test_provider_upgrade_retrieve_complex_success(self): - response: Response = self.client.v2[self.provider, "upgrades", self.upgrade_host_via_action_complex].get() + response = self.client.v2[self.provider, "upgrades", self.upgrade_host_via_action_complex].get() self.assertEqual(response.status_code, HTTP_200_OK) upgrade_data = response.json() @@ -433,12 +454,12 @@ def test_provider_upgrade_run_success(self): self.assertEqual(self.provider.prototype.version, self.upgrade_host_via_action_simple.action.prototype.version) def test_provider_upgrade_run_violate_constraint_fail(self): - response: Response = self.client.v2[self.provider, "upgrades", self.cluster_upgrade, "run"].post() + response = self.client.v2[self.provider, "upgrades", self.cluster_upgrade, "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_cluster_upgrade_run_violate_constraint_fail(self): - response: Response = self.client.v2[self.cluster_1, "upgrades", self.provider_upgrade, "run"].post() + response = self.client.v2[self.cluster_1, "upgrades", self.provider_upgrade, "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -453,12 +474,12 @@ def test_cluster_upgrade_run_not_found_fail(self): self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_cluster_upgrade_retrieve_not_found_fail(self): - response: Response = self.client.v2[self.cluster_1, "upgrades", self.get_non_existent_pk(Upgrade)].get() + response = self.client.v2[self.cluster_1, "upgrades", self.get_non_existent_pk(Upgrade)].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_hostprovider_upgrade_retrieve_not_found_fail(self): - response: Response = self.client.v2[self.provider, "upgrades", self.get_non_existent_pk(Upgrade)].get() + response = self.client.v2[self.provider, "upgrades", self.get_non_existent_pk(Upgrade)].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -466,27 +487,27 @@ def test_cluster_upgrade_hostcomponent_validation_fail(self): endpoint = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run"] for hc_data in ([{"hostId": 1}], [{"componentId": 4}], [{}]): with self.subTest(f"Pass host_component_map as {hc_data}"): - response: Response = endpoint.post(data={"hostComponentMap": hc_data}) + response = endpoint.post(data={"hostComponentMap": hc_data}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_cluster_list_unauthorized_fail(self) -> None: - response: Response = self.unauthorized_client.v2[self.cluster_1, "upgrades"].get() + response = self.unauthorized_client.v2[self.cluster_1, "upgrades"].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_cluster_retrieve_unauthorized_fail(self): - response: Response = self.unauthorized_client.v2[self.cluster_1, "upgrades", self.cluster_upgrade].get() + response = self.unauthorized_client.v2[self.cluster_1, "upgrades", self.cluster_upgrade].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_hostprovider_list_unauthorized_fail(self) -> None: - response: Response = self.unauthorized_client.v2[self.provider, "upgrades"].get() + response = self.unauthorized_client.v2[self.provider, "upgrades"].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_hostprovider_retrieve_unauthorized_fail(self): - response: Response = self.unauthorized_client.v2[self.provider, "upgrades", self.provider_upgrade].get() + response = self.unauthorized_client.v2[self.provider, "upgrades", self.provider_upgrade].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -535,6 +556,34 @@ def test_list_upgrades_permission_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), upgrades_count) + def test_upgrade_adcm_3899_success(self): + accept_license( + prototype=Prototype.objects.filter( + bundle=self.upgrade_cluster_via_action_simple.bundle, + type=ObjectType.SERVICE, + name="service_1", + version=self.upgrade_cluster_via_action_simple.bundle.version, + ).first() + ) + self.client.login(username="test_user_username", password="test_user_password") + with self.grant_permissions(to=self.user, on=self.cluster_1, role_name="Cluster Administrator"): + with RunTaskMock() as run_task: + response = self.client.v2[ + self.cluster_1, "upgrades", self.upgrade_cluster_via_action_simple, "run" + ].post() + self.assertEqual(response.status_code, HTTP_200_OK) + + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + + response = (self.client.v2 / "jobs").get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 4) + response = (self.client.v2 / "jobs").get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 4) + class TestAdcmUpgrade(APITestCase): @classmethod diff --git a/python/rbac/tests/test_policy/test_cluster_admin_role.py b/python/rbac/tests/test_policy/test_cluster_admin_role.py index 2175335d1d..828833ff47 100644 --- a/python/rbac/tests/test_policy/test_cluster_admin_role.py +++ b/python/rbac/tests/test_policy/test_cluster_admin_role.py @@ -99,6 +99,9 @@ def test_policy_with_cluster_admin_role(self): "edit_action_host_group_cluster", "edit_action_host_group_clusterobject", "edit_action_host_group_servicecomponent", + "view_logstorage", + "view_joblog", + "view_tasklog", }, ) @@ -470,6 +473,9 @@ def test_adding_new_policy_keeps_previous_permission(self): "view_servicecomponent", "view_upgrade_of_cluster", "view_ansible_config_of_cluster", + "view_logstorage", + "view_joblog", + "view_tasklog", }, ) @@ -546,5 +552,8 @@ def test_adding_new_policy_keeps_previous_permission(self): "edit_action_host_group_cluster", "edit_action_host_group_clusterobject", "edit_action_host_group_servicecomponent", + "view_logstorage", + "view_joblog", + "view_tasklog", }, ) diff --git a/python/rbac/tests/test_policy/test_provider_admin_role.py b/python/rbac/tests/test_policy/test_provider_admin_role.py index d3decdb3a6..f800cd4428 100644 --- a/python/rbac/tests/test_policy/test_provider_admin_role.py +++ b/python/rbac/tests/test_policy/test_provider_admin_role.py @@ -61,6 +61,9 @@ def test_policy_with_provider_admin_role(self): "view_upgrade_of_hostprovider", "view_configlog", "view_objectconfig", + "view_logstorage", + "view_joblog", + "view_tasklog", }, ) diff --git a/python/rbac/upgrade/role_spec.yaml b/python/rbac/upgrade/role_spec.yaml index a7cd0b3dfd..194ba3400c 100644 --- a/python/rbac/upgrade/role_spec.yaml +++ b/python/rbac/upgrade/role_spec.yaml @@ -941,6 +941,15 @@ roles: - view - view_upgrade_of - do_upgrade_of + - name: joblog + codenames: + - view + - name: tasklog + codenames: + - view + - name: logstorage + codenames: + - view - name: Upgrade provider bundle description: Update the provider bundle @@ -961,6 +970,16 @@ roles: - view - view_upgrade_of - do_upgrade_of + - name: joblog + codenames: + - view + - name: tasklog + codenames: + - view + - name: logstorage + codenames: + - view + - name: Remove cluster description: The ability to remove cluster From ce2e064c10c918d1b38e6d94d73ba58550b60ec4 Mon Sep 17 00:00:00 2001 From: Alexey Latunov Date: Thu, 22 Aug 2024 07:53:17 +0000 Subject: [PATCH 30/50] [UI] ADCM-5855: Rework Mapping container blockers logic https://tracker.yandex.ru/ADCM-5855 --- .../DynamicActionHostMapping.tsx | 26 +++++++++-- .../DynamicActionHostMapping.utils.ts | 44 +++++++++++++++--- .../ClusterMapping/ClusterMapping.utils.ts | 46 +++++-------------- .../ComponentContainer/ComponentContainer.tsx | 39 +++++++--------- .../ComponentsMapping/Service/Service.tsx | 3 ++ 5 files changed, 90 insertions(+), 68 deletions(-) diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx index 691986e6c8..12fe208f79 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx @@ -1,14 +1,20 @@ import React, { useEffect, useMemo } from 'react'; import { useDispatch, useStore } from '@hooks'; import { Button, ButtonGroup, SearchInput, SpinnerPanel, ToolbarPanel } from '@uikit'; -import { DynamicActionCommonOptions } from '@commonComponents/DynamicActionDialog/DynamicAction.types'; +import type { DynamicActionCommonOptions } from '@commonComponents/DynamicActionDialog/DynamicAction.types'; import s from '@commonComponents/DynamicActionDialog/DynamicActionDialog.module.scss'; import { useClusterMapping } from '@pages/cluster/ClusterMapping/useClusterMapping'; import ComponentContainer from '@pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer'; import { getMappings } from '@store/adcm/clusters/clustersDynamicActionsSlice'; -import { getComponentMapActions, getDisabledMappings } from './DynamicActionHostMapping.utils'; +import { + checkComponentActionsMappingAvailability, + checkHostActionsMappingAvailability, + getComponentMapActions, + getDisabledMappings, +} from './DynamicActionHostMapping.utils'; import { Link } from 'react-router-dom'; import { LoadState } from '@models/loadState'; +import type { AdcmHostShortView, AdcmMappingComponent } from '@models/adcm'; interface DynamicActionHostMappingProps extends DynamicActionCommonOptions { submitLabel?: string; @@ -92,17 +98,29 @@ const DynamicActionHostMapping: React.FC = ({ const allowActions = getComponentMapActions(actionDetails, service, componentMapping.component); const componentMappingErrors = mappingErrors[componentMapping.component.id]; + const checkComponentMappingAvailability = (component: AdcmMappingComponent) => { + return checkComponentActionsMappingAvailability(component, allowActions); + }; + + const checkHostMappingAvailability = (host: AdcmHostShortView) => { + return checkHostActionsMappingAvailability( + host, + allowActions, + disabledMappings[componentMapping.component.id], + ); + }; + return ( ); }), diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts index 2ff4b34046..8821d3a626 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts @@ -1,11 +1,16 @@ -import type { - AdcmDynamicActionDetails, +import { + type AdcmDynamicActionDetails, AdcmHostComponentMapRuleAction, - AdcmMapping, - AdcmMappingComponent, - AdcmMappingComponentService, + type AdcmHostShortView, + type AdcmMapping, + type AdcmMappingComponent, + type AdcmMappingComponentService, + type HostId, } from '@models/adcm'; -import type { DisabledComponentsMappings } from '@pages/cluster/ClusterMapping/ClusterMapping.types'; +import type { + ComponentAvailabilityErrors, + DisabledComponentsMappings, +} from '@pages/cluster/ClusterMapping/ClusterMapping.types'; export const getComponentMapActions = ( actionDetails: AdcmDynamicActionDetails, @@ -36,3 +41,30 @@ export const getDisabledMappings = (mapping: AdcmMapping[]) => { return result; }; + +export const checkComponentActionsMappingAvailability = ( + component: AdcmMappingComponent, + allowActions: Set, +): ComponentAvailabilityErrors => { + const result: ComponentAvailabilityErrors = {}; + + result.componentNotAvailableError = + allowActions.size === 0 ? 'Mapping is not allowed in action configuration' : result.componentNotAvailableError; + result.addingHostsNotAllowedError = !allowActions.has(AdcmHostComponentMapRuleAction.Add) + ? 'Adding hosts is not allowed in the action configuration' + : undefined; + result.removingHostsNotAllowedError = !allowActions.has(AdcmHostComponentMapRuleAction.Remove) + ? 'Removing hosts is not allowed in the action configuration' + : undefined; + + return result; +}; + +export const checkHostActionsMappingAvailability = ( + host: AdcmHostShortView, + allowActions: Set, + disabledHosts: Set = new Set(), +): string | undefined => { + const isDisabled = !allowActions.has(AdcmHostComponentMapRuleAction.Remove) && disabledHosts.has(host.id); + return isDisabled ? 'Removing host is not allowed in the action configuration' : undefined; +}; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts index 82647de24f..d193536d7c 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts @@ -8,7 +8,6 @@ import { type HostId, type ServiceId, type AdcmComponentDependency, - AdcmHostComponentMapRuleAction, AdcmEntitySystemState, AdcmMaintenanceMode, } from '@models/adcm'; @@ -380,43 +379,20 @@ const isMandatoryComponent = (componentMapping: ComponentMapping) => { return !(component.constraints[0] === 0 && hosts.length === 0); }; -export const checkComponentMappingAvailability = ( - component: AdcmMappingComponent, - allowActions?: Set, -): ComponentAvailabilityErrors => { - const result: ComponentAvailabilityErrors = {}; - if (allowActions) { - result.componentNotAvailableError = - allowActions.size === 0 ? 'Mapping is not allowed in action configuration' : undefined; - result.addingHostsNotAllowedError = !allowActions.has(AdcmHostComponentMapRuleAction.Add) - ? 'Adding hosts is not allowed in the action configuration' - : undefined; - result.removingHostsNotAllowedError = !allowActions.has(AdcmHostComponentMapRuleAction.Remove) - ? 'Removing hosts is not allowed in the action configuration' - : undefined; - } else { - const isAvailable = - component.service.state === AdcmEntitySystemState.Created && - component.maintenanceMode === AdcmMaintenanceMode.Off; - - result.componentNotAvailableError = !isAvailable +export const checkComponentMappingAvailability = (component: AdcmMappingComponent): ComponentAvailabilityErrors => { + const isAvailable = + component.service.state === AdcmEntitySystemState.Created && component.maintenanceMode === AdcmMaintenanceMode.Off; + + const result: ComponentAvailabilityErrors = { + componentNotAvailableError: !isAvailable ? 'Service of this component must have "Created" state. Maintenance mode on the components must be Off' - : undefined; - } + : undefined, + }; return result; }; -export const checkHostMappingAvailability = ( - host: AdcmHostShortView, - allowActions?: Set, - disabledHosts?: Set, -): string | undefined => { - if (allowActions && disabledHosts) { - const isDisabled = !allowActions.has(AdcmHostComponentMapRuleAction.Remove) && disabledHosts.has(host.id); - return isDisabled ? 'Removing host is not allowed in the action configuration' : undefined; - } else { - const isAvailable = host.maintenanceMode === AdcmMaintenanceMode.Off; - return !isAvailable ? 'Maintenance mode on the host must be Off' : undefined; - } +export const checkHostMappingAvailability = (host: AdcmHostShortView): string | undefined => { + const isAvailable = host.maintenanceMode === AdcmMaintenanceMode.Off; + return !isAvailable ? 'Maintenance mode on the host must be Off' : undefined; }; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer.tsx index 998956938a..7c1c4d9ef1 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer.tsx @@ -4,18 +4,14 @@ import MappingItemSelect from '../../MappingItemSelect/MappingItemSelect'; import MappedHost from './MappedHost/MappedHost'; import AddMappingButton from '../../AddMappingButton/AddMappingButton'; import ComponentRestrictions from '../../HostsMapping/RestrictionsList/ComponentRestrictions'; -import { - type AdcmHostShortView, - type AdcmMappingComponent, - type HostId, - AdcmHostComponentMapRuleAction, -} from '@models/adcm'; -import type { ComponentMapping, ComponentMappingErrors, MappingFilter } from '../../ClusterMapping.types'; -import { - getConstraintsLimit, - checkComponentMappingAvailability, - checkHostMappingAvailability, -} from '../../ClusterMapping.utils'; +import { type AdcmHostShortView, type AdcmMappingComponent } from '@models/adcm'; +import type { + ComponentAvailabilityErrors, + ComponentMapping, + ComponentMappingErrors, + MappingFilter, +} from '../../ClusterMapping.types'; +import { getConstraintsLimit } from '../../ClusterMapping.utils'; import s from './ComponentContainer.module.scss'; import cn from 'classnames'; @@ -24,13 +20,13 @@ export interface ComponentContainerProps { mappingErrors?: ComponentMappingErrors; filter: MappingFilter; allHosts: AdcmHostShortView[]; - disabledHosts?: Set; onMap: (hosts: AdcmHostShortView[], component: AdcmMappingComponent) => void; onUnmap: (hostId: number, componentId: number) => void; onInstallServices?: (component: AdcmMappingComponent) => void; - allowActions?: Set; denyAddHostReason?: React.ReactNode; denyRemoveHostReason?: React.ReactNode; + checkComponentMappingAvailability: (component: AdcmMappingComponent) => ComponentAvailabilityErrors; + checkHostMappingAvailability: (host: AdcmHostShortView) => string | undefined; } const ComponentContainer = ({ @@ -38,24 +34,21 @@ const ComponentContainer = ({ mappingErrors, filter, allHosts, - disabledHosts, onUnmap, onMap, onInstallServices, - allowActions, + checkComponentMappingAvailability, + checkHostMappingAvailability, }: ComponentContainerProps) => { const [isSelectOpen, setIsSelectOpen] = useState(false); const addIconRef = useRef(null); const { component, hosts } = componentMapping; - const { componentNotAvailableError, addingHostsNotAllowedError } = checkComponentMappingAvailability( - component, - allowActions, - ); + const { componentNotAvailableError, addingHostsNotAllowedError } = checkComponentMappingAvailability(component); const hostsOptions = useMemo[]>( () => allHosts.map((host) => { - const hostMappingAvailabilityError = checkHostMappingAvailability(host, allowActions, disabledHosts); + const hostMappingAvailabilityError = checkHostMappingAvailability(host); return { label: host.name, value: host, @@ -63,7 +56,7 @@ const ComponentContainer = ({ title: hostMappingAvailabilityError, }; }), - [allHosts, allowActions, disabledHosts], + [allHosts, checkHostMappingAvailability], ); const visibleHosts = useMemo( @@ -140,7 +133,7 @@ const ComponentContainer = ({
{visibleHosts.map((host) => { - const removingHostNotAllowedError = checkHostMappingAvailability(host, allowActions, disabledHosts); + const removingHostNotAllowedError = checkHostMappingAvailability(host); return ( ); })} From 51ee8d1c841eb1fa605ea525fd1a435597612d8a Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Thu, 22 Aug 2024 10:27:40 +0000 Subject: [PATCH 31/50] ADCM-5877 [UI] Disable name and description inputs when editing the action hosts group https://tracker.yandex.ru/ADCM-5877 --- .../ActionHostGroupDialogForm.tsx | 41 ++++++++++++------- .../CreateActionHostGroupDialog.tsx | 1 + ....module.scss => TextFormField.module.scss} | 2 +- .../Forms/TextFormField/TextFormField.tsx | 6 +-- 4 files changed, 31 insertions(+), 19 deletions(-) rename adcm-web/app/src/components/common/Forms/TextFormField/{TextFromField.module.scss => TextFormField.module.scss} (57%) diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx index 1c7385052f..7144988c3c 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx @@ -6,12 +6,14 @@ import type { AdcmActionHostGroupFormData } from './useActionHostGroupDialogForm import ListTransfer from '@uikit/ListTransfer/ListTransfer'; import { availableHostsPanel, selectedHostsPanel } from './ActionHostGroupDialogForm.constants'; import s from './ActionHostGroupDialogForm.module.scss'; +import TextFormField from '@commonComponents/Forms/TextFormField/TextFormField'; export interface ActionHostGroupDialogFormProps { formData: AdcmActionHostGroupFormData; hostCandidates: AdcmActionHostGroupHost[]; errors: FormErrors; onChangeFormData: (changes: Partial) => void; + isCreateNew?: boolean; } const ActionHostGroupDialogForm = ({ @@ -19,6 +21,7 @@ const ActionHostGroupDialogForm = ({ hostCandidates, errors, onChangeFormData, + isCreateNew, }: ActionHostGroupDialogFormProps) => { const allHosts = useMemo(() => { return hostCandidates.map((host) => { @@ -45,23 +48,31 @@ const ActionHostGroupDialogForm = ({
- + {isCreateNew ? ( + + ) : ( + {formData.name} + )} - + {isCreateNew ? ( + + ) : ( + {formData.name} + )}
diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx index 841e89670d..d0e76419f1 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/CreateActionHostGroupDialog/CreateActionHostGroupDialog.tsx @@ -51,6 +51,7 @@ const CreateActionHostGroupDialog = ({ errors={errors} hostCandidates={hostCandidates} onChangeFormData={onChangeFormData} + isCreateNew={true} /> ); diff --git a/adcm-web/app/src/components/common/Forms/TextFormField/TextFromField.module.scss b/adcm-web/app/src/components/common/Forms/TextFormField/TextFormField.module.scss similarity index 57% rename from adcm-web/app/src/components/common/Forms/TextFormField/TextFromField.module.scss rename to adcm-web/app/src/components/common/Forms/TextFormField/TextFormField.module.scss index 492529b167..f9483c0340 100644 --- a/adcm-web/app/src/components/common/Forms/TextFormField/TextFromField.module.scss +++ b/adcm-web/app/src/components/common/Forms/TextFormField/TextFormField.module.scss @@ -1,3 +1,3 @@ -.textFromField { +.TextFormField { line-height: 32px; } diff --git a/adcm-web/app/src/components/common/Forms/TextFormField/TextFormField.tsx b/adcm-web/app/src/components/common/Forms/TextFormField/TextFormField.tsx index 0e4e141554..89a9c78f0c 100644 --- a/adcm-web/app/src/components/common/Forms/TextFormField/TextFormField.tsx +++ b/adcm-web/app/src/components/common/Forms/TextFormField/TextFormField.tsx @@ -1,8 +1,8 @@ import { FieldProps } from '@uikit'; -import s from './TextFromField.module.scss'; +import s from './TextFormField.module.scss'; export interface TextFormField extends FieldProps, React.PropsWithChildren {} -const TextFromField = ({ children }: TextFormField) =>
{children}
; +const TextFormField = ({ children }: TextFormField) =>
{children}
; -export default TextFromField; +export default TextFormField; From 1a0947d41281ec64b46d85b5cb8c75f3800675dd Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Fri, 23 Aug 2024 16:26:10 +0300 Subject: [PATCH 32/50] ADCM-3899 Bump role spec version --- python/rbac/upgrade/role_spec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/rbac/upgrade/role_spec.yaml b/python/rbac/upgrade/role_spec.yaml index 194ba3400c..b41a32a76c 100644 --- a/python/rbac/upgrade/role_spec.yaml +++ b/python/rbac/upgrade/role_spec.yaml @@ -1,6 +1,6 @@ --- -version: 12 +version: 13 roles: - name: Add host From a203eaf2e563e5178971d7c99229c43e52380ac0 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 26 Aug 2024 12:23:25 +0000 Subject: [PATCH 33/50] ADCM-5809 Totally remove `cm.api_context` --- python/api/tests/test_api.py | 3 -- python/api_v2/cluster/utils.py | 14 ++++--- python/api_v2/host/utils.py | 4 +- python/cm/api.py | 22 ++++++----- python/cm/api_context.py | 59 ----------------------------- python/cm/services/concern/locks.py | 18 +++++++++ 6 files changed, 41 insertions(+), 79 deletions(-) delete mode 100644 python/cm/api_context.py create mode 100644 python/cm/services/concern/locks.py diff --git a/python/api/tests/test_api.py b/python/api/tests/test_api.py index 78edfcee0f..f106613d9d 100755 --- a/python/api/tests/test_api.py +++ b/python/api/tests/test_api.py @@ -902,14 +902,12 @@ def test_save_hc(self, mock_update_issues, mock_reset_hc_map): mock_update_issues.assert_called() mock_reset_hc_map.assert_called_once() - @patch("cm.api.CTX") @patch("cm.services.status.notify.reset_hc_map") @patch("cm.api.update_hierarchy_issues") def test_save_hc__big_update__locked_hierarchy( self, mock_issue, # noqa: ARG002 mock_load, # noqa: ARG002 - ctx, ): """ Update bigger HC map - move `component_2` from `host_2` to `host_3` @@ -934,7 +932,6 @@ def test_save_hc__big_update__locked_hierarchy( tree = Tree(self.cluster) affected = (node.value for node in tree.get_all_affected(tree.built_from)) lock_affected_objects(task=task, objects=affected) - ctx.lock = task.lock # refresh due to new instances were updated in task.lock_affected() host_1.refresh_from_db() diff --git a/python/api_v2/cluster/utils.py b/python/api_v2/cluster/utils.py index 8c7cc88dea..a5672318e6 100644 --- a/python/api_v2/cluster/utils.py +++ b/python/api_v2/cluster/utils.py @@ -14,7 +14,6 @@ from itertools import chain from typing import Literal -from cm.api_context import CTX from cm.data_containers import ( ClusterData, ComponentData, @@ -44,6 +43,7 @@ Prototype, ServiceComponent, ) +from cm.services.concern.locks import get_lock_on_object from cm.services.status.notify import reset_hc_map, reset_objects_in_mm from cm.status_api import send_host_component_map_update_event from django.contrib.contenttypes.models import ContentType @@ -258,11 +258,15 @@ def _save_mapping(mapping_data: MappingData) -> QuerySet[HostComponent]: on_commit(func=reset_hc_map) on_commit(func=reset_objects_in_mm) - for removed_host in mapping_data.removed_hosts: - remove_concern_from_object(object_=removed_host, concern=CTX.lock) + cluster = mapping_data.orm_objects["cluster"] - for added_host in mapping_data.added_hosts: - add_concern_to_object(object_=added_host, concern=CTX.lock) + lock = get_lock_on_object(object_=cluster) + if lock: + for removed_host in mapping_data.removed_hosts: + remove_concern_from_object(object_=removed_host, concern=lock) + + for added_host in mapping_data.added_hosts: + add_concern_to_object(object_=added_host, concern=lock) _handle_mapping_config_groups(mapping_data=mapping_data) diff --git a/python/api_v2/host/utils.py b/python/api_v2/host/utils.py index 4622a07418..ee59b3d956 100644 --- a/python/api_v2/host/utils.py +++ b/python/api_v2/host/utils.py @@ -13,7 +13,6 @@ from adcm.permissions import check_custom_perm from cm.adcm_config.config import init_object_config from cm.api import check_license -from cm.api_context import CTX from cm.issue import ( _prototype_issue_map, add_concern_to_object, @@ -21,6 +20,7 @@ ) from cm.logger import logger from cm.models import Cluster, Host, HostProvider, ObjectType, Prototype +from cm.services.concern.locks import get_lock_on_object from cm.services.maintenance_mode import get_maintenance_mode_response from cm.services.status.notify import reset_hc_map from rbac.models import re_apply_object_policy @@ -56,7 +56,7 @@ def process_config_issues_policies_hc(host: Host) -> None: host.config = obj_conf host.save(update_fields=["config"]) - add_concern_to_object(object_=host, concern=CTX.lock) + add_concern_to_object(object_=host, concern=get_lock_on_object(host.provider)) _recheck_new_host_issues(host=host) re_apply_object_policy(apply_object=host.provider) diff --git a/python/cm/api.py b/python/cm/api.py index b5f1b08252..198df0f65f 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -31,7 +31,6 @@ save_object_config, ) from cm.adcm_config.utils import proto_ref -from cm.api_context import CTX from cm.converters import orm_object_to_core_type from cm.errors import AdcmEx, raise_adcm_ex from cm.issue import ( @@ -69,6 +68,7 @@ TaskLog, ) from cm.services.concern.flags import BuiltInFlag, raise_flag, update_hierarchy +from cm.services.concern.locks import get_lock_on_object from cm.services.status.notify import reset_hc_map, reset_objects_in_mm from cm.status_api import ( send_config_creation_event, @@ -155,7 +155,7 @@ def add_host(prototype: Prototype, provider: HostProvider, fqdn: str, descriptio obj_conf = init_object_config(prototype, host) host.config = obj_conf host.save() - add_concern_to_object(object_=host, concern=CTX.lock) + add_concern_to_object(object_=host, concern=get_lock_on_object(object_=provider)) update_hierarchy_issues(host.provider) re_apply_object_policy(provider) @@ -175,7 +175,6 @@ def add_host_provider(prototype: Prototype, name: str, description: str = ""): obj_conf = init_object_config(prototype, provider) provider.config = obj_conf provider.save() - add_concern_to_object(object_=provider, concern=CTX.lock) update_hierarchy_issues(provider) logger.info("host provider #%s %s is added", provider.pk, provider.name) @@ -286,7 +285,8 @@ def remove_host_from_cluster(host: Host) -> Host: group.hosts.remove(host) update_hierarchy_issues(obj=host) - remove_concern_from_object(object_=host, concern=CTX.lock) + # if there's no lock on cluster, nothing should be removed + remove_concern_from_object(object_=host, concern=get_lock_on_object(object_=cluster)) update_hierarchy_issues(obj=cluster) re_apply_object_policy(apply_object=cluster) @@ -535,11 +535,13 @@ def save_hc( old_hosts = {i.host for i in hc_queryset.select_related("host")} new_hosts = {i[1] for i in host_comp_list} - for removed_host in old_hosts.difference(new_hosts): - remove_concern_from_object(object_=removed_host, concern=CTX.lock) + lock = get_lock_on_object(object_=cluster) + if lock: + for removed_host in old_hosts.difference(new_hosts): + remove_concern_from_object(object_=removed_host, concern=lock) - for added_host in new_hosts.difference(old_hosts): - add_concern_to_object(object_=added_host, concern=CTX.lock) + for added_host in new_hosts.difference(old_hosts): + add_concern_to_object(object_=added_host, concern=lock) still_hc = still_existed_hc(cluster, host_comp_list) host_service_of_still_hc = {(hc.host, hc.service) for hc in still_hc} @@ -930,8 +932,8 @@ def add_host_to_cluster(cluster: Cluster, host: Host) -> Host: with atomic(): host.cluster = cluster - host.save() - add_concern_to_object(object_=host, concern=CTX.lock) + host.save(update_fields=["cluster"]) + update_hierarchy_issues(host) update_hierarchy_issues(cluster) re_apply_object_policy(cluster) diff --git a/python/cm/api_context.py b/python/cm/api_context.py deleted file mode 100644 index f3d42d9fc7..0000000000 --- a/python/cm/api_context.py +++ /dev/null @@ -1,59 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -API methods could be called from web and from ansible, -so some things should be done in different ways. Also it's painful to fetch or calc some common -things in every API method to pass it down in call stack, So it's better off to get and keep them -here during WSGI call or Ansible run - -Implemented as singleton module, just `import ctx from cm.api_context` and use `ctx.*` when needed -""" - -from pathlib import Path -import os - -from cm import models -from cm.logger import logger - - -class _Context: - """Common context for API methods calls""" - - def __init__(self): - self.task: models.TaskLog | None = None - self.job: models.JobLog | None = None - self.lock: models.ConcernItem | None = None - self.get_job_data() - - def get_job_data(self): - env = os.environ - ansible_config = env.get("ANSIBLE_CONFIG") - if not ansible_config: - return - - job_id = Path(ansible_config).parent.name - try: - self.job = models.JobLog.objects.select_related("task", "task__lock").get(id=int(job_id)) - except (ValueError, models.ObjectDoesNotExist): - return - - self.task = getattr(self.job, "task", None) - self.lock = getattr(self.task, "lock", None) - msg = f"API context was initialized with {self.job}, {self.task}, {self.lock}" - logger.debug(msg) - - -# initialized on first import -CTX = None -if not CTX: - CTX = _Context() diff --git a/python/cm/services/concern/locks.py b/python/cm/services/concern/locks.py new file mode 100644 index 0000000000..742a1aacdb --- /dev/null +++ b/python/cm/services/concern/locks.py @@ -0,0 +1,18 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from cm.models import Cluster, ClusterObject, ConcernItem, ConcernType, Host, HostProvider, ServiceComponent + + +def get_lock_on_object(object_: Cluster | ClusterObject | ServiceComponent | HostProvider | Host) -> ConcernItem | None: + return object_.concerns.filter(type=ConcernType.LOCK).first() From ac0e6b03a1df251b7829fda1ae6e97ed124af88f Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Mon, 26 Aug 2024 18:31:27 +0500 Subject: [PATCH 34/50] ADCM-5907 Don't raise flag automatically if object is in "created" state --- python/api_v2/tests/test_concerns.py | 26 +++++++++++++++++++++----- python/cm/api.py | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/python/api_v2/tests/test_concerns.py b/python/api_v2/tests/test_concerns.py index 213bfe7748..e4807ecd72 100644 --- a/python/api_v2/tests/test_concerns.py +++ b/python/api_v2/tests/test_concerns.py @@ -137,13 +137,29 @@ def test_outdated_config_flag(self): self.assertEqual(response.status_code, HTTP_201_CREATED) response: Response = self.client.v2[cluster].get() + self.assertEqual(response.status_code, HTTP_200_OK) + + with self.subTest("Absent on state = 'created'"): + data = response.json() + self.assertEqual(len(data["concerns"]), 0) + + cluster.state = "notcreated" + cluster.save(update_fields=["state"]) + + response: Response = self.client.v2[cluster, "configs"].post( + data={"config": {"string": "new_string"}, "adcmMeta": {}, "description": ""}, + ) + self.assertEqual(response.status_code, HTTP_201_CREATED) + response: Response = self.client.v2[cluster].get() self.assertEqual(response.status_code, HTTP_200_OK) - data = response.json() - self.assertEqual(len(data["concerns"]), 1) - concern, *_ = data["concerns"] - self.assertEqual(concern["type"], "flag") - self.assertDictEqual(concern["reason"], expected_concern_reason) + + with self.subTest("Present on another state"): + data = response.json() + self.assertEqual(len(data["concerns"]), 1) + concern, *_ = data["concerns"] + self.assertEqual(concern["type"], "flag") + self.assertDictEqual(concern["reason"], expected_concern_reason) def test_service_requirements(self): cluster = self.add_cluster(bundle=self.service_requirements_bundle, name="service_requirements_cluster") diff --git a/python/cm/api.py b/python/cm/api.py index 198df0f65f..0cf780ac18 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -410,7 +410,7 @@ def update_obj_config(obj_conf: ObjectConfig, config: dict, attr: dict, descript def raise_outdated_config_flag_if_required(object_: MainObject): - if not object_.prototype.flag_autogeneration.get("enable_outdated_config", False): + if object_.state == "created" or not object_.prototype.flag_autogeneration.get("enable_outdated_config", False): return flag = BuiltInFlag.ADCM_OUTDATED_CONFIG.value From 60aeab1c7f6b02c54140c3f020cb0e2a36873456 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Mon, 26 Aug 2024 13:56:52 +0000 Subject: [PATCH 35/50] ADCM-5868: Forbid bundle deletion if there are running tasks --- python/cm/bundle.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/cm/bundle.py b/python/cm/bundle.py index 1ccec4c668..fd2197796e 100644 --- a/python/cm/bundle.py +++ b/python/cm/bundle.py @@ -39,6 +39,7 @@ Cluster, ConfigLog, HostProvider, + JobStatus, ObjectType, ProductCategory, Prototype, @@ -54,6 +55,7 @@ StageSubAction, StageUpgrade, SubAction, + TaskLog, Upgrade, ) from cm.services.bundle import ADCMBundlePathResolver, BundlePathResolver, PathResolver @@ -1268,6 +1270,18 @@ def delete_bundle(bundle): f'of bundle #{bundle.id} "{bundle.name}" {bundle.version}', ) + running_task = ( + TaskLog.objects.select_related("action") + .filter(status=JobStatus.RUNNING, action__prototype__bundle=bundle) + .first() + ) + if running_task is not None: + raise AdcmEx( + code="BUNDLE_CONFLICT", + msg=f'There is running task #{running_task.id} "{running_task.action.display_name}" ' + f'of bundle #{bundle.id} "{bundle.name}" {bundle.version}', + ) + adcm = ADCM.objects.filter(prototype__bundle=bundle) if adcm: raise_adcm_ex( From 3bafa370c8604517b826ec8149873362a331abff Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Mon, 26 Aug 2024 15:22:15 +0000 Subject: [PATCH 36/50] ADCM-5866: CEF audit.log file does not have all log operations --- python/adcm/custom_loggers.py | 28 ++++++++++++++++++++++++++++ python/adcm/settings.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 python/adcm/custom_loggers.py diff --git a/python/adcm/custom_loggers.py b/python/adcm/custom_loggers.py new file mode 100644 index 0000000000..1f41b3516a --- /dev/null +++ b/python/adcm/custom_loggers.py @@ -0,0 +1,28 @@ +# 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. + +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path +from tempfile import gettempdir +import fcntl + + +class LockingTimedRotatingFileHandler(TimedRotatingFileHandler): + def emit(self, record): + lock_file_path = Path(gettempdir(), Path(self.baseFilename).name).with_suffix(".lock") + + with lock_file_path.open(mode="wt", encoding="utf-8") as lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + super().emit(record) + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/python/adcm/settings.py b/python/adcm/settings.py index 449e852f22..39fe17cf71 100644 --- a/python/adcm/settings.py +++ b/python/adcm/settings.py @@ -270,7 +270,7 @@ def get_db_options() -> dict: "backupCount": 10, }, "audit_file_handler": { - "class": "logging.handlers.TimedRotatingFileHandler", + "class": "adcm.custom_loggers.LockingTimedRotatingFileHandler", "filename": LOG_DIR / "audit.log", "when": "midnight", "backupCount": 10, From d913f2cb8398665e1237822d92e62622144b9d07 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Tue, 27 Aug 2024 11:59:52 +0000 Subject: [PATCH 37/50] ADCM-5881: Add ordering for Host pages --- python/api_v2/host/filters.py | 30 +++++- python/api_v2/tests/test_host.py | 165 +++++++++++++++++++++++-------- 2 files changed, 153 insertions(+), 42 deletions(-) diff --git a/python/api_v2/host/filters.py b/python/api_v2/host/filters.py index d91a78c6d4..57513c9d45 100644 --- a/python/api_v2/host/filters.py +++ b/python/api_v2/host/filters.py @@ -26,7 +26,21 @@ class HostFilter(FilterSet): cluster_name = CharFilter(label="Cluster name", field_name="cluster__name") is_in_cluster = BooleanFilter(label="Is host in cluster", method="filter_is_in_cluster") ordering = OrderingFilter( - fields={"fqdn": "name", "id": "id"}, field_labels={"name": "Name", "id": "Id"}, label="ordering" + fields={ + "fqdn": "name", + "id": "id", + "state": "state", + "provider__name": "hostproviderName", + "cluster__name": "clusterName", + }, + field_labels={ + "fqdn": "Name", + "id": "Id", + "state": "State", + "provider__name": "Hostprovider name", + "cluster__name": "Cluster name", + }, + label="ordering", ) class Meta: @@ -43,7 +57,19 @@ class HostClusterFilter(FilterSet): hostprovider_name = CharFilter(label="Hostprovider name", field_name="provider__name") component_id = NumberFilter(label="Component id", field_name="hostcomponent__component_id") ordering = OrderingFilter( - fields={"fqdn": "name", "id": "id"}, field_labels={"name": "Name", "id": "Id"}, label="ordering" + fields={ + "fqdn": "name", + "state": "state", + "id": "id", + "provider__name": "hostproviderName", + }, + field_labels={ + "name": "Name", + "id": "Id", + "state": "State", + "hostproviderName": "Hostprovider name", + }, + label="ordering", ) class Meta: diff --git a/python/api_v2/tests/test_host.py b/python/api_v2/tests/test_host.py index 26ce6ab552..f738184893 100644 --- a/python/api_v2/tests/test_host.py +++ b/python/api_v2/tests/test_host.py @@ -266,31 +266,79 @@ def test_hostprovider_filter(self): self.assertEqual(response.json()["count"], 1) self.assertEqual(response.json()["results"][0]["id"], host2.pk) - def test_ordering_by_default_success(self): - self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_5") - self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_2") + def test_ordering_success(self): + provider_2 = self.add_provider(bundle=self.provider_bundle, name="another provider", description="provider") + self.host.state = "active" + self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_5", cluster=self.cluster_1) + self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_2", cluster=self.cluster_2) - response = (self.client.v2 / "hosts").get() - - self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(response.json()["count"], 3) - self.assertListEqual( - ["test_host", "test_host_2", "test_host_5"], - [host["name"] for host in response.json()["results"]], + self.add_host(bundle=self.provider_bundle, provider=provider_2, fqdn="test_host_7", cluster=self.cluster_2) + self.add_host(bundle=self.provider_bundle, provider=provider_2, fqdn="test_host_6", cluster=self.cluster_1) + self.add_host( + bundle=self.provider_bundle, description="description", provider=self.provider, fqdn="a_first_host" ) + Host.objects.filter(id__in=range(3)).update(state="running") + Host.objects.filter(id__in=range(5, -1)).update(state="active") - def test_ordering_by_id_desc_success(self): - host_2 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_6") - host_3 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_3") + with self.subTest("Ascending order by default (by fqdn"): + response = (self.client.v2 / "hosts").get() - response = (self.client.v2 / "hosts").get(query={"ordering": "-id"}) + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["a_first_host", "test_host", "test_host_2", "test_host_5", "test_host_6", "test_host_7"], + [host["name"] for host in response.json()["results"]], + ) - self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(response.json()["count"], 3) - self.assertListEqual( - [host_3.pk, host_2.pk, self.host.pk], - [host["id"] for host in response.json()["results"]], - ) + with self.subTest("Descending order by provider name"): + response = (self.client.v2 / "hosts").get(query={"ordering": "-hostproviderName"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["test_host", "test_host_5", "test_host_2", "a_first_host", "test_host_7", "test_host_6"], + [host["name"] for host in response.json()["results"]], + ) + + with self.subTest("Descending order by id"): + response = (self.client.v2 / "hosts").get(query={"ordering": "-id"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["a_first_host", "test_host_6", "test_host_7", "test_host_2", "test_host_5", "test_host"], + [host["name"] for host in response.json()["results"]], + ) + + with self.subTest("Descending order by cluster name"): + response = (self.client.v2 / "hosts").get(query={"ordering": "-clusterName"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["test_host_2", "test_host_7", "test_host_5", "test_host_6", "test_host", "a_first_host"], + [host["name"] for host in response.json()["results"]], + ) + + with self.subTest("Ascending order by state"): + response = (self.client.v2 / "hosts").get(query={"ordering": "state"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["test_host_2", "test_host_7", "test_host_6", "a_first_host", "test_host", "test_host_5"], + [host["name"] for host in response.json()["results"]], + ) + + with self.subTest("Descending order by state, ascending by name and provider name"): + response = (self.client.v2 / "hosts").get(query={"ordering": "state,-id,hostproviderName"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["a_first_host", "test_host_6", "test_host_7", "test_host_2", "test_host_5", "test_host"], + [host["name"] for host in response.json()["results"]], + ) class TestClusterHost(BaseAPITestCase): @@ -487,31 +535,68 @@ def test_maintenance_mode(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "on") - def test_ordering_by_default_success(self): - self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - self.add_host_to_cluster(cluster=self.cluster_1, host=self.host_2) + def test_ordering_success(self): + provider_2 = self.add_provider(bundle=self.provider_bundle, name="another provider", description="provider") + self.host.state = "active" + self.add_host_to_cluster(self.cluster_1, self.host) + self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_5", cluster=self.cluster_1) + self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_2", cluster=self.cluster_1) - response = self.client.v2[self.cluster_1, "hosts"].get() + self.add_host(bundle=self.provider_bundle, provider=provider_2, fqdn="test_host_7", cluster=self.cluster_1) + self.add_host(bundle=self.provider_bundle, provider=provider_2, fqdn="test_host_6", cluster=self.cluster_1) - self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(response.json()["count"], 3) - self.assertListEqual( - ["bound-to-same-host", "second-host", "test_host"], - [host["name"] for host in response.json()["results"]], - ) + Host.objects.filter(id__in=range(3)).update(state="running") + Host.objects.filter(id__in=range(5, -1)).update(state="active") - def test_ordering_by_id_desc_success(self): - self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - self.add_host_to_cluster(cluster=self.cluster_1, host=self.host_2) + with self.subTest("Ascending order by default (by name)"): + response = self.client.v2[self.cluster_1, "hosts"].get() - response = self.client.v2[self.cluster_1, "hosts"].get(query={"ordering": "-id"}) + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["bound-to-same-host", "test_host", "test_host_2", "test_host_5", "test_host_6", "test_host_7"], + [host["name"] for host in response.json()["results"]], + ) - self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(response.json()["count"], 3) - self.assertListEqual( - [self.control_host_same_cluster.pk, self.host_2.pk, self.host.pk], - [host["id"] for host in response.json()["results"]], - ) + with self.subTest("Descending order by id"): + response = self.client.v2[self.cluster_1, "hosts"].get(query={"ordering": "-id"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["test_host_6", "test_host_7", "test_host_2", "test_host_5", "bound-to-same-host", "test_host"], + [host["name"] for host in response.json()["results"]], + ) + + with self.subTest("Descending order by provider name"): + response = self.client.v2[self.cluster_1, "hosts"].get(query={"ordering": "hostproviderName"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["test_host_7", "test_host_6", "test_host", "bound-to-same-host", "test_host_5", "test_host_2"], + [host["name"] for host in response.json()["results"]], + ) + + with self.subTest("Ascending order by state"): + response = self.client.v2[self.cluster_1, "hosts"].get(query={"ordering": "state"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["bound-to-same-host", "test_host_5", "test_host_2", "test_host_7", "test_host_6", "test_host"], + [host["name"] for host in response.json()["results"]], + ) + + with self.subTest("Descending order by state, ascending by name and provider name"): + response = self.client.v2[self.cluster_1, "hosts"].get(query={"ordering": "state,-id,hostproviderName"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) + self.assertListEqual( + ["test_host_6", "test_host_7", "test_host_2", "test_host_5", "bound-to-same-host", "test_host"], + [host["name"] for host in response.json()["results"]], + ) def test_adcm_5687_filtering_by_component_id(self): service = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1).get() From dd8ef800dec492fda725b3bb01acdec0f6148c59 Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Wed, 28 Aug 2024 08:29:40 +0000 Subject: [PATCH 38/50] bugfix/ADCM-5879 show action hosts group hosts in dynamic action dialog Task: https://tracker.yandex.ru/ADCM-5879 --- .../adcm/clusterServiceActionHostGroups.ts | 2 +- ...clusterServiceComponentActionHostGroups.ts | 4 +- .../ActionHostGroups/useActionHostGroups.ts | 23 +++--- .../DynamicAction.types.ts | 13 +--- .../DynamicActionConfirm.tsx | 16 ---- .../DynamicActionDialog.module.scss | 8 +- .../DynamicActionDialog.tsx | 75 +++++------------- .../DynamicActionDialog.utils.ts | 41 ++++++---- ...micActionAgreeActionHostsGroup.module.scss | 36 +++++++++ .../DynamicActionAgreeActionHostsGroup.tsx | 78 +++++++++++++++++++ .../DynamicActionConfigSchema.tsx | 29 +++---- .../DynamicActionConfigSchemaToolbar.tsx | 15 ++-- .../DynamicActionConfirm.module.scss | 7 ++ .../DynamicActionConfirm.tsx | 41 ++++++++++ .../DynamicActionHostMapping.tsx | 24 +++--- .../DynamicActionHostMapping.utils.ts | 0 .../DynamicActionSteps/DynamicActionSteps.tsx | 71 ++++++++++------- .../components/uikit/Tags/Tags.module.scss | 2 +- adcm-web/app/src/models/adcm/dynamicAction.ts | 13 +++- .../dynamicActionsSlice.ts | 20 ++--- 20 files changed, 323 insertions(+), 195 deletions(-) delete mode 100644 adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfirm/DynamicActionConfirm.tsx create mode 100644 adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.module.scss create mode 100644 adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.tsx rename adcm-web/app/src/components/common/DynamicActionDialog/{ => DynamicActionSteps}/DynamicActionConfigSchema/DynamicActionConfigSchema.tsx (71%) rename adcm-web/app/src/components/common/DynamicActionDialog/{ => DynamicActionSteps}/DynamicActionConfigSchema/DynamicActionConfigSchemaToolbar/DynamicActionConfigSchemaToolbar.tsx (83%) create mode 100644 adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfirm/DynamicActionConfirm.module.scss create mode 100644 adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfirm/DynamicActionConfirm.tsx rename adcm-web/app/src/components/common/DynamicActionDialog/{ => DynamicActionSteps}/DynamicActionHostMapping/DynamicActionHostMapping.tsx (86%) rename adcm-web/app/src/components/common/DynamicActionDialog/{ => DynamicActionSteps}/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts (100%) diff --git a/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts index 874093a84d..faf58f9b7d 100644 --- a/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts +++ b/adcm-web/app/src/api/adcm/clusterServiceActionHostGroups.ts @@ -37,7 +37,7 @@ export class AdcmClusterServiceActionHostGroupsApi { public static async getActionHostGroup(args: GetAdcmServiceActionHostGroupArgs) { const { clusterId, serviceId, actionHostGroupId } = args; const response = await httpClient.get( - `/api/v2/clusters/${clusterId}/serviceId/${serviceId}/action-host-groups/${actionHostGroupId}/`, + `/api/v2/clusters/${clusterId}/services/${serviceId}/action-host-groups/${actionHostGroupId}/`, ); return response.data; diff --git a/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts b/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts index d482ab939c..d404aadb23 100644 --- a/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts +++ b/adcm-web/app/src/api/adcm/clusterServiceComponentActionHostGroups.ts @@ -35,9 +35,9 @@ export class AdcmClusterServiceComponentActionHostGroupsApi { } public static async getActionHostGroup(args: GetAdcmComponentActionHostGroupArgs) { - const { clusterId, serviceId, actionHostGroupId } = args; + const { clusterId, serviceId, componentId, actionHostGroupId } = args; const response = await httpClient.get( - `/api/v2/clusters/${clusterId}/serviceId/${serviceId}/action-host-groups/${actionHostGroupId}/`, + `/api/v2/clusters/${clusterId}/services/${serviceId}/components/${componentId}/action-host-groups/${actionHostGroupId}/`, ); return response.data; diff --git a/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts b/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts index 8fd0e8c5b2..ccc846578f 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts +++ b/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts @@ -50,8 +50,8 @@ export const useActionHostGroups = ( const isActionHostGroupsLoading = useStore(({ adcm }) => isShowSpinner(adcm.actionHostGroups.loadState)); const actionHostGroups = useStore(({ adcm }) => adcm.actionHostGroups.actionHostGroups); - const runnableActionHostGroupId = useStore(({ adcm }) => adcm.dynamicActions.actionHostGroupId); const dynamicActions = useStore(({ adcm }) => adcm.dynamicActions.dynamicActions); + const runnableActionHostGroup = useStore(({ adcm }) => adcm.dynamicActions.actionHostGroup); const dynamicActionDetails = useStore(({ adcm }) => adcm.dynamicActions.actionDetails); const isCreateDialogOpen = useStore(({ adcm }) => adcm.actionHostGroupsActions.createDialog.isOpen); @@ -96,15 +96,17 @@ export const useActionHostGroups = ( }; const handleSubmitDynamicAction = (actionRunConfig: AdcmDynamicActionRunConfig) => { - dispatch( - runDynamicAction({ - entityType, - entityArgs, - actionHostGroupId: runnableActionHostGroupId!, - actionId: dynamicActionDetails!.id, - actionRunConfig, - }), - ); + if (runnableActionHostGroup && dynamicActionDetails) { + dispatch( + runDynamicAction({ + entityType, + entityArgs, + actionHostGroupId: runnableActionHostGroup.id, + actionId: dynamicActionDetails.id, + actionRunConfig, + }), + ); + } }; const handleCloseDynamicActionDialog = () => { @@ -185,6 +187,7 @@ export const useActionHostGroups = ( dynamicActionDialogProps: dynamicActionDetails ? { actionDetails: dynamicActionDetails, + actionHostGroup: runnableActionHostGroup ?? undefined, clusterId: entityArgs.clusterId, onSubmit: handleSubmitDynamicAction, onCancel: handleCloseDynamicActionDialog, diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicAction.types.ts b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicAction.types.ts index 0fabb35e8e..ad88fa481a 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicAction.types.ts +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicAction.types.ts @@ -1,13 +1,6 @@ -import { AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm/dynamicAction'; - -export interface DynamicActionCommonOptions { - actionDetails: AdcmDynamicActionDetails; - onSubmit: (data: Partial) => void; - onCancel: () => void; -} - -export enum DynamicActionType { - Confirm = 'confirm', +export enum DynamicActionStep { + AgreeActionHostsGroup = 'agreeActionHostsGroup', ConfigSchema = 'configSchema', HostComponentMapping = 'hostComponentMapping', + Confirm = 'confirm', } diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfirm/DynamicActionConfirm.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfirm/DynamicActionConfirm.tsx deleted file mode 100644 index 209039568c..0000000000 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfirm/DynamicActionConfirm.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { AdcmDynamicActionDetails } from '@models/adcm/dynamicAction'; - -interface DynamicActionConfirmProps { - actionDetails: AdcmDynamicActionDetails; -} - -const DynamicActionConfirm: React.FC = ({ actionDetails }) => { - return ( -
-
{actionDetails.disclaimer || `${actionDetails.displayName} will be started.`}
-
- ); -}; - -export default DynamicActionConfirm; diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.module.scss b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.module.scss index de2cd3bf2f..d16c7e62a2 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.module.scss +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.module.scss @@ -1,13 +1,13 @@ .dynamicActionDialog { - &__toolbar { + &__tabs { margin-bottom: 24px; } - &__tabs { + &__toolbar { margin-bottom: 24px; } - &__verbose { - margin-top: 32px; + &__footer { + margin-top: 24px; } } diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.tsx index 15618ae420..a838269213 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.tsx @@ -1,44 +1,27 @@ -import React, { ChangeEvent, useState, useEffect, useMemo } from 'react'; -import { Checkbox, Dialog } from '@uikit'; -import DynamicActionSteps from '@commonComponents/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps'; -import { - getDefaultRunConfig, - getDynamicActionTypes, -} from '@commonComponents/DynamicActionDialog/DynamicActionDialog.utils'; -import { DynamicActionCommonOptions, DynamicActionType } from './DynamicAction.types'; -import { AdcmDynamicActionRunConfig } from '@models/adcm/dynamicAction'; -import DynamicActionConfirm from '@commonComponents/DynamicActionDialog/DynamicActionConfirm/DynamicActionConfirm'; -import CustomDialogControls from '@commonComponents/Dialog/CustomDialogControls/CustomDialogControls'; +import React, { useMemo } from 'react'; +import { Dialog } from '@uikit'; +import DynamicActionSteps from './DynamicActionSteps/DynamicActionSteps'; +import { getDynamicActionSteps } from './DynamicActionDialog.utils'; +import type { AdcmActionHostGroup, AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm'; -export interface DynamicActionDialogProps extends Omit { +export interface DynamicActionDialogProps { clusterId: number | null; + actionDetails: AdcmDynamicActionDetails; + actionHostGroup?: AdcmActionHostGroup; onSubmit: (data: AdcmDynamicActionRunConfig) => void; + onCancel: () => void; } -const DynamicActionDialog: React.FC = ({ clusterId, actionDetails, onCancel, onSubmit }) => { - const [localActionRunConfig, setLocalActionRunConfig] = useState(() => { - return getDefaultRunConfig(); - }); - +const DynamicActionDialog: React.FC = ({ + clusterId, + actionDetails, + actionHostGroup, + onCancel, + onSubmit, +}) => { const dynamicActionTypes = useMemo(() => { - return getDynamicActionTypes(actionDetails); - }, [actionDetails]); - const [isShowDisclaimer, setIsShowDisclaimer] = useState(dynamicActionTypes.includes(DynamicActionType.Confirm)); - - useEffect(() => { - setIsShowDisclaimer(dynamicActionTypes.includes(DynamicActionType.Confirm)); - }, [dynamicActionTypes, setIsShowDisclaimer]); - - const handleSubmit = (data: Partial) => { - const newActionRunConfig = { ...localActionRunConfig, ...data }; - setLocalActionRunConfig(newActionRunConfig); - setIsShowDisclaimer(true); - }; - - const handleChangeVerbose = (event: ChangeEvent) => { - const isVerbose = event.target.checked; - setLocalActionRunConfig((prev) => ({ ...prev, isVerbose })); - }; + return getDynamicActionSteps(actionDetails, actionHostGroup); + }, [actionDetails, actionHostGroup]); const commonDialogOptions = { isOpen: true, @@ -47,32 +30,14 @@ const DynamicActionDialog: React.FC = ({ clusterId, ac onCancel: onCancel, }; - if (isShowDisclaimer) { - const dialogControls = ( - onSubmit(localActionRunConfig)} - isActionButtonDefaultFocus={true} - > - - - ); - - return ( - - - - ); - } - return ( diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.utils.ts b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.utils.ts index 2d0be96246..2db4f5d390 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.utils.ts +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.utils.ts @@ -1,35 +1,44 @@ -import { DynamicActionType } from '@commonComponents/DynamicActionDialog/DynamicAction.types'; -import { AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm/dynamicAction'; -import { AdcmConfiguration, ConfigurationData } from '@models/adcm'; +import { DynamicActionStep } from '@commonComponents/DynamicActionDialog/DynamicAction.types'; +import type { + AdcmActionHostGroup, + AdcmConfiguration, + ConfigurationData, + AdcmDynamicActionDetails, + AdcmDynamicActionRunConfig, +} from '@models/adcm'; import { generateFromSchema } from '@utils/jsonSchema/jsonSchemaUtils'; -export const getDynamicActionTypes = (actionDetails: AdcmDynamicActionDetails): DynamicActionType[] => { - const res = [] as DynamicActionType[]; - if (actionDetails.configuration !== null) { - res.push(DynamicActionType.ConfigSchema); +export const getDynamicActionSteps = ( + actionDetails: AdcmDynamicActionDetails, + actionHostsGroup: AdcmActionHostGroup | undefined, +): DynamicActionStep[] => { + const steps = [] as DynamicActionStep[]; + + if (actionHostsGroup) { + steps.push(DynamicActionStep.AgreeActionHostsGroup); } - if (actionDetails.hostComponentMapRules.length > 0) { - res.push(DynamicActionType.HostComponentMapping); + if (actionDetails.configuration !== null) { + steps.push(DynamicActionStep.ConfigSchema); } - if (res.length > 0) { - return res; + if (actionDetails.hostComponentMapRules.length > 0) { + steps.push(DynamicActionStep.HostComponentMapping); } - // fallback for standard message 'Are you sure?' - return [DynamicActionType.Confirm]; + steps.push(DynamicActionStep.Confirm); + return steps; }; -export const getDefaultHostMappingRunConfig = (): Pick => ({ +const getDefaultHostMappingRunConfig = (): Pick => ({ hostComponentMap: [], }); -export const getDefaultConfigurationRunConfig = (): Pick => ({ +const getDefaultConfigurationRunConfig = (): Pick => ({ configuration: null, }); -export const getDefaultVerboseRunConfig = (): Pick => ({ +const getDefaultVerboseRunConfig = (): Pick => ({ isVerbose: false, }); diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.module.scss b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.module.scss new file mode 100644 index 0000000000..55f6f47df9 --- /dev/null +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.module.scss @@ -0,0 +1,36 @@ +:global { + body.theme-dark { + --hosts-border: var(--color-xdark); + } + + body.theme-light { + --hosts-border: var(--alert-border-success); + } +} + +.dynamicActionAgreeActionHostsGroup { + display: flex; + flex-direction: column; + + &__filter { + display: flex; + gap: 12px; + } + + &__dynamicActionAgreeActionHostsGroup__content { + flex: 1; + } + + &__hosts { + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + max-height: 272px; + + border-radius: 8px; + border: 1px solid var(--hosts-border); + } +} diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.tsx new file mode 100644 index 0000000000..b9fd8793c0 --- /dev/null +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.tsx @@ -0,0 +1,78 @@ +import { useMemo, useState } from 'react'; +import { Button, ButtonGroup, ToolbarPanel, Checkbox, SearchInput, Tag } from '@uikit'; +import type { AdcmActionHostGroup, AdcmDynamicActionRunConfig } from '@models/adcm'; +import dialogStyles from '../../DynamicActionDialog.module.scss'; +import s from './DynamicActionAgreeActionHostsGroup.module.scss'; +import cn from 'classnames'; + +interface DynamicActionAgreeActionHostsGroupProps { + actionHostGroup: AdcmActionHostGroup; + onNext: (changes: Partial) => void; + onCancel: () => void; +} + +const DynamicActionAgreeActionHostsGroup = ({ + actionHostGroup, + onNext, + onCancel, +}: DynamicActionAgreeActionHostsGroupProps) => { + const [isAgree, setIsAgree] = useState(false); + const [hostNameFilter, setHostNameFilter] = useState(''); + + const handleAgreeChange = (event: React.ChangeEvent) => { + setIsAgree(event.target.checked); + }; + + const handleHostNameFilterChange = (e: React.ChangeEvent) => { + setHostNameFilter(e.target.value); + }; + + const handleFilterReset = () => { + setHostNameFilter(''); + }; + + const handleNextClick = () => { + onNext({}); + }; + + const filteredHosts = useMemo(() => { + if (hostNameFilter === '') { + return actionHostGroup.hosts; + } + return actionHostGroup.hosts.filter( + (x) => x.name.toLocaleLowerCase().indexOf(hostNameFilter.toLocaleLowerCase()) !== -1, + ); + }, [hostNameFilter, actionHostGroup.hosts]); + + return ( +
+ +
+ +
+ + + + +
+ +
+
+ {filteredHosts.map((h) => ( + {h.name} + ))} +
+
+ +
+
+
+ ); +}; + +export default DynamicActionAgreeActionHostsGroup; diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfigSchema/DynamicActionConfigSchema.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfigSchema/DynamicActionConfigSchema.tsx similarity index 71% rename from adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfigSchema/DynamicActionConfigSchema.tsx rename to adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfigSchema/DynamicActionConfigSchema.tsx index f6db461c53..5489299ead 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfigSchema/DynamicActionConfigSchema.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfigSchema/DynamicActionConfigSchema.tsx @@ -1,23 +1,23 @@ -import React, { useState } from 'react'; -import { DynamicActionCommonOptions } from '@commonComponents/DynamicActionDialog/DynamicAction.types'; +import { useState } from 'react'; import { prepareConfigurationFromActionDetails } from '@commonComponents/DynamicActionDialog/DynamicActionDialog.utils'; import DynamicActionConfigSchemaToolbar from './DynamicActionConfigSchemaToolbar/DynamicActionConfigSchemaToolbar'; import ConfigurationFormContextProvider from '@commonComponents/configuration/ConfigurationFormContext/ConfigurationFormContextProvider'; import ConfigurationMain from '@commonComponents/configuration/ConfigurationMain/ConfigurationMain'; -import { AdcmConfiguration, AdcmDynamicActionRunConfig } from '@models/adcm'; +import type { AdcmConfiguration, AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm'; -interface DynamicActionConfigSchemaProps extends DynamicActionCommonOptions { - submitLabel?: string; +interface DynamicActionConfigSchemaProps { + actionDetails: AdcmDynamicActionDetails; configuration?: AdcmDynamicActionRunConfig['configuration'] | null; + onNext: (changes: Partial) => void; + onCancel: () => void; } -const DynamicActionConfigSchema: React.FC = ({ +const DynamicActionConfigSchema = ({ actionDetails, - onSubmit, + onNext, onCancel, - submitLabel = 'Run', configuration: runConfiguration, -}) => { +}: DynamicActionConfigSchemaProps) => { const [localConfiguration, setLocalConfiguration] = useState(() => { const configuration = prepareConfigurationFromActionDetails(actionDetails); if (configuration === null) return null; @@ -34,9 +34,9 @@ const DynamicActionConfigSchema: React.FC = ({ setLocalConfiguration(configuration); }; - const handleSubmit = () => { + const handleNext = () => { if (localConfiguration) { - onSubmit({ + onNext({ configuration: { config: localConfiguration.configurationData, adcmMeta: localConfiguration.attributes }, }); } @@ -45,12 +45,7 @@ const DynamicActionConfigSchema: React.FC = ({ return (
- +
diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfigSchema/DynamicActionConfigSchemaToolbar/DynamicActionConfigSchemaToolbar.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfigSchema/DynamicActionConfigSchemaToolbar/DynamicActionConfigSchemaToolbar.tsx similarity index 83% rename from adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfigSchema/DynamicActionConfigSchemaToolbar/DynamicActionConfigSchemaToolbar.tsx rename to adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfigSchema/DynamicActionConfigSchemaToolbar/DynamicActionConfigSchemaToolbar.tsx index f34963f51d..58f1667b27 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionConfigSchema/DynamicActionConfigSchemaToolbar/DynamicActionConfigSchemaToolbar.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfigSchema/DynamicActionConfigSchemaToolbar/DynamicActionConfigSchemaToolbar.tsx @@ -1,21 +1,18 @@ import React from 'react'; -import s from '@commonComponents/DynamicActionDialog/DynamicActionDialog.module.scss'; -import { Button, ButtonGroup, SearchInput, Switch } from '@uikit'; -import ToolbarPanel from '@uikit/ToolbarPanel/ToolbarPanel'; +import { Button, ButtonGroup, SearchInput, Switch, ToolbarPanel } from '@uikit'; import { useConfigurationFormContext } from '@commonComponents/configuration/ConfigurationFormContext/ConfigurationFormContext.context'; +import s from '@commonComponents/DynamicActionDialog/DynamicActionDialog.module.scss'; interface DynamicActionConfigSchemaToolbarProps { - onSubmit: () => void; + onNext: () => void; onCancel: () => void; onReset: () => void; - submitLabel: string; } const DynamicActionConfigSchemaToolbar: React.FC = ({ onReset, - onSubmit, + onNext, onCancel, - submitLabel, }) => { const { filter, onFilterChange, isValid, areExpandedAll, handleChangeExpandedAll } = useConfigurationFormContext(); @@ -39,8 +36,8 @@ const DynamicActionConfigSchemaToolbar: React.FC Cancel - diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfirm/DynamicActionConfirm.module.scss b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfirm/DynamicActionConfirm.module.scss new file mode 100644 index 0000000000..eec4be24ea --- /dev/null +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfirm/DynamicActionConfirm.module.scss @@ -0,0 +1,7 @@ +.dynamicActionConfirm { + &__footer { + display: flex; + justify-content: space-between; + align-items: center; + } +} diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfirm/DynamicActionConfirm.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfirm/DynamicActionConfirm.tsx new file mode 100644 index 0000000000..d7e8bfba66 --- /dev/null +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionConfirm/DynamicActionConfirm.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { Button, ButtonGroup, Checkbox } from '@uikit'; +import type { AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm'; +import dialogStyles from '../../DynamicActionDialog.module.scss'; +import s from './DynamicActionConfirm.module.scss'; +import cn from 'classnames'; + +interface DynamicActionConfirmProps { + actionDetails: AdcmDynamicActionDetails; + onRun: (changes: Partial) => void; + onCancel: () => void; +} + +const DynamicActionConfirm = ({ actionDetails, onRun, onCancel }: DynamicActionConfirmProps) => { + const [isVerbose, setIsVerbose] = useState(false); + + const handleRun = () => { + onRun({ isVerbose }); + }; + + const handleVerboseChange = (event: React.ChangeEvent) => { + setIsVerbose(event.target.checked); + }; + + return ( +
+
{actionDetails.disclaimer || `${actionDetails.displayName} will be started.`}
+
+ + + + + +
+
+ ); +}; + +export default DynamicActionConfirm; diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionHostMapping/DynamicActionHostMapping.tsx similarity index 86% rename from adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx rename to adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionHostMapping/DynamicActionHostMapping.tsx index fcc11a8516..a6ae0838c1 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionHostMapping/DynamicActionHostMapping.tsx @@ -1,26 +1,28 @@ import React, { useEffect, useMemo } from 'react'; +import { Link } from 'react-router-dom'; import { useDispatch, useStore } from '@hooks'; import { Button, ButtonGroup, SearchInput, SpinnerPanel, ToolbarPanel } from '@uikit'; -import { DynamicActionCommonOptions } from '@commonComponents/DynamicActionDialog/DynamicAction.types'; -import s from '@commonComponents/DynamicActionDialog/DynamicActionDialog.module.scss'; import { useClusterMapping } from '@pages/cluster/ClusterMapping/useClusterMapping'; import ComponentContainer from '@pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer'; import { getMappings } from '@store/adcm/entityDynamicActions/dynamicActionsMappingSlice'; import { getComponentMapActions, getDisabledMappings } from './DynamicActionHostMapping.utils'; -import { Link } from 'react-router-dom'; import { LoadState } from '@models/loadState'; +import type { AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm'; +import s from '../..//DynamicActionDialog.module.scss'; -interface DynamicActionHostMappingProps extends DynamicActionCommonOptions { - submitLabel?: string; +interface DynamicActionHostMappingProps { clusterId: number; + actionDetails: AdcmDynamicActionDetails; + configuration?: AdcmDynamicActionRunConfig['configuration'] | null; + onNext: (changes: Partial) => void; + onCancel: () => void; } const DynamicActionHostMapping: React.FC = ({ clusterId, actionDetails, - onSubmit, + onNext, onCancel, - submitLabel = 'Run', }) => { const dispatch = useDispatch(); @@ -47,8 +49,8 @@ const DynamicActionHostMapping: React.FC = ({ const isServicesMappingEmpty = servicesMapping.length === 0; - const handleSubmit = () => { - onSubmit({ hostComponentMap: localMapping }); + const handleNext = () => { + onNext({ hostComponentMap: localMapping }); }; const handleFilterChange = (event: React.ChangeEvent) => { @@ -67,8 +69,8 @@ const DynamicActionHostMapping: React.FC = ({ - diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts similarity index 100% rename from adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts rename to adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps.tsx index 22e9266f15..76a7a84777 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps.tsx @@ -1,39 +1,42 @@ -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import WizardSteps from '@uikit/WizardSteps/WizardSteps'; -import DynamicActionConfigSchema from '@commonComponents/DynamicActionDialog/DynamicActionConfigSchema/DynamicActionConfigSchema'; -import { - DynamicActionCommonOptions, - DynamicActionType, -} from '@commonComponents/DynamicActionDialog/DynamicAction.types'; -import DynamicActionHostMapping from '@commonComponents/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping'; -import { AdcmDynamicActionRunConfig } from '@models/adcm/dynamicAction'; -import s from '../DynamicActionDialog.module.scss'; +import { DynamicActionStep } from '../DynamicAction.types'; +import DynamicActionConfigSchema from './DynamicActionConfigSchema/DynamicActionConfigSchema'; +import DynamicActionAgreeActionHostsGroup from './DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup'; +import DynamicActionHostMapping from './DynamicActionHostMapping/DynamicActionHostMapping'; +import type { AdcmActionHostGroup, AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm'; import { getNextStep, isLastStep } from '@uikit/WizardSteps/WizardSteps.utils'; +import DynamicActionConfirm from './DynamicActionConfirm/DynamicActionConfirm'; +import { getDefaultRunConfig } from '../DynamicActionDialog.utils'; +import s from '../DynamicActionDialog.module.scss'; -const stepsTitles: Record = { - [DynamicActionType.ConfigSchema]: 'Configuration', - [DynamicActionType.HostComponentMapping]: 'Host - Component', - [DynamicActionType.Confirm]: 'Confirm', +const stepsTitles: Record = { + [DynamicActionStep.AgreeActionHostsGroup]: 'Hosts group', + [DynamicActionStep.ConfigSchema]: 'Configuration', + [DynamicActionStep.HostComponentMapping]: 'Host - Component', + [DynamicActionStep.Confirm]: 'Confirm', }; -interface DynamicActionsStepsProps extends DynamicActionCommonOptions { +interface DynamicActionsStepsProps { clusterId: number | null; - actionSteps: DynamicActionType[]; + actionDetails: AdcmDynamicActionDetails; + actionHostGroup?: AdcmActionHostGroup; + actionSteps: DynamicActionStep[]; + onSubmit: (runConfig: AdcmDynamicActionRunConfig) => void; + onCancel: () => void; } -const DynamicActionSteps: React.FC = ({ +const DynamicActionSteps = ({ actionDetails, + actionHostGroup, onSubmit, onCancel, clusterId, actionSteps, -}) => { - const [localActionRunConfig, setLocalActionRunConfig] = useState>(() => { - return { - configuration: null, - hostComponentMap: [], - }; - }); +}: DynamicActionsStepsProps) => { + const [localActionRunConfig, setLocalActionRunConfig] = useState(() => + getDefaultRunConfig(), + ); const [currentStep, setCurrentStep] = useState(actionSteps[0]); @@ -44,12 +47,11 @@ const DynamicActionSteps: React.FC = ({ })); }, [actionSteps]); - const handleSubmit = (data: Partial) => { + const handleStepChange = (data: Partial) => { const newActionRunConfig = { ...localActionRunConfig, ...data }; setLocalActionRunConfig(newActionRunConfig); if (isLastStep(currentStep, wizardStepsOptions)) { - // if submit of last step - call full submit onSubmit(newActionRunConfig); } else { setCurrentStep(getNextStep(currentStep, wizardStepsOptions)); @@ -68,23 +70,32 @@ const DynamicActionSteps: React.FC = ({ className={s.dynamicActionDialog__tabs} /> )} - {currentStep === DynamicActionType.ConfigSchema && ( + {currentStep === DynamicActionStep.AgreeActionHostsGroup && actionHostGroup && ( + + )} + {currentStep === DynamicActionStep.ConfigSchema && ( )} - {currentStep === DynamicActionType.HostComponentMapping && clusterId && ( + {currentStep === DynamicActionStep.HostComponentMapping && clusterId && ( )} + {currentStep === DynamicActionStep.Confirm && ( + + )}
); }; diff --git a/adcm-web/app/src/components/uikit/Tags/Tags.module.scss b/adcm-web/app/src/components/uikit/Tags/Tags.module.scss index 45d696da11..57a7a53a01 100644 --- a/adcm-web/app/src/components/uikit/Tags/Tags.module.scss +++ b/adcm-web/app/src/components/uikit/Tags/Tags.module.scss @@ -29,7 +29,7 @@ display: inline-flex; align-items: flex-start; gap: var(--tag-gap, 8px); - padding: 6px; + padding: 6px 12px; transition: 250ms background-color, 250ms color; background: var(--tag-background); color: var(--tag-color); diff --git a/adcm-web/app/src/models/adcm/dynamicAction.ts b/adcm-web/app/src/models/adcm/dynamicAction.ts index 4843945567..3ffa9727cf 100644 --- a/adcm-web/app/src/models/adcm/dynamicAction.ts +++ b/adcm-web/app/src/models/adcm/dynamicAction.ts @@ -1,5 +1,5 @@ -import { AdcmMapping } from './clusterMapping'; -import { AdcmConfig, ConfigurationSchema } from './configuration'; +import type { AdcmMapping } from './clusterMapping'; +import type { ConfigurationAttributes, ConfigurationData, ConfigurationSchema } from './configuration'; export enum AdcmHostComponentMapRuleAction { Add = 'add', @@ -19,7 +19,9 @@ export interface AdcmDynamicAction { startImpossibleReason: string; } -export type AdcmDynamicActionConfiguration = Pick & { +export type AdcmDynamicActionConfiguration = { + config: ConfigurationData; + adcmMeta: ConfigurationAttributes; configSchema: ConfigurationSchema; }; @@ -36,7 +38,10 @@ export interface AdcmDynamicActionDetails { export interface AdcmDynamicActionRunConfig { hostComponentMap: AdcmMapping[]; isVerbose: boolean; - configuration: Pick | null; + configuration: { + config: ConfigurationData; + adcmMeta: ConfigurationAttributes; + } | null; } export type EntitiesDynamicActions = Record; diff --git a/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts index ca52ad02d6..16ee407052 100644 --- a/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts +++ b/adcm-web/app/src/store/adcm/entityDynamicActions/dynamicActionsSlice.ts @@ -3,7 +3,7 @@ import { createAsyncThunk } from '@store/redux'; import { RequestError } from '@api'; import { fulfilledFilter } from '@utils/promiseUtils'; import { showError, showSuccess } from '@store/notificationsSlice'; -import type { AdcmDynamicActionDetails, EntitiesDynamicActions } from '@models/adcm/dynamicAction'; +import type { AdcmActionHostGroup, AdcmDynamicActionDetails, EntitiesDynamicActions } from '@models/adcm'; import { getErrorMessage } from '@utils/httpResponseUtils'; import { ActionStatuses } from '@constants'; import { services } from '@store/adcm/entityActionHostGroups/actionHostGroupsSlice.constants'; @@ -56,8 +56,9 @@ const openDynamicActionDialog = createAsyncThunk( try { const service = services[entityType]; const actionDetails = await service.getActionHostGroupAction({ ...entityArgs, actionHostGroupId, actionId }); + const actionHostGroup = await service.getActionHostGroup({ ...entityArgs, actionHostGroupId }); - return actionDetails; + return { actionDetails, actionHostGroup }; } catch (error) { thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); return thunkAPI.rejectWithValue(null); @@ -92,13 +93,13 @@ const runDynamicAction = createAsyncThunk( ); type AdcmDynamicActionsState = { - actionHostGroupId: number | null; + actionHostGroup: AdcmActionHostGroup | null; actionDetails: AdcmDynamicActionDetails | null; dynamicActions: EntitiesDynamicActions; }; const createInitialState = (): AdcmDynamicActionsState => ({ - actionHostGroupId: null, + actionHostGroup: null, actionDetails: null, dynamicActions: {}, }); @@ -112,7 +113,7 @@ const dynamicActionsSlice = createSlice({ }, closeDynamicActionDialog(state) { state.actionDetails = null; - state.actionHostGroupId = null; + state.actionHostGroup = null; }, }, extraReducers: (builder) => { @@ -123,18 +124,19 @@ const dynamicActionsSlice = createSlice({ state.dynamicActions = []; }); builder.addCase(openDynamicActionDialog.fulfilled, (state, action) => { + const { actionDetails, actionHostGroup } = action.payload; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - state.actionDetails = action.payload; - state.actionHostGroupId = action.meta.arg.actionHostGroupId; + state.actionDetails = actionDetails; + state.actionHostGroup = actionHostGroup; }); builder.addCase(openDynamicActionDialog.rejected, (state) => { state.actionDetails = null; - state.actionHostGroupId = null; + state.actionHostGroup = null; }); builder.addCase(runDynamicAction.pending, (state) => { state.actionDetails = null; - state.actionHostGroupId = null; + state.actionHostGroup = null; }); }, }); From 7dd21e0bd58d16342ffb234d0062420801506fcc Mon Sep 17 00:00:00 2001 From: Vladimir Remizov Date: Wed, 28 Aug 2024 08:31:51 +0000 Subject: [PATCH 39/50] [ui] feature/ADCM-5906 blocked actions host group by parent concerns https://tracker.yandex.ru/ADCM-5906 --- .../ActionHostGroupsTable/ActionHostGroupsTable.tsx | 5 +++++ .../common/ActionHostGroups/useActionHostGroups.ts | 11 ++++++++++- .../useClusterActionHostGroups.ts | 4 +++- .../useServiceActionHostGroups.ts | 4 +++- .../useComponentActionHostGroups.ts | 4 +++- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx index 91e464f964..cfa184ff32 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupsTable/ActionHostGroupsTable.tsx @@ -9,6 +9,9 @@ import ExpandDetailsCell from '@commonComponents/ExpandDetailsCell/ExpandDetails import ActionHostGroupTableFooter from '../ActionHostGroupTableFooter/ActionHostGroupFooter'; export interface ActionHostGroupsTableProps { + // action host group not independent entity + // is parent entity have some concerns then we can't run dynamic actions on actions groups + isDynamicActionsDisabled: boolean; actionHostGroups: AdcmActionHostGroup[]; dynamicActions: EntitiesDynamicActions; isLoading: boolean; @@ -20,6 +23,7 @@ export interface ActionHostGroupsTableProps { const ActionHostGroupsTable = ({ actionHostGroups, dynamicActions, + isDynamicActionsDisabled, isLoading, onOpenDynamicActionDialog, onOpenEditDialog, @@ -53,6 +57,7 @@ const ActionHostGroupsTable = ({ onOpenDynamicActionDialog(actionHostGroup, actionId)} /> diff --git a/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts b/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts index 8fd0e8c5b2..d77c2f91ef 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts +++ b/adcm-web/app/src/components/common/ActionHostGroups/useActionHostGroups.ts @@ -25,9 +25,15 @@ import type { DynamicActionDialogProps } from '@commonComponents/DynamicActionDi import type { EditActionHostGroupDialogProps } from './ActionHostGroupDialogs/EditActionHostGroupDialog/EditActionHostGroupDialog'; import type { DeleteActionHostGroupDialogProps } from '@commonComponents/ActionHostGroups/ActionHostGroupDialogs/DeleteActionHostGroupDialog/DeleteActionHostGroupDialog'; import type { ActionHostGroupOwner, EntityArgs } from '@store/adcm/entityActionHostGroups/actionHostGroups.types'; -import type { AdcmActionHostGroup, AdcmActionHostGroupsFilter, AdcmDynamicActionRunConfig } from '@models/adcm'; +import type { + AdcmActionHostGroup, + AdcmActionHostGroupsFilter, + AdcmConcerns, + AdcmDynamicActionRunConfig, +} from '@models/adcm'; import { useRequestActionHostGroups } from './useRequestActionHostGroups'; import { isShowSpinner } from '@uikit/Table/Table.utils'; +import { isBlockingConcernPresent } from '@utils/concernUtils'; type UseActionHostGroupsResult = { entityArgs: EntityArgs; @@ -42,6 +48,7 @@ type UseActionHostGroupsResult = { export const useActionHostGroups = ( entityType: T, entityArgs: EntityArgs, + parentConcerns: AdcmConcerns[] = [], ): UseActionHostGroupsResult => { const dispatch = useDispatch(); @@ -169,6 +176,8 @@ export const useActionHostGroups = ( onOpenCreateDialog: handleOpenCreateDialog, }, tableProps: { + // if parent entity has a concerns then we should block dynamic actions for action host groups + isDynamicActionsDisabled: isBlockingConcernPresent(parentConcerns), actionHostGroups, dynamicActions, isLoading: isActionHostGroupsLoading, diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useClusterActionHostGroups.ts b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useClusterActionHostGroups.ts index 11b5b2fbb8..f6059abf72 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useClusterActionHostGroups.ts +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/useClusterActionHostGroups.ts @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom'; import { useActionHostGroups } from '@commonComponents/ActionHostGroups/useActionHostGroups'; import { useMemo } from 'react'; +import { useStore } from '@hooks'; export const useClusterActionHostGroups = () => { const { clusterId: clusterIdFromUrl } = useParams(); @@ -8,6 +9,7 @@ export const useClusterActionHostGroups = () => { const entityArgs = useMemo(() => ({ clusterId }), [clusterId]); - const props = useActionHostGroups('cluster', entityArgs); + const cluster = useStore(({ adcm }) => adcm.cluster.cluster); + const props = useActionHostGroups('cluster', entityArgs, cluster?.concerns); return props; }; diff --git a/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/useServiceActionHostGroups.ts b/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/useServiceActionHostGroups.ts index ae22433deb..44658e2cac 100644 --- a/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/useServiceActionHostGroups.ts +++ b/adcm-web/app/src/components/pages/cluster/service/ServiceActionHostGroups/useServiceActionHostGroups.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useActionHostGroups } from '@commonComponents/ActionHostGroups/useActionHostGroups'; +import { useStore } from '@hooks'; export const useServiceActionHostGroups = () => { const { clusterId: clusterIdFromUrl, serviceId: serviceIdFromUrl } = useParams(); @@ -9,7 +10,8 @@ export const useServiceActionHostGroups = () => { const entityArgs = useMemo(() => ({ clusterId, serviceId }), [clusterId, serviceId]); - const props = useActionHostGroups('service', entityArgs); + const service = useStore(({ adcm }) => adcm.service.service); + const props = useActionHostGroups('service', entityArgs, service?.concerns); return props; }; diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/useComponentActionHostGroups.ts b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/useComponentActionHostGroups.ts index 9c41e8d497..7e8ab33988 100644 --- a/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/useComponentActionHostGroups.ts +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/useComponentActionHostGroups.ts @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom'; import { useActionHostGroups } from '@commonComponents/ActionHostGroups/useActionHostGroups'; import { useMemo } from 'react'; +import { useStore } from '@hooks'; export const useComponentActionHostGroups = () => { const { clusterId: clusterIdFromUrl, serviceId: serviceIdFromUrl, componentId: componentIdFromUrl } = useParams(); @@ -10,7 +11,8 @@ export const useComponentActionHostGroups = () => { const entityArgs = useMemo(() => ({ clusterId, serviceId, componentId }), [clusterId, serviceId, componentId]); - const props = useActionHostGroups('component', entityArgs); + const component = useStore(({ adcm }) => adcm.serviceComponent.serviceComponent); + const props = useActionHostGroups('component', entityArgs, component?.concerns); return props; }; From 00d0ffacdcef0dd546697b4dcbcc19617e3e14af Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Wed, 28 Aug 2024 15:12:04 +0000 Subject: [PATCH 40/50] feature/ADCM-5914 change action host groups labels Task: https://tracker.yandex.ru/ADCM-5914 --- .../ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx | 2 +- .../CreateActionHostGroupDialog.tsx | 2 +- .../EditActionHostGroupDialog/EditActionHostGroupDialog.tsx | 2 +- .../ActionHostGroupsTableToolbar.tsx | 2 +- .../ClusterServiceNavigation/ClusterServiceNavigation.tsx | 4 ++-- .../ClusterActionHostGroups/ClusterActionHostGroups.tsx | 2 +- .../ClusterConfigurationsNavigation.tsx | 2 +- .../ComponentActionHostGroups/ComponentActionHostGroups.tsx | 2 +- .../ComponentConfigurationsNavigation.tsx | 2 +- adcm-web/app/src/routes/routes.ts | 6 +++--- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx index 7144988c3c..bbe3590961 100644 --- a/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx +++ b/adcm-web/app/src/components/common/ActionHostGroups/ActionHostGroupDialogs/ActionHostGroupDialogForm/ActionHostGroupDialogForm.tsx @@ -47,7 +47,7 @@ const ActionHostGroupDialogForm = ({ return (
- + {isCreateNew ? ( - + ); diff --git a/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx b/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx index 11c046e6ae..a2ad447cba 100644 --- a/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx +++ b/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx @@ -8,7 +8,7 @@ import { useLocation } from 'react-router-dom'; const tabsNavigationDictionary: { [key: string]: string } = { 'primary-configuration': 'Primary configuration', 'configuration-groups': 'Configuration groups', - 'action-hosts-groups': 'Action hosts groups', + 'action-hosts-groups': 'Action host groups', components: 'Components', info: 'Info', }; @@ -40,7 +40,7 @@ const ClusterServiceNavigation: React.FC = () => { Primary configuration Configuration groups - Action hosts groups + Action host groups Components Info diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx index de4c07cd78..56a2edd012 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterActionHostGroups/ClusterActionHostGroups.tsx @@ -24,7 +24,7 @@ const ClusterActionHostGroups = () => { { href: '/clusters', label: 'Clusters' }, { href: `/clusters/${cluster.id}`, label: cluster.name }, { href: `/clusters/${cluster.id}/configuration`, label: 'Configuration' }, - { label: 'Action hosts groups' }, + { label: 'Action host groups' }, ]), ); } diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx index e23e42e3b8..7bce699a1c 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx @@ -10,7 +10,7 @@ const ClusterConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups Ansible settings - Action hosts groups + Action host groups diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx index 4284874635..5ad3368c64 100644 --- a/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentActionHostGroups/ComponentActionHostGroups.tsx @@ -31,7 +31,7 @@ const ComponentActionHostGroups = () => { href: `/clusters/${cluster.id}/services/${service.id}/components/${component.id}`, label: component.displayName, }, - { label: 'Action hosts groups' }, + { label: 'Action host groups' }, ]), ); } diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx index 880412aef6..0159a9a390 100644 --- a/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx @@ -10,7 +10,7 @@ const ComponentConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups - Action hosts groups + Action host groups diff --git a/adcm-web/app/src/routes/routes.ts b/adcm-web/app/src/routes/routes.ts index a3f8705172..c0de623cc3 100644 --- a/adcm-web/app/src/routes/routes.ts +++ b/adcm-web/app/src/routes/routes.ts @@ -154,7 +154,7 @@ const routes: RoutesConfigs = { label: ':serviceId', }, { - label: 'Action hosts groups', + label: 'Action host groups', }, ], }, @@ -274,7 +274,7 @@ const routes: RoutesConfigs = { label: ':componentId', }, { - label: 'Action hosts groups', + label: 'Action host groups', }, ], }, @@ -465,7 +465,7 @@ const routes: RoutesConfigs = { label: 'Configuration', }, { - label: 'Action hosts groups', + label: 'Action host groups', }, ], }, From 0358930ec19dbf010f8a2c22ee27ccd01f0d1ba7 Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Thu, 29 Aug 2024 11:01:19 +0000 Subject: [PATCH 41/50] feature/ADCM-5914 fix action host groups url segment task: https://tracker.yandex.ru/ADCM-5914 --- adcm-web/app/src/App.tsx | 6 +++--- .../ClusterServiceNavigation/ClusterServiceNavigation.tsx | 4 ++-- .../ClusterConfigurationsNavigation.tsx | 2 +- .../ComponentConfigurationsNavigation.tsx | 2 +- adcm-web/app/src/routes/routes.ts | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/adcm-web/app/src/App.tsx b/adcm-web/app/src/App.tsx index 0a43dde5f0..d6e7c6875b 100644 --- a/adcm-web/app/src/App.tsx +++ b/adcm-web/app/src/App.tsx @@ -111,7 +111,7 @@ function App() { element={} /> } /> } /> } /> } /> } /> diff --git a/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx b/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx index a2ad447cba..e8952cc383 100644 --- a/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx +++ b/adcm-web/app/src/components/layouts/ClusterServiceLayout/ClusterServiceNavigation/ClusterServiceNavigation.tsx @@ -8,7 +8,7 @@ import { useLocation } from 'react-router-dom'; const tabsNavigationDictionary: { [key: string]: string } = { 'primary-configuration': 'Primary configuration', 'configuration-groups': 'Configuration groups', - 'action-hosts-groups': 'Action host groups', + 'action-host-groups': 'Action host groups', components: 'Components', info: 'Info', }; @@ -40,7 +40,7 @@ const ClusterServiceNavigation: React.FC = () => { Primary configuration Configuration groups - Action host groups + Action host groups Components Info diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx index 7bce699a1c..7dd0a2acaf 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx @@ -10,7 +10,7 @@ const ClusterConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups Ansible settings - Action host groups + Action host groups diff --git a/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx index 0159a9a390..4999dba9f8 100644 --- a/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/service/component/ComponentConfigurationsNavigation/ComponentConfigurationsNavigation.tsx @@ -10,7 +10,7 @@ const ComponentConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups - Action host groups + Action host groups diff --git a/adcm-web/app/src/routes/routes.ts b/adcm-web/app/src/routes/routes.ts index c0de623cc3..172269d1bd 100644 --- a/adcm-web/app/src/routes/routes.ts +++ b/adcm-web/app/src/routes/routes.ts @@ -134,7 +134,7 @@ const routes: RoutesConfigs = { }, ], }, - '/clusters/:clusterId/services/:serviceId/action-hosts-groups': { + '/clusters/:clusterId/services/:serviceId/action-host-groups': { pageTitle: 'Clusters', breadcrumbs: [ { @@ -246,7 +246,7 @@ const routes: RoutesConfigs = { }, ], }, - '/clusters/:clusterId/services/:serviceId/components/:componentId/action-hosts-groups': { + '/clusters/:clusterId/services/:serviceId/components/:componentId/action-host-groups': { pageTitle: 'Clusters', breadcrumbs: [ { @@ -450,7 +450,7 @@ const routes: RoutesConfigs = { }, ], }, - '/clusters/:clusterId/configuration/action-hosts-groups': { + '/clusters/:clusterId/configuration/action-host-groups': { pageTitle: 'Clusters', breadcrumbs: [ { From 374f073c2033610dbe58406d3a77d5805b9e506c Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 30 Aug 2024 04:35:26 +0000 Subject: [PATCH 42/50] Revert "Merge branch 'ADCM-5866' into 'develop'" This reverts merge request !4002 --- python/adcm/custom_loggers.py | 28 ---------------------------- python/adcm/settings.py | 2 +- 2 files changed, 1 insertion(+), 29 deletions(-) delete mode 100644 python/adcm/custom_loggers.py diff --git a/python/adcm/custom_loggers.py b/python/adcm/custom_loggers.py deleted file mode 100644 index 1f41b3516a..0000000000 --- a/python/adcm/custom_loggers.py +++ /dev/null @@ -1,28 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from logging.handlers import TimedRotatingFileHandler -from pathlib import Path -from tempfile import gettempdir -import fcntl - - -class LockingTimedRotatingFileHandler(TimedRotatingFileHandler): - def emit(self, record): - lock_file_path = Path(gettempdir(), Path(self.baseFilename).name).with_suffix(".lock") - - with lock_file_path.open(mode="wt", encoding="utf-8") as lock_file: - try: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - super().emit(record) - finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/python/adcm/settings.py b/python/adcm/settings.py index 39fe17cf71..449e852f22 100644 --- a/python/adcm/settings.py +++ b/python/adcm/settings.py @@ -270,7 +270,7 @@ def get_db_options() -> dict: "backupCount": 10, }, "audit_file_handler": { - "class": "adcm.custom_loggers.LockingTimedRotatingFileHandler", + "class": "logging.handlers.TimedRotatingFileHandler", "filename": LOG_DIR / "audit.log", "when": "midnight", "backupCount": 10, From 18456cc2fe69716da19e6f2ede59e384d26e7ed3 Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Fri, 30 Aug 2024 08:35:46 +0000 Subject: [PATCH 43/50] feature/ADCM-5964 improve config editing UX Task: https://tracker.yandex.ru/ADCM-5864 --- .../uikit/Button/Button.module.scss | 2 +- .../uikit/CodeEditor/CodeEditor.tsx | 21 ++++++++++----- .../uikit/CodeEditor/CodeEditorContent.tsx | 11 ++++++-- .../uikit/CodeEditor/CodeEditorTextArea.tsx | 4 ++- .../AddConfigurationFieldDialog.tsx | 2 ++ .../ConfigurationEditorDialog.tsx | 2 +- .../EditConfigurationFieldDialog.tsx | 1 + .../Dialogs/FieldControls/BooleanControl.tsx | 10 ++++++- .../FieldControls/ConfigurationField.tsx | 1 + .../Dialogs/FieldControls/NumberControl.tsx | 12 +++++++-- .../StringControls/MultilineStringControl.tsx | 9 +++++++ .../StringControls/SecretControl.tsx | 26 ++++++++++++++++--- .../StringControls/StringControl.tsx | 12 +++++++-- .../uikit/InputPassword/InputPassword.tsx | 1 + 14 files changed, 95 insertions(+), 19 deletions(-) diff --git a/adcm-web/app/src/components/uikit/Button/Button.module.scss b/adcm-web/app/src/components/uikit/Button/Button.module.scss index b712debe99..74a2aced4a 100644 --- a/adcm-web/app/src/components/uikit/Button/Button.module.scss +++ b/adcm-web/app/src/components/uikit/Button/Button.module.scss @@ -126,7 +126,7 @@ border: 2px solid var(--button-border); white-space: nowrap; - &:hover { + &:hover, &:focus { background: var(--button-background-hover); border-color: var(--button-border-hover); color: var(--button-color-hover); diff --git a/adcm-web/app/src/components/uikit/CodeEditor/CodeEditor.tsx b/adcm-web/app/src/components/uikit/CodeEditor/CodeEditor.tsx index 90e2a3fa90..316ddac73d 100644 --- a/adcm-web/app/src/components/uikit/CodeEditor/CodeEditor.tsx +++ b/adcm-web/app/src/components/uikit/CodeEditor/CodeEditor.tsx @@ -12,6 +12,7 @@ export interface CodeEditorProps { className?: string; isReadonly?: boolean; onChange: (code: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; } interface CodeProviderInterface extends CodeEditorProps { @@ -20,20 +21,28 @@ interface CodeProviderInterface extends CodeEditorProps { const CodeCtx = createContext({} as CodeEditorProps); -const CodeProvider = ({ children, code, language, isReadonly, onChange }: CodeProviderInterface) => { - const codeData = { code, language, isReadonly, onChange }; +const CodeProvider = ({ children, code, language, isReadonly, onChange, onKeyDown }: CodeProviderInterface) => { + const codeData = { code, language, isReadonly, onChange, onKeyDown }; return {children}; }; const TagWithContext: React.FC<{ children: HighlighterChildType }> = ({ children }) => { - const { code, isReadonly, onChange } = useContext(CodeCtx); + const { code, isReadonly, onChange, onKeyDown } = useContext(CodeCtx); - return ; + return ( + + ); }; -const CodeEditor = ({ code, language, isSecret, className, isReadonly, onChange }: CodeEditorProps) => { +const CodeEditor = ({ code, language, isSecret, className, isReadonly, onChange, onKeyDown }: CodeEditorProps) => { return ( - +
diff --git a/adcm-web/app/src/components/uikit/CodeEditor/CodeEditorContent.tsx b/adcm-web/app/src/components/uikit/CodeEditor/CodeEditorContent.tsx index 8b782ae82c..18392994b1 100644 --- a/adcm-web/app/src/components/uikit/CodeEditor/CodeEditorContent.tsx +++ b/adcm-web/app/src/components/uikit/CodeEditor/CodeEditorContent.tsx @@ -8,9 +8,10 @@ export interface CodeEditorContentProps { children: HighlighterChildType; isReadonly?: boolean; onChange: (code: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; } -const CodeEditorContent = ({ code, children, isReadonly, onChange }: CodeEditorContentProps) => { +const CodeEditorContent = ({ code, children, isReadonly, onChange, onKeyDown }: CodeEditorContentProps) => { const [, childArray] = children; const rowCount = childArray.length || 1; @@ -20,7 +21,13 @@ const CodeEditorContent = ({ code, children, isReadonly, onChange }: CodeEditorC + } />
diff --git a/adcm-web/app/src/components/uikit/CodeEditor/CodeEditorTextArea.tsx b/adcm-web/app/src/components/uikit/CodeEditor/CodeEditorTextArea.tsx index c801c32354..95a6c1c26f 100644 --- a/adcm-web/app/src/components/uikit/CodeEditor/CodeEditorTextArea.tsx +++ b/adcm-web/app/src/components/uikit/CodeEditor/CodeEditorTextArea.tsx @@ -8,9 +8,10 @@ interface CodeEditorTextAreaProps { rowCount: number; isReadonly?: boolean; onChange: (code: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; } -const CodeEditorTextArea = ({ code, rowCount, isReadonly, onChange }: CodeEditorTextAreaProps) => { +const CodeEditorTextArea = ({ code, rowCount, isReadonly, onChange, onKeyDown }: CodeEditorTextAreaProps) => { const longestRow = code.split('\n').reduce((longest, row) => { return longest < row.length ? row.length : longest; }, 0); @@ -23,6 +24,7 @@ const CodeEditorTextArea = ({ code, rowCount, isReadonly, onChange }: CodeEditor autoCapitalize="off" onKeyDown={(event) => { shortcuts(event); + onKeyDown?.(event); }} rows={rowCount} cols={longestRow} diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/AddConfigurationFieldDialog/AddConfigurationFieldDialog.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/AddConfigurationFieldDialog/AddConfigurationFieldDialog.tsx index 8649331450..17e8e0b397 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/AddConfigurationFieldDialog/AddConfigurationFieldDialog.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/AddConfigurationFieldDialog/AddConfigurationFieldDialog.tsx @@ -75,6 +75,7 @@ const AddConfigurationFieldDialog = ({ fieldSchema={node.data.fieldSchema} isReadonly={false} onChange={handleValueChange} + onApply={handleApply} /> ) : ( )}
diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/ConfigurationEditorDialog/ConfigurationEditorDialog.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/ConfigurationEditorDialog/ConfigurationEditorDialog.tsx index 40051aab13..e06ff751c6 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/ConfigurationEditorDialog/ConfigurationEditorDialog.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/ConfigurationEditorDialog/ConfigurationEditorDialog.tsx @@ -28,7 +28,7 @@ const ConfigurationEditorDialog = ({
{children}
-
diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/NumberControl.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/NumberControl.tsx index d5d4a7606b..f7c0cdf562 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/NumberControl.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/NumberControl.tsx @@ -9,9 +9,10 @@ export interface NumberControlProps { fieldSchema: SingleSchemaDefinition; isReadonly: boolean; onChange: (value: JSONPrimitive) => void; + onApply: () => void; } -const NumberControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: NumberControlProps) => { +const NumberControl = ({ fieldName, fieldSchema, value, isReadonly, onChange, onApply }: NumberControlProps) => { const handleChange = (e: React.ChangeEvent) => { if (e.target.value === '') { onChange(''); @@ -20,14 +21,21 @@ const NumberControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: } }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + onApply(); + } + }; + return ( ); diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx index 0c03c2dabd..b20bb3da0c 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx @@ -16,6 +16,7 @@ interface MultilineStringControlProps { fieldSchema: SingleSchemaDefinition; isReadonly: boolean; onChange: (newValue: JSONPrimitive, isValid?: boolean) => void; + onApply: () => void; } const MultilineStringControl = ({ @@ -24,6 +25,7 @@ const MultilineStringControl = ({ fieldSchema, isReadonly, onChange, + onApply, }: MultilineStringControlProps) => { const stringValue = value?.toString() ?? ''; const format = fieldSchema.format ?? 'text'; @@ -49,6 +51,12 @@ const MultilineStringControl = ({ onChange(code, error === undefined); }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.code === 'Enter' && event.ctrlKey) { + onApply(); + } + }; + return ( ); diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/SecretControl.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/SecretControl.tsx index 79b31bb9d1..6c3f1682e4 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/SecretControl.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/SecretControl.tsx @@ -13,9 +13,10 @@ export interface StringControlProps { fieldSchema: SingleSchemaDefinition; isReadonly: boolean; onChange: (value: JSONPrimitive, isValid?: boolean) => void; + onApply: () => void; } -const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: StringControlProps) => { +const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange, onApply }: StringControlProps) => { const [secret, setSecret] = useState(value as string); const [confirm, setConfirm] = useState(value as string); const [secretError, setSecretError] = useState(undefined); @@ -43,6 +44,13 @@ const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: setConfirm(defaultValue as string); }; + const handleKeyDown = (event: React.KeyboardEvent) => { + const areEqual = secret === confirm; + if (areEqual && event.key === 'Enter') { + onApply(); + } + }; + return (
- + {!isReadonly && ( - + )}
diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControl.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControl.tsx index ca30b711dc..b0533ba104 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControl.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControl.tsx @@ -12,9 +12,10 @@ export interface StringControlProps { fieldSchema: SingleSchemaDefinition; isReadonly: boolean; onChange: (value: JSONPrimitive, isValid?: boolean) => void; + onApply: () => void; } -const StringControl = ({ fieldName, value, fieldSchema, isReadonly, onChange }: StringControlProps) => { +const StringControl = ({ fieldName, value, fieldSchema, isReadonly, onChange, onApply }: StringControlProps) => { const [error, setError] = useState(undefined); const handleChange = (event: React.ChangeEvent) => { @@ -24,6 +25,12 @@ const StringControl = ({ fieldName, value, fieldSchema, isReadonly, onChange }: onChange(event.target.value, error === undefined); }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (error === undefined && event.key === 'Enter') { + onApply(); + } + }; + const stringValue = value as string; return ( @@ -40,9 +47,10 @@ const StringControl = ({ fieldName, value, fieldSchema, isReadonly, onChange }: disabled={isReadonly} suggestions={fieldSchema.adcmMeta.stringExtra.suggestions} onChange={handleChange} + onKeyDown={handleKeyDown} /> ) : ( - + )} ); diff --git a/adcm-web/app/src/components/uikit/InputPassword/InputPassword.tsx b/adcm-web/app/src/components/uikit/InputPassword/InputPassword.tsx index a5e74e7161..0d13397bc6 100644 --- a/adcm-web/app/src/components/uikit/InputPassword/InputPassword.tsx +++ b/adcm-web/app/src/components/uikit/InputPassword/InputPassword.tsx @@ -48,6 +48,7 @@ const InputPassword = React.forwardRef( size={20} onClick={toggleShowPassword} disabled={props.disabled} + tabIndex={-1} /> } /> From 59c36011acbb665a88c93d19051f48fc5f88dbeb Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Fri, 30 Aug 2024 12:44:35 +0300 Subject: [PATCH 44/50] bugfix/ADCM-5784 + missed step numbers --- ...micActionAgreeActionHostsGroup.module.scss | 5 ++ .../DynamicActionAgreeActionHostsGroup.tsx | 7 +- .../DynamicActionSteps/DynamicActionSteps.tsx | 2 +- .../uikit/ScrollBar/Scrollbar.module.scss | 2 +- .../uikit/Switch/Switch.module.scss | 4 +- .../uikit/WizardSteps/WizardSteps.module.scss | 77 ++----------------- .../uikit/WizardSteps/WizardSteps.tsx | 6 +- adcm-web/app/src/scss/vars.scss | 4 + 8 files changed, 31 insertions(+), 76 deletions(-) diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.module.scss b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.module.scss index 55f6f47df9..ebdb55bff9 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.module.scss +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.module.scss @@ -15,6 +15,11 @@ &__filter { display: flex; gap: 12px; + flex: 1; + + .searchInput { + width: 100%; + } } &__dynamicActionAgreeActionHostsGroup__content { diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.tsx index b9fd8793c0..65c46165df 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionAgreeActionHostsGroup/DynamicActionAgreeActionHostsGroup.tsx @@ -48,7 +48,12 @@ const DynamicActionAgreeActionHostsGroup = ({
- +
diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps.tsx index 76a7a84777..4383e00289 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionSteps/DynamicActionSteps.tsx @@ -14,7 +14,7 @@ const stepsTitles: Record = { [DynamicActionStep.AgreeActionHostsGroup]: 'Hosts group', [DynamicActionStep.ConfigSchema]: 'Configuration', [DynamicActionStep.HostComponentMapping]: 'Host - Component', - [DynamicActionStep.Confirm]: 'Confirm', + [DynamicActionStep.Confirm]: 'Confirmation', }; interface DynamicActionsStepsProps { diff --git a/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss b/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss index 18f850cc12..a3c017f24d 100644 --- a/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss +++ b/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss @@ -32,7 +32,7 @@ body { } .defaultThumb { - background: rgba(127, 130, 133, 0.2); + background: var(--color-plate-20); width: 100%; height: 100%; border-radius: 4px; diff --git a/adcm-web/app/src/components/uikit/Switch/Switch.module.scss b/adcm-web/app/src/components/uikit/Switch/Switch.module.scss index d7a4c29ce9..c3ff2ed4ca 100644 --- a/adcm-web/app/src/components/uikit/Switch/Switch.module.scss +++ b/adcm-web/app/src/components/uikit/Switch/Switch.module.scss @@ -31,9 +31,9 @@ --switch-blue-on-background: var(--color-xblue); --switch-blue-on-background-hover: var(--color-teal); - --switch-off-background: rgba(127, 130, 133, 0.2); + --switch-off-background: var(--color-plate-20); --switch-off-button-background: var(--color-xgray-light); - --switch-off-background-hover: rgba(127, 130, 133, 0.4); + --switch-off-background-hover: var(--color-plate-40); --switch-off-button-background-hover: var(--color-xgray-light); --switch-disabled-background: var(--color-xdark-plate20); diff --git a/adcm-web/app/src/components/uikit/WizardSteps/WizardSteps.module.scss b/adcm-web/app/src/components/uikit/WizardSteps/WizardSteps.module.scss index daa4c3ce01..11c3c91a69 100644 --- a/adcm-web/app/src/components/uikit/WizardSteps/WizardSteps.module.scss +++ b/adcm-web/app/src/components/uikit/WizardSteps/WizardSteps.module.scss @@ -1,85 +1,22 @@ :global { body.theme-dark { - --wizard-step-color-default: var(--color-xgray-light); - --wizard-step-border-default: transparent; - --wizard-step-background-default: transparent; - --wizard-step-marker-background-default: var(--color-xdark); - - // disabled - --wizard-step-color-disabled: var(--color-all-new-black); - --wizard-step-border-disabled: transparent; - --wizard-step-background-disabled: transparent; - --wizard-step-marker-background-disabled: var(--color-xdark-plate10); - - // active - --wizard-step-color-active: var(--color-xgreen-saturated); - --wizard-step-border-active: var(--color-xdark); - --wizard-step-background-active: var(--color-new-light); - - // error - --wizard-step-color-error: var(--color-xred); - --wizard-step-border-error: var(--color-red-10); - --wizard-step-background-error: var(--color-red-10); - --wizard-step-marker-background-error: var(--color-red-10); + --wizard-step--stem-number-background-color: var(--color-plate-10); } body.theme-light { - --wizard-step-marker-color: var(--color-adcmx); - --wizard-step-marker-background: #EEF2F5; - - --wizard-step-marker-color-active: var(--color-xdark); - --wizard-step-marker-background-active: var(--color-popup-light); + --wizard-step--stem-number-background-color: var(--color-popup-light); } } .wizardSteps { - counter-reset: step; - display: flex; - gap: 12px; - - &__item { - padding: 5px 11px 5px 5px; - font-weight: 500; - font-size: 15px; - line-height: 20px; + .tabButton { display: flex; - align-items: center; - border-radius: 4px; - border: 1px solid var(--wizard-step-border, var(--wizard-step-border-default)); - background: var(--wizard-step-background, var(--wizard-step-background-default)); - color: var(--wizard-step-color, var(--wizard-step-color-default)); - cursor: pointer; + gap: 6px; - &::before { - content: counter(step); - counter-increment: step; - border-radius: 50px; - background: var(--wizard-step-marker-background, var(--wizard-step-marker-background-default)); + &__stepNumber { width: 20px; - line-height: 20px; - text-align: center; - margin-right: 6px; - font-size: 13px; - } - - &:global(.is-active) { - --wizard-step-border: var(--wizard-step-border-active); - --wizard-step-background: var(--wizard-step-background-active); - --wizard-step-color: var(--wizard-step-color-active); - } - - &:global(.is-disabled) { - --wizard-step-border: var(--wizard-step-border-disabled); - --wizard-step-background: var(--wizard-step-background-disabled); - --wizard-step-color: var(--wizard-step-color-disabled); - cursor: not-allowed; - } - - &:global(.has-error) { - --wizard-step-border: var(--wizard-step-border-error); - --wizard-step-background: var(--wizard-step-background-error); - --wizard-step-color: var(--wizard-step-color-error); - --wizard-step-marker-background: var(--wizard-step-marker-background-error); + border-radius: 20px; + background-color: var(--wizard-step--stem-number-background-color); } } } diff --git a/adcm-web/app/src/components/uikit/WizardSteps/WizardSteps.tsx b/adcm-web/app/src/components/uikit/WizardSteps/WizardSteps.tsx index eaccd133e5..eda4ebdaba 100644 --- a/adcm-web/app/src/components/uikit/WizardSteps/WizardSteps.tsx +++ b/adcm-web/app/src/components/uikit/WizardSteps/WizardSteps.tsx @@ -4,6 +4,8 @@ import { TabsBlock } from '@uikit'; import { TabsBlockProps } from '@uikit/Tabs/TabsBlock'; import { WizardStep } from '@uikit/WizardSteps/WizardSteps.types'; import { getStepIndex } from '@uikit/WizardSteps/WizardSteps.utils'; +import s from './WizardSteps.module.scss'; +import cn from 'classnames'; interface WizardStepProps extends Omit { steps: WizardStep[]; @@ -19,15 +21,17 @@ const WizardSteps: React.FC = ({ steps, currentStep, onChangeSt }; return ( - + {steps.map((step, index) => ( getStepIndex(currentStep, steps)} > + {index + 1} {step.title} ))} diff --git a/adcm-web/app/src/scss/vars.scss b/adcm-web/app/src/scss/vars.scss index c2a5453d5a..bd9efa587a 100644 --- a/adcm-web/app/src/scss/vars.scss +++ b/adcm-web/app/src/scss/vars.scss @@ -73,6 +73,10 @@ --color-xwhite-off: #FCFCFD; --color-white-background-50: #EEF2F5; + --color-plate-10: rgba(127, 130, 133, 0.10); + --color-plate-20: rgba(127, 130, 133, 0.20); + --color-plate-40: rgba(127, 130, 133, 0.40); + --color-orangex: #FFCC47; From cff8ed211b9104f082c4988270c707ffbe331282 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Fri, 30 Aug 2024 14:13:07 +0000 Subject: [PATCH 45/50] ADCM-5873: Host not removed from ActionHostsGroup when removed from cluster --- .../ansible_plugin/executors/hostcomponent.py | 8 + python/ansible_plugin/tests/test_adcm_hc.py | 58 ++++- python/api_v2/cluster/utils.py | 36 ++- python/api_v2/tests/test_action_host_group.py | 215 ++++++++++++++++++ python/cm/api.py | 3 + python/core/cluster/types.py | 34 +++ 6 files changed, 352 insertions(+), 2 deletions(-) diff --git a/python/ansible_plugin/executors/hostcomponent.py b/python/ansible_plugin/executors/hostcomponent.py index 6622cfc1a7..b01af77e0c 100644 --- a/python/ansible_plugin/executors/hostcomponent.py +++ b/python/ansible_plugin/executors/hostcomponent.py @@ -12,8 +12,10 @@ from typing import Any, Collection, Literal +from api_v2.cluster.utils import handle_mapping_action_host_groups from cm.api import add_hc, get_hc from cm.models import Cluster, Host, JobLog, ServiceComponent +from cm.services.cluster import retrieve_clusters_topology from core.types import ADCMCoreType, CoreObjectDescriptor from pydantic import field_validator @@ -85,6 +87,9 @@ def __call__( raise PluginIncorrectCallError(message="You can not change hc in plugin for action with hc_acl") cluster = Cluster.objects.get(id=runtime.vars.context.cluster_id) + + original_topology = next(retrieve_clusters_topology(cluster_ids=(cluster.id,))) + hostcomponent = get_hc(cluster) for operation in arguments.operations: component_id, service_id = ServiceComponent.objects.values_list("id", "service_id").get( @@ -122,4 +127,7 @@ def __call__( add_hc(cluster, hostcomponent) + updated_topology = next(retrieve_clusters_topology(cluster_ids=(cluster.id,))) + handle_mapping_action_host_groups(mapping_delta=original_topology - updated_topology) + return CallResult(value=None, changed=True, error=None) diff --git a/python/ansible_plugin/tests/test_adcm_hc.py b/python/ansible_plugin/tests/test_adcm_hc.py index 99f8bde0be..99ac3e2dd6 100644 --- a/python/ansible_plugin/tests/test_adcm_hc.py +++ b/python/ansible_plugin/tests/test_adcm_hc.py @@ -13,8 +13,10 @@ from operator import itemgetter from cm.converters import orm_object_to_core_type -from cm.models import HostComponent, ServiceComponent +from cm.models import ActionHostGroup, HostComponent, ServiceComponent +from cm.services.action_host_group import ActionHostGroupRepo, ActionHostGroupService, CreateDTO from cm.services.job.run.repo import JobRepoImpl +from core.types import CoreObjectDescriptor from ansible_plugin.errors import PluginContextError, PluginIncorrectCallError, PluginRuntimeError from ansible_plugin.executors.hostcomponent import ADCMHostComponentPluginExecutor @@ -250,6 +252,47 @@ def test_add_already_existing_fail(self) -> None: ) self.assertEqual(self.get_current_hc_dicts(), expected_hc) + def test_remove_host_with_action_group_called_success(self) -> None: + service_2 = self.add_services_to_cluster(["service_2"], cluster=self.cluster).first() + component_2 = ServiceComponent.objects.filter(service=service_2).first() + self.set_hostcomponent( + cluster=self.cluster, + entries=( + (self.host_1, self.component_1), + (self.host_2, component_2), + ), + ) + + action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + + action_group = self.create_action_host_group(action_host_group_service, "Component Group", self.component_1) + action_group_2 = self.create_action_host_group(action_host_group_service, "Component Group 2", component_2) + + action_host_group_service.add_hosts_to_group(group_id=action_group.id, hosts=[self.host_1.id]) + action_host_group_service.add_hosts_to_group(group_id=action_group_2.id, hosts=[self.host_2.id]) + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments=f""" + operations: + - action: remove + service: {self.service_1.name} + component: {self.component_1.name} + host: {self.host_1.fqdn} + """, + call_context=job, + ) + + result = executor.execute() + + self.assertIsNone(result.error) + + self.assertEqual(action_group.hosts.count(), 0) + self.assertEqual(action_group_2.hosts.count(), 1) + def test_remove_absent_fail(self) -> None: object_ = self.component_1 self.set_hostcomponent( @@ -280,3 +323,16 @@ def test_remove_absent_fail(self) -> None: result.error.message, f'There is no component "{self.component_1.name}" on host "{self.host_2.fqdn}"' ) self.assertEqual(self.get_current_hc_dicts(), expected_hc) + + def create_action_host_group( + self, action_host_group_service: ActionHostGroupService, name: str, component: ServiceComponent + ) -> ActionHostGroup: + return ActionHostGroup.objects.get( + id=action_host_group_service.create( + CreateDTO( + name=name, + owner=CoreObjectDescriptor(id=component.id, type=orm_object_to_core_type(component)), + description="description", + ) + ) + ) diff --git a/python/api_v2/cluster/utils.py b/python/api_v2/cluster/utils.py index a5672318e6..64cf531876 100644 --- a/python/api_v2/cluster/utils.py +++ b/python/api_v2/cluster/utils.py @@ -11,7 +11,9 @@ # limitations under the License. from collections import defaultdict +from functools import reduce from itertools import chain +from operator import or_ from typing import Literal from cm.data_containers import ( @@ -33,6 +35,7 @@ update_issues_and_flags_after_deleting, ) from cm.models import ( + ActionHostGroup, Cluster, ClusterObject, GroupConfig, @@ -43,11 +46,13 @@ Prototype, ServiceComponent, ) +from cm.services.cluster import retrieve_clusters_topology from cm.services.concern.locks import get_lock_on_object from cm.services.status.notify import reset_hc_map, reset_objects_in_mm from cm.status_api import send_host_component_map_update_event +from core.cluster.types import MovedHosts from django.contrib.contenttypes.models import ContentType -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from django.db.transaction import atomic, on_commit from rbac.models import Policy from rest_framework.status import HTTP_409_CONFLICT @@ -255,6 +260,8 @@ def _check_mapping_data(mapping_data: MappingData) -> None: @atomic def _save_mapping(mapping_data: MappingData) -> QuerySet[HostComponent]: + original_topology = next(retrieve_clusters_topology(cluster_ids=(mapping_data.cluster.id,))) + on_commit(func=reset_hc_map) on_commit(func=reset_objects_in_mm) @@ -284,6 +291,10 @@ def _save_mapping(mapping_data: MappingData) -> QuerySet[HostComponent]: HostComponent.objects.filter(cluster_id=mapping_data.cluster.id).delete() HostComponent.objects.bulk_create(objs=mapping_objects) + updated_topology = next(retrieve_clusters_topology(cluster_ids=(mapping_data.cluster.id,))) + + handle_mapping_action_host_groups(mapping_delta=original_topology - updated_topology) + update_hierarchy_issues(obj=mapping_data.orm_objects["cluster"]) for provider_id in {host.provider_id for host in mapping_data.hosts.values()}: update_hierarchy_issues(obj=mapping_data.orm_objects["providers"][provider_id]) @@ -311,6 +322,29 @@ def _handle_mapping_config_groups(mapping_data: MappingData) -> None: group_config.hosts.remove(*removed_hosts_not_in_mapping) +def handle_mapping_action_host_groups(mapping_delta: MovedHosts) -> None: + select_predicates = [] + for service_id, hosts in mapping_delta.services.items(): + select_predicates.append( + Q( + host_id__in=hosts, + actionhostgroup__object_id=service_id, + actionhostgroup__object_type=ContentType.objects.get_for_model(ClusterObject), + ) + ) + for component_id, hosts in mapping_delta.components.items(): + select_predicates.append( + Q( + host_id__in=hosts, + actionhostgroup__object_id=component_id, + actionhostgroup__object_type=ContentType.objects.get_for_model(ServiceComponent), + ) + ) + + if len(select_predicates) > 0: + ActionHostGroup.hosts.through.objects.filter(reduce(or_, select_predicates)).delete() + + def _handle_mapping_policies(mapping_data: MappingData) -> None: service_ids_in_mappings: set[int] = set( chain( diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py index 30a6297c06..61f2b384ff 100644 --- a/python/api_v2/tests/test_action_host_group.py +++ b/python/api_v2/tests/test_action_host_group.py @@ -189,6 +189,221 @@ def test_delete_success(self) -> None: **self.prepare_audit_object_arguments(expected_object=target), ) + def test_unlink_host_from_component_success(self) -> None: + service_2 = self.add_services_to_cluster(["second"], cluster=self.cluster).get() + + component_2 = self.service.servicecomponent_set.last() + component_3 = service_2.servicecomponent_set.last() + self.hosts += [ + self.add_host(provider=self.hostprovider, fqdn=f"host-{i}", cluster=self.cluster) for i in range(3, 6) + ] + + self.set_hostcomponent( + cluster=self.cluster, + entries=( + (self.hosts[0], self.component), + (self.hosts[1], self.component), + (self.hosts[3], component_2), + (self.hosts[4], component_2), + (self.hosts[5], component_2), + (self.hosts[0], component_3), + (self.hosts[1], component_3), + (self.hosts[2], component_3), + ), + ) + + component_group = self.create_action_host_group(name="Component Group", owner=self.component) + component_group_2 = self.create_action_host_group(name="Component Group 2", owner=component_2) + component_group_3 = self.create_action_host_group(name="Component Group 3", owner=component_3) + self.action_host_group_service.add_hosts_to_group( + group_id=component_group.id, hosts=[self.hosts[0].id, self.hosts[1].id] + ) + + self.action_host_group_service.add_hosts_to_group( + group_id=component_group_2.id, hosts=[self.hosts[3].id, self.hosts[4].id, self.hosts[5].id] + ) + + self.action_host_group_service.add_hosts_to_group( + group_id=component_group_3.id, hosts=[self.hosts[0].id, self.hosts[1].id, self.hosts[2].id] + ) + + response = self.client.v2[self.cluster, "mapping"].post( + data=[ + {"hostId": self.hosts[4].id, "componentId": component_2.pk}, + {"hostId": self.hosts[5].id, "componentId": component_2.pk}, + {"hostId": self.hosts[0].id, "componentId": component_3.pk}, + {"hostId": self.hosts[1].id, "componentId": component_3.pk}, + {"hostId": self.hosts[2].id, "componentId": component_3.pk}, + ] + ) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + self.assertEqual( + ActionHostGroup.objects.filter( + object_id__in=(self.component.id, component_2.id, component_3.id), + object_type=self.component.content_type, + ).count(), + 3, + ) + self.assertEqual( + component_group.hosts.count(), + 0, + ) + + self.assertEqual( + component_group_2.hosts.count(), + 2, + ) + + self.assertEqual( + component_group_3.hosts.count(), + 3, + ) + + def test_move_host_to_another_component_success(self) -> None: + service_2 = self.add_services_to_cluster(["second"], cluster=self.cluster).get() + component_1 = service_2.servicecomponent_set.first() + component_2 = service_2.servicecomponent_set.last() + + self.set_hostcomponent( + cluster=self.cluster, + entries=( + (self.hosts[0], self.component), + (self.hosts[1], self.component), + (self.hosts[2], self.component), + (self.hosts[0], component_1), + (self.hosts[1], component_1), + (self.hosts[2], component_1), + (self.hosts[0], component_2), + ), + ) + + service_group = self.create_action_host_group(name="Service Group", owner=self.service) + service_group_2 = self.create_action_host_group(name="Service Group 2", owner=service_2) + + component_group = self.create_action_host_group(name="Component Group", owner=self.component) + component_group_2 = self.create_action_host_group(name="Component Group 2", owner=component_1) + component_group_3 = self.create_action_host_group(name="Component Group 3", owner=component_2) + + for group in [service_group, service_group_2, component_group, component_group_2]: + self.action_host_group_service.add_hosts_to_group( + group_id=group.id, hosts=[self.hosts[0].id, self.hosts[1].id, self.hosts[2].id] + ) + + self.action_host_group_service.add_hosts_to_group(group_id=component_group_3.id, hosts=[self.hosts[0].id]) + + response = self.client.v2[self.cluster, "mapping"].post( + data=[ + {"hostId": self.hosts[2].id, "componentId": self.component.pk}, + {"hostId": self.hosts[1].id, "componentId": self.component.pk}, + {"hostId": self.hosts[2].id, "componentId": component_1.pk}, + {"hostId": self.hosts[0].id, "componentId": component_1.pk}, + ] + ) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + for group in [service_group, component_group]: + self.assertListEqual( + list(group.hosts.order_by("id")), + [self.hosts[1], self.hosts[2]], + ) + + for group in [service_group_2, component_group_2]: + self.assertListEqual( + list(group.hosts.order_by("id")), + [self.hosts[0], self.hosts[2]], + ) + + self.assertEqual(component_group_3.hosts.count(), 0) + + def test_unlink_hosts_from_correct_service_success(self) -> None: + service_2 = self.add_services_to_cluster(["second"], cluster=self.cluster).get() + component_2 = service_2.servicecomponent_set.last() + self.set_hostcomponent( + cluster=self.cluster, + entries=( + (self.hosts[0], self.component), + (self.hosts[1], self.component), + (self.hosts[0], component_2), + (self.hosts[1], component_2), + ), + ) + + component_group = self.create_action_host_group(name="Component Group", owner=self.component) + component_group_2 = self.create_action_host_group(name="Component Group 2", owner=component_2) + + self.action_host_group_service.add_hosts_to_group( + group_id=component_group.id, hosts=[self.hosts[0].id, self.hosts[1].id] + ) + + self.action_host_group_service.add_hosts_to_group( + group_id=component_group_2.id, hosts=[self.hosts[0].id, self.hosts[1].id] + ) + + response = self.client.v2[self.cluster, "mapping"].post( + data=[ + {"hostId": self.hosts[0].id, "componentId": self.component.pk}, + {"hostId": self.hosts[1].id, "componentId": component_2.pk}, + ] + ) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + self.assertListEqual( + list(component_group.hosts.all()), + [self.hosts[0]], + ) + + self.assertListEqual( + list(component_group_2.hosts.all()), + [self.hosts[1]], + ) + + def test_unlink_host_from_cluster_service_and_component_success(self) -> None: + self.set_hostcomponent( + cluster=self.cluster, entries=((self.hosts[0], self.component), (self.hosts[1], self.component)) + ) + + cluster_group = self.create_action_host_group(name="Cluster Group", owner=self.cluster) + self.action_host_group_service.add_hosts_to_group(group_id=cluster_group.id, hosts=[self.hosts[0].id]) + service_group_1 = self.create_action_host_group(name="Service Group", owner=self.service) + self.create_action_host_group(name="Service Group #2", owner=self.service) + component_group = self.create_action_host_group(name="Component Group", owner=self.component) + self.action_host_group_service.add_hosts_to_group( + group_id=component_group.id, hosts=[self.hosts[0].id, self.hosts[1].id] + ) + + response = self.client.v2[self.cluster, "mapping"].post(data=[]) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + with self.subTest(msg="hosts are UNMAPPED FROM THE CLUSTER SUCCESS"): + for target, group, groups_left_amount in ( + (self.service, service_group_1, 2), + (self.component, component_group, 1), + ): + self.assertEqual( + ActionHostGroup.objects.filter(object_id=target.id, object_type=target.content_type).count(), + groups_left_amount, + ) + self.assertEqual( + group.hosts.count(), + 0, + ) + self.assertEqual(cluster_group.hosts.count(), 1) + with self.subTest(msg="host is REMOVED FROM THE CLUSTER SUCCESS"): + response = self.client.v2[self.cluster, "hosts", self.hosts[0]].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + self.assertEqual( + ActionHostGroup.objects.filter( + object_id=self.cluster.id, object_type=self.cluster.content_type + ).count(), + 1, + ) + self.assertEqual( + cluster_group.hosts.count(), + 0, + ) + def test_retrieve_success(self) -> None: name = "aWeSOME Group NAmE" host_1, host_2, host_3, *_ = self.hosts diff --git a/python/cm/api.py b/python/cm/api.py index 0cf780ac18..8011967e49 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -46,6 +46,7 @@ from cm.logger import logger from cm.models import ( ADCM, + ActionHostGroup, ADCMEntity, AnsibleConfig, Cluster, @@ -277,6 +278,8 @@ def remove_host_from_cluster(host: Host) -> Host: return raise_adcm_ex(code="HOST_CONFLICT", msg="It is forbidden to delete host from cluster in upgrade mode") with atomic(): + # As the host is bounded to a certain cluster it is safe to delete all relations at once + ActionHostGroup.hosts.through.objects.filter(host_id=host.id).delete() host.maintenance_mode = MaintenanceMode.OFF host.cluster = None host.save() diff --git a/python/core/cluster/types.py b/python/core/cluster/types.py index 7b4fa0368f..242d40ce36 100644 --- a/python/core/cluster/types.py +++ b/python/core/cluster/types.py @@ -14,6 +14,8 @@ from itertools import chain from typing import Generator, NamedTuple +from typing_extensions import Self + from core.types import ClusterID, ComponentID, HostID, ServiceID, ShortObjectInfo @@ -41,6 +43,11 @@ def host_ids(self) -> Generator[HostID, None, None]: return chain.from_iterable(component.hosts for component in self.components.values()) +class MovedHosts(NamedTuple): + services: dict[ServiceID, set[HostID]] + components: dict[ComponentID, set[HostID]] + + class ClusterTopology(NamedTuple): cluster_id: ClusterID services: dict[ServiceID, ServiceTopology] @@ -50,6 +57,33 @@ class ClusterTopology(NamedTuple): def component_ids(self) -> Generator[ComponentID, None, None]: return chain.from_iterable(service.components for service in self.services.values()) + def __sub__(self, previous: Self) -> MovedHosts: + """Returns unmapped hosts that were in the previous and removed from self""" + services_diff, components_diff = {}, {} + for service_id, service_topology in previous.services.items(): + new_service_topology = self.services.get(service_id) + if not new_service_topology: + if host_diff := set(service_topology.host_ids): + services_diff[service_id] = host_diff + continue + + host_diff = set(new_service_topology.host_ids) - set(service_topology.host_ids) + if host_diff: + services_diff[service_id] = host_diff + + for component_id, component_topology in service_topology.components.items(): + new_component_topology = new_service_topology.components.get(component_id) + if not new_component_topology: + if host_diff := set(service_topology.host_ids): + components_diff[service_id] = host_diff + continue + + host_diff = set(new_component_topology.hosts) - set(component_topology.hosts) + if host_diff: + components_diff[component_id] = host_diff + + return MovedHosts(services=services_diff, components=components_diff) + class ObjectMaintenanceModeState(Enum): ON = "on" From 9885ecb857224a8864cd25c2b514dc2f52e43524 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 2 Sep 2024 09:02:39 +0000 Subject: [PATCH 46/50] ADCM-5922 Rework `find_hosts_difference` --- .../ansible_plugin/executors/hostcomponent.py | 5 +- python/api_v2/cluster/utils.py | 5 +- python/core/cluster/operations.py | 49 ++++++++++++++ python/core/cluster/types.py | 55 ++++++--------- python/core/tests/test_cluster.py | 67 +++++++++++++++++++ 5 files changed, 146 insertions(+), 35 deletions(-) diff --git a/python/ansible_plugin/executors/hostcomponent.py b/python/ansible_plugin/executors/hostcomponent.py index b01af77e0c..3a7018be41 100644 --- a/python/ansible_plugin/executors/hostcomponent.py +++ b/python/ansible_plugin/executors/hostcomponent.py @@ -16,6 +16,7 @@ from cm.api import add_hc, get_hc from cm.models import Cluster, Host, JobLog, ServiceComponent from cm.services.cluster import retrieve_clusters_topology +from core.cluster.operations import find_hosts_difference from core.types import ADCMCoreType, CoreObjectDescriptor from pydantic import field_validator @@ -128,6 +129,8 @@ def __call__( add_hc(cluster, hostcomponent) updated_topology = next(retrieve_clusters_topology(cluster_ids=(cluster.id,))) - handle_mapping_action_host_groups(mapping_delta=original_topology - updated_topology) + handle_mapping_action_host_groups( + mapping_delta=find_hosts_difference(new_topology=updated_topology, old_topology=original_topology).unmapped + ) return CallResult(value=None, changed=True, error=None) diff --git a/python/api_v2/cluster/utils.py b/python/api_v2/cluster/utils.py index 64cf531876..85d67de430 100644 --- a/python/api_v2/cluster/utils.py +++ b/python/api_v2/cluster/utils.py @@ -50,6 +50,7 @@ from cm.services.concern.locks import get_lock_on_object from cm.services.status.notify import reset_hc_map, reset_objects_in_mm from cm.status_api import send_host_component_map_update_event +from core.cluster.operations import find_hosts_difference from core.cluster.types import MovedHosts from django.contrib.contenttypes.models import ContentType from django.db.models import Q, QuerySet @@ -293,7 +294,9 @@ def _save_mapping(mapping_data: MappingData) -> QuerySet[HostComponent]: updated_topology = next(retrieve_clusters_topology(cluster_ids=(mapping_data.cluster.id,))) - handle_mapping_action_host_groups(mapping_delta=original_topology - updated_topology) + handle_mapping_action_host_groups( + mapping_delta=find_hosts_difference(old_topology=original_topology, new_topology=updated_topology).unmapped + ) update_hierarchy_issues(obj=mapping_data.orm_objects["cluster"]) for provider_id in {host.provider_id for host in mapping_data.hosts.values()}: diff --git a/python/core/cluster/operations.py b/python/core/cluster/operations.py index 3297d5275f..635b9328ad 100644 --- a/python/core/cluster/operations.py +++ b/python/core/cluster/operations.py @@ -25,6 +25,7 @@ MaintenanceModeOfObjects, ObjectMaintenanceModeState, ServiceTopology, + TopologyHostDiff, ) from core.types import ClusterID, ComponentID, HostID, ShortObjectInfo @@ -160,6 +161,54 @@ def calculate_maintenance_mode_for_component( return own_mm +def find_hosts_difference(new_topology: ClusterTopology, old_topology: ClusterTopology) -> TopologyHostDiff: + """ + Detect which hosts were mapped and unmapped between new and old topologies + + Note that results will contain newly added and removed services and components: + - all hosts on newly added objects are considered mapped + - all hosts on removed objects are considered unmapped + """ + diff = TopologyHostDiff() + + dummy_info = ShortObjectInfo(id=-1, name="notexist") + + for service_id, new_service_topology in new_topology.services.items(): + old_service_topology = old_topology.services.get(service_id, ServiceTopology(info=dummy_info, components={})) + + old_service_hosts = set(old_service_topology.host_ids) + new_service_hosts = set(new_service_topology.host_ids) + + diff.unmapped.services[service_id] = old_service_hosts - new_service_hosts + diff.mapped.services[service_id] = new_service_hosts - old_service_hosts + + for component_id, new_component_topology in new_service_topology.components.items(): + old_component_topology = old_service_topology.components.get( + component_id, ComponentTopology(info=dummy_info, hosts={}) + ) + + old_component_hosts = set(old_component_topology.hosts) + new_component_hosts = set(new_component_topology.hosts) + + diff.unmapped.components[component_id] = old_component_hosts - new_component_hosts + diff.mapped.components[component_id] = new_component_hosts - old_component_hosts + + # it's possible that some components are gone now, for universality reasons we should handle that case + for gone_component_id in set(old_service_topology.components).difference(new_service_topology.components): + diff.unmapped.components[gone_component_id] = set(old_service_topology.components[gone_component_id].hosts) + + # Appearance of new services is well covered in previous loop (new/old components too), + # but disappearance of old services isn't, so we handle it in here + for gone_service_id in set(old_topology.services).difference(new_topology.services): + gone_service_topology = old_topology.services[gone_service_id] + diff.unmapped.services[gone_service_id] = set(gone_service_topology.host_ids) + + for gone_component_id, gone_component_topology in gone_service_topology.components.items(): + diff.unmapped.components[gone_component_id] = set(gone_component_topology.hosts) + + return diff + + # !===== Hosts In Cluster =====! diff --git a/python/core/cluster/types.py b/python/core/cluster/types.py index 242d40ce36..328c53e60c 100644 --- a/python/core/cluster/types.py +++ b/python/core/cluster/types.py @@ -10,12 +10,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections import UserDict +from dataclasses import dataclass, field from enum import Enum from itertools import chain from typing import Generator, NamedTuple -from typing_extensions import Self - from core.types import ClusterID, ComponentID, HostID, ServiceID, ShortObjectInfo @@ -43,11 +43,6 @@ def host_ids(self) -> Generator[HostID, None, None]: return chain.from_iterable(component.hosts for component in self.components.values()) -class MovedHosts(NamedTuple): - services: dict[ServiceID, set[HostID]] - components: dict[ComponentID, set[HostID]] - - class ClusterTopology(NamedTuple): cluster_id: ClusterID services: dict[ServiceID, ServiceTopology] @@ -57,32 +52,26 @@ class ClusterTopology(NamedTuple): def component_ids(self) -> Generator[ComponentID, None, None]: return chain.from_iterable(service.components for service in self.services.values()) - def __sub__(self, previous: Self) -> MovedHosts: - """Returns unmapped hosts that were in the previous and removed from self""" - services_diff, components_diff = {}, {} - for service_id, service_topology in previous.services.items(): - new_service_topology = self.services.get(service_id) - if not new_service_topology: - if host_diff := set(service_topology.host_ids): - services_diff[service_id] = host_diff - continue - - host_diff = set(new_service_topology.host_ids) - set(service_topology.host_ids) - if host_diff: - services_diff[service_id] = host_diff - - for component_id, component_topology in service_topology.components.items(): - new_component_topology = new_service_topology.components.get(component_id) - if not new_component_topology: - if host_diff := set(service_topology.host_ids): - components_diff[service_id] = host_diff - continue - - host_diff = set(new_component_topology.hosts) - set(component_topology.hosts) - if host_diff: - components_diff[component_id] = host_diff - - return MovedHosts(services=services_diff, components=components_diff) + +class NoEmptyValuesDict(UserDict): + def __setitem__(self, key, value): + # if value is "empty" for one reason or another + if not value: + return + + super().__setitem__(key, value) + + +@dataclass(slots=True) +class MovedHosts: + services: NoEmptyValuesDict[ServiceID, set[HostID]] = field(default_factory=NoEmptyValuesDict) + components: NoEmptyValuesDict[ComponentID, set[HostID]] = field(default_factory=NoEmptyValuesDict) + + +@dataclass(slots=True) +class TopologyHostDiff: + mapped: MovedHosts = field(default_factory=MovedHosts) + unmapped: MovedHosts = field(default_factory=MovedHosts) class ObjectMaintenanceModeState(Enum): diff --git a/python/core/tests/test_cluster.py b/python/core/tests/test_cluster.py index 6ad2a95c63..e321cb49fe 100644 --- a/python/core/tests/test_cluster.py +++ b/python/core/tests/test_cluster.py @@ -16,12 +16,16 @@ calculate_maintenance_mode_for_cluster_objects, calculate_maintenance_mode_for_component, calculate_maintenance_mode_for_service, + find_hosts_difference, ) from core.cluster.types import ( ClusterTopology, ComponentTopology, MaintenanceModeOfObjects, + MovedHosts, + NoEmptyValuesDict, ServiceTopology, + TopologyHostDiff, ) from core.cluster.types import ObjectMaintenanceModeState as MM # noqa: N814 from core.types import ShortObjectInfo @@ -187,3 +191,66 @@ def test_calculate_maintenance_mode_for_component(self) -> None: ), expected_result, ) + + def test_find_hosts_difference(self) -> None: + h1, h2, h3, h4, h5 = (ShortObjectInfo(id=i, name=f"host{i}") for i in range(1, 6)) + s1, s2, s3, s4 = (ShortObjectInfo(id=i, name=f"service{i}") for i in range(1, 5)) + c1, c2, c3, c4, c5 = (ShortObjectInfo(id=i, name=f"component{i}") for i in range(1, 6)) + + on_hosts = lambda *hosts_: {h.id: h for h in hosts_} # noqa: E731 + + topology_1 = ClusterTopology( + cluster_id=1, + services={ + s1.id: ServiceTopology( + info=s1, + components={ + c1.id: ComponentTopology(info=c1, hosts=on_hosts(h1, h2, h3)), + c2.id: ComponentTopology(info=c2, hosts=on_hosts(h1)), + }, + ), + s2.id: ServiceTopology( + info=s2, + components={c3.id: ComponentTopology(info=c3, hosts=on_hosts(h4))}, + ), + s3.id: ServiceTopology(info=s3, components={c4.id: ComponentTopology(info=c4, hosts=on_hosts(h1))}), + }, + hosts=on_hosts(h1, h2, h3, h4), + ) + + topology_2 = ClusterTopology( + cluster_id=1, + services={ + s1.id: ServiceTopology( + info=s1, + components={ + c1.id: ComponentTopology(info=c1, hosts=on_hosts(h1)), + c2.id: ComponentTopology(info=c2, hosts=on_hosts(h1, h2)), + }, + ), + s2.id: ServiceTopology(info=s2, components={c3.id: ComponentTopology(info=c3, hosts=on_hosts(h4, h5))}), + s4.id: ServiceTopology(info=s4, components={c5.id: ComponentTopology(info=c5, hosts=on_hosts(h4))}), + }, + hosts=on_hosts(h1, h2, h3, h4, h5), + ) + + diff_new_2_old_1 = TopologyHostDiff( + mapped=MovedHosts( + services=NoEmptyValuesDict({s2.id: {h5.id}, s4.id: {h4.id}}), + components=NoEmptyValuesDict({c2.id: {h2.id}, c3.id: {h5.id}, c5.id: {h4.id}}), + ), + unmapped=MovedHosts( + services=NoEmptyValuesDict({s1.id: {h3.id}, s3.id: {h1.id}}), + components=NoEmptyValuesDict({c1.id: {h2.id, h3.id}, c4.id: {h1.id}}), + ), + ) + + with self.subTest("Direct"): + actual_diff = find_hosts_difference(new_topology=topology_2, old_topology=topology_1) + self.assertEqual(actual_diff, diff_new_2_old_1) + + with self.subTest("Reversed"): + actual_diff = find_hosts_difference(new_topology=topology_1, old_topology=topology_2) + self.assertEqual( + actual_diff, TopologyHostDiff(mapped=diff_new_2_old_1.unmapped, unmapped=diff_new_2_old_1.mapped) + ) From ed3804c8959c300276560264195952816ef001c7 Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Tue, 3 Sep 2024 10:31:49 +0000 Subject: [PATCH 47/50] ADCM-5873 Fix removing hosts from group for action with hc_acl --- python/ansible_plugin/executors/hostcomponent.py | 10 ---------- python/cm/api.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/python/ansible_plugin/executors/hostcomponent.py b/python/ansible_plugin/executors/hostcomponent.py index 3a7018be41..321f95a7d0 100644 --- a/python/ansible_plugin/executors/hostcomponent.py +++ b/python/ansible_plugin/executors/hostcomponent.py @@ -12,11 +12,8 @@ from typing import Any, Collection, Literal -from api_v2.cluster.utils import handle_mapping_action_host_groups from cm.api import add_hc, get_hc from cm.models import Cluster, Host, JobLog, ServiceComponent -from cm.services.cluster import retrieve_clusters_topology -from core.cluster.operations import find_hosts_difference from core.types import ADCMCoreType, CoreObjectDescriptor from pydantic import field_validator @@ -89,8 +86,6 @@ def __call__( cluster = Cluster.objects.get(id=runtime.vars.context.cluster_id) - original_topology = next(retrieve_clusters_topology(cluster_ids=(cluster.id,))) - hostcomponent = get_hc(cluster) for operation in arguments.operations: component_id, service_id = ServiceComponent.objects.values_list("id", "service_id").get( @@ -128,9 +123,4 @@ def __call__( add_hc(cluster, hostcomponent) - updated_topology = next(retrieve_clusters_topology(cluster_ids=(cluster.id,))) - handle_mapping_action_host_groups( - mapping_delta=find_hosts_difference(new_topology=updated_topology, old_topology=original_topology).unmapped - ) - return CallResult(value=None, changed=True, error=None) diff --git a/python/cm/api.py b/python/cm/api.py index 8011967e49..7dd27001b9 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -16,6 +16,8 @@ import json from adcm_version import compare_prototype_versions +from api_v2.cluster.utils import handle_mapping_action_host_groups +from core.cluster.operations import find_hosts_difference from core.types import CoreObjectDescriptor from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -68,6 +70,7 @@ ServiceComponent, TaskLog, ) +from cm.services.cluster import retrieve_clusters_topology from cm.services.concern.flags import BuiltInFlag, raise_flag, update_hierarchy from cm.services.concern.locks import get_lock_on_object from cm.services.status.notify import reset_hc_map, reset_objects_in_mm @@ -538,6 +541,8 @@ def save_hc( old_hosts = {i.host for i in hc_queryset.select_related("host")} new_hosts = {i[1] for i in host_comp_list} + previous_topology = next(retrieve_clusters_topology(cluster_ids=(cluster.id,))) + lock = get_lock_on_object(object_=cluster) if lock: for removed_host in old_hosts.difference(new_hosts): @@ -575,6 +580,11 @@ def save_hc( host_component.save() host_component_list.append(host_component) + updated_topology = next(retrieve_clusters_topology(cluster_ids=(cluster.id,))) + handle_mapping_action_host_groups( + mapping_delta=find_hosts_difference(old_topology=previous_topology, new_topology=updated_topology).unmapped + ) + update_hierarchy_issues(cluster) for provider in {host.provider for host in Host.objects.filter(cluster=cluster)}: From e93876176d256b3fdf6ca25b1e490a2a3c1203c6 Mon Sep 17 00:00:00 2001 From: Dmitry Bezrukov Date: Tue, 3 Sep 2024 12:22:08 +0000 Subject: [PATCH 48/50] fix About --- .../HeaderHelp/AboutAdcm/AboutAdcmModal/AboutAdcmModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adcm-web/app/src/components/layouts/partials/HeaderHelp/AboutAdcm/AboutAdcmModal/AboutAdcmModal.tsx b/adcm-web/app/src/components/layouts/partials/HeaderHelp/AboutAdcm/AboutAdcmModal/AboutAdcmModal.tsx index 65e2a3a104..f76a4014a6 100644 --- a/adcm-web/app/src/components/layouts/partials/HeaderHelp/AboutAdcm/AboutAdcmModal/AboutAdcmModal.tsx +++ b/adcm-web/app/src/components/layouts/partials/HeaderHelp/AboutAdcm/AboutAdcmModal/AboutAdcmModal.tsx @@ -15,7 +15,7 @@ const AboutAdcmModal = ({ isOpen, onOpenChange }: AboutAdcmProps) => { e.preventDefault()} />
- Arendata Cluster Manager + Arenadata Cluster Manager version {adcmVersion}
From 7c544fcc434589103053fbdc88adf83d01f88a54 Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Wed, 4 Sep 2024 09:55:33 +0500 Subject: [PATCH 49/50] ADCM-5931 Fix duplicates problem when filtering Action Host Groups by `hasHost` --- python/api_v2/action_host_group/filters.py | 2 +- python/api_v2/tests/test_action_host_group.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/python/api_v2/action_host_group/filters.py b/python/api_v2/action_host_group/filters.py index 6497853ed0..1ef60d2e18 100644 --- a/python/api_v2/action_host_group/filters.py +++ b/python/api_v2/action_host_group/filters.py @@ -16,7 +16,7 @@ class ActionHostGroupFilter(FilterSet): name = CharFilter(field_name="name", label="Name", lookup_expr="icontains") - has_host = CharFilter(field_name="hosts", label="Group Has Host", lookup_expr="fqdn__icontains") + has_host = CharFilter(field_name="hosts", label="Group Has Host", lookup_expr="fqdn__icontains", distinct=True) class Meta: model = ActionHostGroup diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py index 61f2b384ff..c75a58a6ce 100644 --- a/python/api_v2/tests/test_action_host_group.py +++ b/python/api_v2/tests/test_action_host_group.py @@ -538,6 +538,34 @@ def test_filter_groups_success(self) -> None: self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual(list(map(itemgetter("id"), response.json()["results"])), [group_3.id]) + def test_adcm_5931_duplicates_when_filtering_by_has_host(self) -> None: + host_1, host_2, *_ = self.hosts + host_3 = self.add_host(provider=host_1.provider, fqdn="special", cluster=self.cluster) + + group_1 = self.create_action_host_group(name="Service Group", owner=self.service) + group_2 = self.create_action_host_group(name="Super Custom", owner=self.service) + group_3 = self.create_action_host_group(name="Super Custom #2", owner=self.service) + + self.set_hostcomponent( + cluster=self.cluster, entries=[(host, self.component) for host in (host_1, host_2, host_3)] + ) + + self.action_host_group_service.add_hosts_to_group(group_1.id, hosts=[host_1.id, host_2.id]) + self.action_host_group_service.add_hosts_to_group(group_2.id, hosts=[host_1.id, host_3.id, host_2.id]) + self.action_host_group_service.add_hosts_to_group(group_3.id, hosts=[host_3.id]) + + with self.subTest("Only hasHost filter"): + response = self.client.v2[self.service, ACTION_HOST_GROUPS].get(query={"hasHost": "host"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(list(map(itemgetter("id"), response.json()["results"])), [group_1.id, group_2.id]) + + with self.subTest("Name and hasHost filter"): + response = self.client.v2[self.service, ACTION_HOST_GROUPS].get(query={"hasHost": "host", "name": "Super"}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(list(map(itemgetter("id"), response.json()["results"])), [group_2.id]) + def test_host_candidates_success(self) -> None: host_1, host_2, host_3 = self.hosts host_1_data, host_2_data, host_3_data = ({"id": host.id, "name": host.fqdn} for host in self.hosts) From 14f4a4b504f29431409a2a99417ecd4b4971d4d5 Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Tue, 3 Sep 2024 15:28:36 +0300 Subject: [PATCH 50/50] Bump version 2.3.0 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 50c4136250..57f5bc7f59 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ APP_IMAGE ?= hub.adsw.io/adcm/adcm APP_TAG ?= $(subst /,_,$(BRANCH_NAME)) SELENOID_HOST ?= 10.92.2.65 SELENOID_PORT ?= 4444 -ADCM_VERSION = "2.3.0-dev" +ADCM_VERSION = "2.3.0" PY_FILES = python dev/linters conf/adcm/python_scripts .PHONY: help