From a7d1e3a6fbca7456382217ad3f1d9922a5af550c Mon Sep 17 00:00:00 2001 From: Sylvain <35365065+sanderegg@users.noreply.github.com> Date: Mon, 16 Dec 2024 08:05:43 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9BAutoscaling:=20Warm=20buffers?= =?UTF-8?q?=20do=20not=20replace=20hot=20buffers=20(#6962)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codecov.yml | 10 +- .github/workflows/ci-testing-deploy.yml | 24 +- .../src/pytest_simcore/helpers/aws_ec2.py | 5 +- .../modules/auto_scaling_core.py | 36 ++- .../modules/dask.py | 2 +- .../utils/utils_docker.py | 8 +- services/autoscaling/tests/unit/conftest.py | 202 +++++++++++- ...test_modules_auto_scaling_computational.py | 116 ++++++- .../unit/test_modules_auto_scaling_dynamic.py | 305 +++++++++++++++++- .../unit/test_modules_buffer_machine_core.py | 172 +--------- .../unit/test_utils_auto_scaling_core.py | 10 +- 11 files changed, 664 insertions(+), 226 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 02666df0a13..eb2e6697348 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -10,10 +10,10 @@ flag_management: statuses: - type: project target: auto - threshold: 1% + threshold: 2% - type: patch target: auto - threshold: 1% + threshold: 2% component_management: @@ -22,7 +22,7 @@ component_management: statuses: - type: project target: auto - threshold: 1% + threshold: 2% branches: - "!master" individual_components: @@ -116,12 +116,12 @@ coverage: project: default: informational: true - threshold: 1% + threshold: 2% patch: default: informational: true - threshold: 1% + threshold: 2% comment: layout: "header,diff,flags,components,footer" diff --git a/.github/workflows/ci-testing-deploy.yml b/.github/workflows/ci-testing-deploy.yml index aa1efbee7a9..789c552cc81 100644 --- a/.github/workflows/ci-testing-deploy.yml +++ b/.github/workflows/ci-testing-deploy.yml @@ -772,7 +772,7 @@ jobs: if: ${{ !cancelled() }} run: ./ci/github/unit-testing/catalog.bash test - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -879,7 +879,7 @@ jobs: if: ${{ !cancelled() }} run: ./ci/github/unit-testing/datcore-adapter.bash test - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -930,7 +930,7 @@ jobs: if: ${{ !cancelled() }} run: ./ci/github/unit-testing/director.bash test - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -981,7 +981,7 @@ jobs: if: ${{ !cancelled() }} run: ./ci/github/unit-testing/director-v2.bash test - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -1910,7 +1910,7 @@ jobs: - name: test run: ./ci/github/integration-testing/webserver.bash test 01 - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -1974,7 +1974,7 @@ jobs: - name: test run: ./ci/github/integration-testing/webserver.bash test 02 - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -2038,7 +2038,7 @@ jobs: - name: test run: ./ci/github/integration-testing/director-v2.bash test 01 - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -2111,7 +2111,7 @@ jobs: - name: test run: ./ci/github/integration-testing/director-v2.bash test 02 - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -2177,7 +2177,7 @@ jobs: - name: test run: ./ci/github/integration-testing/dynamic-sidecar.bash test 01 - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -2241,7 +2241,7 @@ jobs: - name: test run: ./ci/github/integration-testing/simcore-sdk.bash test - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -2330,7 +2330,7 @@ jobs: - name: test run: ./ci/github/system-testing/public-api.bash test - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs @@ -2395,7 +2395,7 @@ jobs: name: ${{ github.job }}_services_settings_schemas path: ./services/**/settings-schema.json - name: upload failed tests logs - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: ${{ github.job }}_docker_logs diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/aws_ec2.py b/packages/pytest-simcore/src/pytest_simcore/helpers/aws_ec2.py index 1e992f4ee45..7bb826149fe 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/aws_ec2.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/aws_ec2.py @@ -42,7 +42,10 @@ async def assert_autoscaled_dynamic_ec2_instances( expected_instance_state: InstanceStateNameType, expected_additional_tag_keys: list[str], instance_filters: Sequence[FilterTypeDef] | None, + expected_user_data: list[str] | None = None, ) -> list[InstanceTypeDef]: + if expected_user_data is None: + expected_user_data = ["docker swarm join"] return await assert_ec2_instances( ec2_client, expected_num_reservations=expected_num_reservations, @@ -54,7 +57,7 @@ async def assert_autoscaled_dynamic_ec2_instances( "io.simcore.autoscaling.monitored_services_labels", *expected_additional_tag_keys, ], - expected_user_data=["docker swarm join"], + expected_user_data=expected_user_data, instance_filters=instance_filters, ) diff --git a/services/autoscaling/src/simcore_service_autoscaling/modules/auto_scaling_core.py b/services/autoscaling/src/simcore_service_autoscaling/modules/auto_scaling_core.py index e2212195aed..9c45de0524b 100644 --- a/services/autoscaling/src/simcore_service_autoscaling/modules/auto_scaling_core.py +++ b/services/autoscaling/src/simcore_service_autoscaling/modules/auto_scaling_core.py @@ -418,15 +418,43 @@ async def _activate_drained_nodes( ) -async def _start_buffer_instances( +async def _start_warm_buffer_instances( app: FastAPI, cluster: Cluster, auto_scaling_mode: BaseAutoscaling ) -> Cluster: + """starts warm buffer if there are assigned tasks, or if a hot buffer of the same type is needed""" + + app_settings = get_application_settings(app) + assert app_settings.AUTOSCALING_EC2_INSTANCES # nosec + instances_to_start = [ i.ec2_instance for i in cluster.buffer_ec2s if i.assigned_tasks ] + + if ( + len(cluster.buffer_drained_nodes) + < app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER + ): + # check if we can migrate warm buffers to hot buffers + hot_buffer_instance_type = cast( + InstanceTypeType, + next( + iter(app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_ALLOWED_TYPES) + ), + ) + free_startable_warm_buffers_to_replace_hot_buffers = [ + warm_buffer.ec2_instance + for warm_buffer in cluster.buffer_ec2s + if (warm_buffer.ec2_instance.type == hot_buffer_instance_type) + and not warm_buffer.assigned_tasks + ] + instances_to_start += free_startable_warm_buffers_to_replace_hot_buffers[ + : app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER + - len(cluster.buffer_drained_nodes) + ] + if not instances_to_start: return cluster - # change the buffer machine to an active one + with log_context( _logger, logging.INFO, f"start {len(instances_to_start)} buffer machines" ): @@ -1187,8 +1215,8 @@ async def _autoscale_cluster( # 2. activate available drained nodes to cover some of the tasks cluster = await _activate_drained_nodes(app, cluster, auto_scaling_mode) - # 3. start buffer instances to cover the remaining tasks - cluster = await _start_buffer_instances(app, cluster, auto_scaling_mode) + # 3. start warm buffer instances to cover the remaining tasks + cluster = await _start_warm_buffer_instances(app, cluster, auto_scaling_mode) # 4. scale down unused instances cluster = await _scale_down_unused_cluster_instances( diff --git a/services/autoscaling/src/simcore_service_autoscaling/modules/dask.py b/services/autoscaling/src/simcore_service_autoscaling/modules/dask.py index 4c5ee00f86c..d57508babf8 100644 --- a/services/autoscaling/src/simcore_service_autoscaling/modules/dask.py +++ b/services/autoscaling/src/simcore_service_autoscaling/modules/dask.py @@ -273,7 +273,7 @@ def _list_processing_tasks_on_worker( async with _scheduler_client(scheduler_url, authentication) as client: worker_url, _ = _dask_worker_from_ec2_instance(client, ec2_instance) - _logger.debug("looking for processing tasksfor %s", f"{worker_url=}") + _logger.debug("looking for processing tasks for %s", f"{worker_url=}") # now get the used resources worker_processing_tasks: list[ diff --git a/services/autoscaling/src/simcore_service_autoscaling/utils/utils_docker.py b/services/autoscaling/src/simcore_service_autoscaling/utils/utils_docker.py index 65caa0f40b1..4c5b5e6f79f 100644 --- a/services/autoscaling/src/simcore_service_autoscaling/utils/utils_docker.py +++ b/services/autoscaling/src/simcore_service_autoscaling/utils/utils_docker.py @@ -521,8 +521,14 @@ async def tag_node( tags: dict[DockerLabelKey, str], available: bool, ) -> Node: + assert node.spec # nosec + if (node.spec.labels == tags) and ( + (node.spec.availability is Availability.active) == available + ): + # nothing to do + return node with log_context( - logger, logging.DEBUG, msg=f"tagging {node.id=} with {tags=} and {available=}" + logger, logging.DEBUG, msg=f"tag {node.id=} with {tags=} and {available=}" ): assert node.id # nosec diff --git a/services/autoscaling/tests/unit/conftest.py b/services/autoscaling/tests/unit/conftest.py index 4a48f2776b6..9b7489268e6 100644 --- a/services/autoscaling/tests/unit/conftest.py +++ b/services/autoscaling/tests/unit/conftest.py @@ -28,11 +28,16 @@ EC2InstanceType, Resources, ) +from common_library.json_serialization import json_dumps from deepdiff import DeepDiff from faker import Faker from fakeredis.aioredis import FakeRedis from fastapi import FastAPI -from models_library.docker import DockerLabelKey, StandardSimcoreDockerLabels +from models_library.docker import ( + DockerGenericTag, + DockerLabelKey, + StandardSimcoreDockerLabels, +) from models_library.generated_models.docker_rest_api import Availability from models_library.generated_models.docker_rest_api import Node as DockerNode from models_library.generated_models.docker_rest_api import ( @@ -45,7 +50,7 @@ Service, TaskSpec, ) -from pydantic import ByteSize, PositiveInt, TypeAdapter +from pydantic import ByteSize, NonNegativeInt, PositiveInt, TypeAdapter from pytest_mock import MockType from pytest_mock.plugin import MockerFixture from pytest_simcore.helpers.host import get_localhost_ip @@ -57,6 +62,7 @@ ) from settings_library.rabbit import RabbitSettings from settings_library.ssm import SSMSettings +from simcore_service_autoscaling.constants import PRE_PULLED_IMAGES_EC2_TAG_KEY from simcore_service_autoscaling.core.application import create_app from simcore_service_autoscaling.core.settings import ( AUTOSCALING_ENV_PREFIX, @@ -71,8 +77,14 @@ DaskTaskResources, ) from simcore_service_autoscaling.modules import auto_scaling_core +from simcore_service_autoscaling.modules.auto_scaling_mode_dynamic import ( + DynamicAutoscaling, +) from simcore_service_autoscaling.modules.docker import AutoscalingDocker from simcore_service_autoscaling.modules.ec2 import SimcoreEC2API +from simcore_service_autoscaling.utils.buffer_machines_pool_core import ( + get_deactivated_buffer_ec2_tags, +) from simcore_service_autoscaling.utils.utils_docker import ( _OSPARC_SERVICE_READY_LABEL_KEY, _OSPARC_SERVICES_READY_DATETIME_LABEL_KEY, @@ -81,7 +93,9 @@ from tenacity.retry import retry_if_exception_type from tenacity.stop import stop_after_delay from tenacity.wait import wait_fixed -from types_aiobotocore_ec2.literals import InstanceTypeType +from types_aiobotocore_ec2 import EC2Client +from types_aiobotocore_ec2.literals import InstanceStateNameType, InstanceTypeType +from types_aiobotocore_ec2.type_defs import TagTypeDef pytest_plugins = [ "pytest_simcore.aws_server", @@ -991,10 +1005,22 @@ def _creator( @pytest.fixture -def mock_machines_buffer(monkeypatch: pytest.MonkeyPatch) -> int: - num_machines_in_buffer = 5 - monkeypatch.setenv("EC2_INSTANCES_MACHINES_BUFFER", f"{num_machines_in_buffer}") - return num_machines_in_buffer +def num_hot_buffer() -> NonNegativeInt: + return 5 + + +@pytest.fixture +def with_instances_machines_hot_buffer( + num_hot_buffer: int, + app_environment: EnvVarsDict, + monkeypatch: pytest.MonkeyPatch, +) -> EnvVarsDict: + return app_environment | setenvs_from_dict( + monkeypatch, + { + "EC2_INSTANCES_MACHINES_BUFFER": f"{num_hot_buffer}", + }, + ) @pytest.fixture @@ -1042,3 +1068,165 @@ async def _( autospec=True, side_effect=_, ) + + +@pytest.fixture +def fake_pre_pull_images() -> list[DockerGenericTag]: + return TypeAdapter(list[DockerGenericTag]).validate_python( + [ + "nginx:latest", + "itisfoundation/my-very-nice-service:latest", + "simcore/services/dynamic/another-nice-one:2.4.5", + "asd", + ] + ) + + +@pytest.fixture +def ec2_instances_allowed_types_with_only_1_buffered( + faker: Faker, + fake_pre_pull_images: list[DockerGenericTag], + external_ec2_instances_allowed_types: None | dict[str, EC2InstanceBootSpecific], +) -> dict[InstanceTypeType, EC2InstanceBootSpecific]: + if not external_ec2_instances_allowed_types: + return { + "t2.micro": EC2InstanceBootSpecific( + ami_id=faker.pystr(), + pre_pull_images=fake_pre_pull_images, + buffer_count=faker.pyint(min_value=1, max_value=10), + ) + } + + allowed_ec2_types = external_ec2_instances_allowed_types + allowed_ec2_types_with_buffer_defined = dict( + filter( + lambda instance_type_and_settings: instance_type_and_settings[ + 1 + ].buffer_count + > 0, + allowed_ec2_types.items(), + ) + ) + assert ( + allowed_ec2_types_with_buffer_defined + ), "one type with buffer is needed for the tests!" + assert ( + len(allowed_ec2_types_with_buffer_defined) == 1 + ), "more than one type with buffer is disallowed in this test!" + return { + TypeAdapter(InstanceTypeType).validate_python(k): v + for k, v in allowed_ec2_types_with_buffer_defined.items() + } + + +@pytest.fixture +def buffer_count( + ec2_instances_allowed_types_with_only_1_buffered: dict[ + InstanceTypeType, EC2InstanceBootSpecific + ], +) -> int: + def _by_buffer_count( + instance_type_and_settings: tuple[InstanceTypeType, EC2InstanceBootSpecific] + ) -> bool: + _, boot_specific = instance_type_and_settings + return boot_specific.buffer_count > 0 + + allowed_ec2_types = ec2_instances_allowed_types_with_only_1_buffered + allowed_ec2_types_with_buffer_defined = dict( + filter(_by_buffer_count, allowed_ec2_types.items()) + ) + assert allowed_ec2_types_with_buffer_defined, "you need one type with buffer" + assert ( + len(allowed_ec2_types_with_buffer_defined) == 1 + ), "more than one type with buffer is disallowed in this test!" + return next(iter(allowed_ec2_types_with_buffer_defined.values())).buffer_count + + +@pytest.fixture +async def create_buffer_machines( + ec2_client: EC2Client, + aws_ami_id: str, + app_settings: ApplicationSettings, + initialized_app: FastAPI, +) -> Callable[ + [int, InstanceTypeType, InstanceStateNameType, list[DockerGenericTag] | None], + Awaitable[list[str]], +]: + async def _do( + num: int, + instance_type: InstanceTypeType, + instance_state_name: InstanceStateNameType, + pre_pull_images: list[DockerGenericTag] | None, + ) -> list[str]: + assert app_settings.AUTOSCALING_EC2_INSTANCES + + assert instance_state_name in [ + "running", + "stopped", + ], "only 'running' and 'stopped' are supported for testing" + + resource_tags: list[TagTypeDef] = [ + {"Key": tag_key, "Value": tag_value} + for tag_key, tag_value in get_deactivated_buffer_ec2_tags( + initialized_app, DynamicAutoscaling() + ).items() + ] + if pre_pull_images is not None and instance_state_name == "stopped": + resource_tags.append( + { + "Key": PRE_PULLED_IMAGES_EC2_TAG_KEY, + "Value": f"{json_dumps(pre_pull_images)}", + } + ) + with log_context( + logging.INFO, f"creating {num} buffer machines of {instance_type}" + ): + instances = await ec2_client.run_instances( + ImageId=aws_ami_id, + MaxCount=num, + MinCount=num, + InstanceType=instance_type, + KeyName=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_KEY_NAME, + SecurityGroupIds=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_SECURITY_GROUP_IDS, + SubnetId=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_SUBNET_ID, + IamInstanceProfile={ + "Arn": app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_ATTACHED_IAM_PROFILE + }, + TagSpecifications=[ + {"ResourceType": "instance", "Tags": resource_tags}, + {"ResourceType": "volume", "Tags": resource_tags}, + {"ResourceType": "network-interface", "Tags": resource_tags}, + ], + UserData="echo 'I am pytest'", + ) + instance_ids = [ + i["InstanceId"] for i in instances["Instances"] if "InstanceId" in i + ] + + waiter = ec2_client.get_waiter("instance_exists") + await waiter.wait(InstanceIds=instance_ids) + instances = await ec2_client.describe_instances(InstanceIds=instance_ids) + assert "Reservations" in instances + assert instances["Reservations"] + assert "Instances" in instances["Reservations"][0] + assert len(instances["Reservations"][0]["Instances"]) == num + for instance in instances["Reservations"][0]["Instances"]: + assert "State" in instance + assert "Name" in instance["State"] + assert instance["State"]["Name"] == "running" + + if instance_state_name == "stopped": + await ec2_client.stop_instances(InstanceIds=instance_ids) + instances = await ec2_client.describe_instances(InstanceIds=instance_ids) + assert "Reservations" in instances + assert instances["Reservations"] + assert "Instances" in instances["Reservations"][0] + assert len(instances["Reservations"][0]["Instances"]) == num + for instance in instances["Reservations"][0]["Instances"]: + assert "State" in instance + assert "Name" in instance["State"] + assert instance["State"]["Name"] == "stopped" + + return instance_ids + + return _do diff --git a/services/autoscaling/tests/unit/test_modules_auto_scaling_computational.py b/services/autoscaling/tests/unit/test_modules_auto_scaling_computational.py index 6e7a0d7c828..bad4215a65e 100644 --- a/services/autoscaling/tests/unit/test_modules_auto_scaling_computational.py +++ b/services/autoscaling/tests/unit/test_modules_auto_scaling_computational.py @@ -305,6 +305,18 @@ async def _(scale_up_params: _ScaleUpParams) -> list[distributed.Future]: return _ +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test_cluster_scaling_with_no_tasks_does_nothing( minimal_configuration: None, app_settings: ApplicationSettings, @@ -330,6 +342,18 @@ async def test_cluster_scaling_with_no_tasks_does_nothing( @pytest.mark.acceptance_test( "Ensure this does not happen https://github.com/ITISFoundation/osparc-simcore/issues/6227" ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test_cluster_scaling_with_disabled_ssm_does_not_block_autoscaling( minimal_configuration: None, disabled_ssm: None, @@ -353,6 +377,18 @@ async def test_cluster_scaling_with_disabled_ssm_does_not_block_autoscaling( ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test_cluster_scaling_with_task_with_too_much_resources_starts_nothing( minimal_configuration: None, app_settings: ApplicationSettings, @@ -800,6 +836,18 @@ async def test_cluster_scaling_up_and_down( # noqa: PLR0915 mock_docker_compute_node_used_resources.assert_not_called() +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test_cluster_does_not_scale_up_if_defined_instance_is_not_allowed( minimal_configuration: None, app_settings: ApplicationSettings, @@ -839,6 +887,18 @@ async def test_cluster_does_not_scale_up_if_defined_instance_is_not_allowed( assert "Unexpected error:" in error_messages[0] +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test_cluster_does_not_scale_up_if_defined_instance_is_not_fitting_resources( minimal_configuration: None, app_settings: ApplicationSettings, @@ -878,6 +938,18 @@ async def test_cluster_does_not_scale_up_if_defined_instance_is_not_fitting_reso assert "Unexpected error:" in error_messages[0] +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) @pytest.mark.parametrize( "scale_up_params", [ @@ -948,6 +1020,18 @@ async def test_cluster_scaling_up_starts_multiple_instances( mock_rabbitmq_post_message.reset_mock() +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) @pytest.mark.parametrize( "scale_up_params", [ @@ -1044,6 +1128,18 @@ async def test_cluster_scaling_up_more_than_allowed_max_starts_max_instances_and ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test_cluster_scaling_up_more_than_allowed_with_multiple_types_max_starts_max_instances_and_not_more( patch_ec2_client_launch_instances_min_number_of_instances: mock.Mock, minimal_configuration: None, @@ -1141,6 +1237,18 @@ async def test_cluster_scaling_up_more_than_allowed_with_multiple_types_max_star ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) @pytest.mark.parametrize( "scale_up_params", [ @@ -1305,11 +1413,15 @@ async def test_long_pending_ec2_is_detected_as_broken_terminated_and_restarted( @pytest.mark.parametrize( - "with_docker_join_drained", ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], indirect=True + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, ) @pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options "with_drain_nodes_labelled", - ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + ["without_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], indirect=True, ) @pytest.mark.parametrize( diff --git a/services/autoscaling/tests/unit/test_modules_auto_scaling_dynamic.py b/services/autoscaling/tests/unit/test_modules_auto_scaling_dynamic.py index ccdb2461c04..afd3c01e4a3 100644 --- a/services/autoscaling/tests/unit/test_modules_auto_scaling_dynamic.py +++ b/services/autoscaling/tests/unit/test_modules_auto_scaling_dynamic.py @@ -24,6 +24,7 @@ from fastapi import FastAPI from models_library.docker import ( DOCKER_TASK_EC2_INSTANCE_TYPE_PLACEMENT_CONSTRAINT_KEY, + DockerGenericTag, DockerLabelKey, StandardSimcoreDockerLabels, ) @@ -43,9 +44,13 @@ assert_cluster_state, create_fake_association, ) -from pytest_simcore.helpers.aws_ec2 import assert_autoscaled_dynamic_ec2_instances +from pytest_simcore.helpers.aws_ec2 import ( + assert_autoscaled_dynamic_ec2_instances, + assert_autoscaled_dynamic_warm_pools_ec2_instances, +) from pytest_simcore.helpers.logging_tools import log_context from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict +from simcore_service_autoscaling.constants import BUFFER_MACHINE_TAG_KEY from simcore_service_autoscaling.core.settings import ApplicationSettings from simcore_service_autoscaling.models import AssociatedInstance, Cluster from simcore_service_autoscaling.modules.auto_scaling_core import ( @@ -68,7 +73,7 @@ _OSPARC_SERVICES_READY_DATETIME_LABEL_KEY, ) from types_aiobotocore_ec2.client import EC2Client -from types_aiobotocore_ec2.literals import InstanceTypeType +from types_aiobotocore_ec2.literals import InstanceStateNameType, InstanceTypeType from types_aiobotocore_ec2.type_defs import FilterTypeDef, InstanceTypeDef @@ -286,6 +291,18 @@ async def _(scale_up_params: _ScaleUpParams) -> list[Service]: return _ +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test_cluster_scaling_with_no_services_does_nothing( minimal_configuration: None, app_settings: ApplicationSettings, @@ -304,10 +321,22 @@ async def test_cluster_scaling_with_no_services_does_nothing( ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test_cluster_scaling_with_no_services_and_machine_buffer_starts_expected_machines( patch_ec2_client_launch_instances_min_number_of_instances: mock.Mock, minimal_configuration: None, - mock_machines_buffer: int, + with_instances_machines_hot_buffer: EnvVarsDict, app_settings: ApplicationSettings, initialized_app: FastAPI, aws_allowed_ec2_instance_type_names_env: list[str], @@ -321,17 +350,13 @@ async def test_cluster_scaling_with_no_services_and_machine_buffer_starts_expect instance_type_filters: Sequence[FilterTypeDef], ): assert app_settings.AUTOSCALING_EC2_INSTANCES - assert ( - mock_machines_buffer - == app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER - ) await auto_scale_cluster( app=initialized_app, auto_scaling_mode=DynamicAutoscaling() ) await assert_autoscaled_dynamic_ec2_instances( ec2_client, expected_num_reservations=1, - expected_num_instances=mock_machines_buffer, + expected_num_instances=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, expected_instance_type=cast( InstanceTypeType, next( @@ -346,7 +371,7 @@ async def test_cluster_scaling_with_no_services_and_machine_buffer_starts_expect mock_rabbitmq_post_message, app_settings, initialized_app, - instances_pending=mock_machines_buffer, + instances_pending=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, ) mock_rabbitmq_post_message.reset_mock() # calling again should attach the new nodes to the reserve, but nothing should start @@ -356,7 +381,7 @@ async def test_cluster_scaling_with_no_services_and_machine_buffer_starts_expect await assert_autoscaled_dynamic_ec2_instances( ec2_client, expected_num_reservations=1, - expected_num_instances=mock_machines_buffer, + expected_num_instances=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, expected_instance_type=cast( InstanceTypeType, next( @@ -375,14 +400,15 @@ async def test_cluster_scaling_with_no_services_and_machine_buffer_starts_expect mock_rabbitmq_post_message, app_settings, initialized_app, - nodes_total=mock_machines_buffer, - nodes_drained=mock_machines_buffer, - instances_running=mock_machines_buffer, + nodes_total=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, + nodes_drained=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, + instances_running=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, cluster_total_resources={ - "cpus": mock_machines_buffer + "cpus": app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER * fake_node.description.resources.nano_cp_us / 1e9, - "ram": mock_machines_buffer * fake_node.description.resources.memory_bytes, + "ram": app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER + * fake_node.description.resources.memory_bytes, }, ) @@ -394,7 +420,7 @@ async def test_cluster_scaling_with_no_services_and_machine_buffer_starts_expect await assert_autoscaled_dynamic_ec2_instances( ec2_client, expected_num_reservations=1, - expected_num_instances=mock_machines_buffer, + expected_num_instances=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, expected_instance_type=cast( InstanceTypeType, next( @@ -407,6 +433,18 @@ async def test_cluster_scaling_with_no_services_and_machine_buffer_starts_expect ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) @pytest.mark.parametrize( "scale_up_params", [ @@ -990,6 +1028,18 @@ async def test_cluster_scaling_up_and_down( ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) @pytest.mark.parametrize( "scale_up_params", [ @@ -1066,6 +1116,18 @@ async def test_cluster_scaling_up_and_down_against_aws( ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) @pytest.mark.parametrize( "scale_up_params", [ @@ -1148,9 +1210,13 @@ async def test_cluster_scaling_up_starts_multiple_instances( @pytest.mark.parametrize( - "with_docker_join_drained", ["with_AUTOSCALING_DOCKER_JOIN_DRAINED"], indirect=True + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, ) @pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options "with_drain_nodes_labelled", ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], indirect=True, @@ -1445,6 +1511,18 @@ async def test_cluster_adapts_machines_on_the_fly( # noqa: PLR0915 assert instance["InstanceType"] == scale_up_params2.expected_instance_type +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) @pytest.mark.parametrize( "scale_up_params", [ @@ -1606,6 +1684,18 @@ async def test_long_pending_ec2_is_detected_as_broken_terminated_and_restarted( ) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test__find_terminateable_nodes_with_no_hosts( minimal_configuration: None, initialized_app: FastAPI, @@ -1626,6 +1716,18 @@ async def test__find_terminateable_nodes_with_no_hosts( assert await _find_terminateable_instances(initialized_app, active_cluster) == [] +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test__try_scale_down_cluster_with_no_nodes( minimal_configuration: None, with_valid_time_before_termination: datetime.timedelta, @@ -1650,6 +1752,18 @@ async def test__try_scale_down_cluster_with_no_nodes( mock_remove_nodes.assert_not_called() +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test__activate_drained_nodes_with_no_tasks( minimal_configuration: None, with_valid_time_before_termination: datetime.timedelta, @@ -1683,6 +1797,18 @@ async def test__activate_drained_nodes_with_no_tasks( mock_docker_tag_node.assert_not_called() +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test__activate_drained_nodes_with_no_drained_nodes( minimal_configuration: None, with_valid_time_before_termination: datetime.timedelta, @@ -1724,6 +1850,18 @@ async def test__activate_drained_nodes_with_no_drained_nodes( mock_docker_tag_node.assert_not_called() +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) async def test__activate_drained_nodes_with_drained_node( minimal_configuration: None, with_valid_time_before_termination: datetime.timedelta, @@ -1790,3 +1928,136 @@ async def test__activate_drained_nodes_with_drained_node( }, available=True, ) + + +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_docker_join_drained", + ["without_AUTOSCALING_DOCKER_JOIN_DRAINED"], + indirect=True, +) +@pytest.mark.parametrize( + # NOTE: only the main test test_cluster_scaling_up_and_down is run with all options + "with_drain_nodes_labelled", + ["with_AUTOSCALING_DRAIN_NODES_WITH_LABELS"], + indirect=True, +) +async def test_warm_buffers_are_started_to_replace_missing_hot_buffers( + patch_ec2_client_launch_instances_min_number_of_instances: mock.Mock, + minimal_configuration: None, + with_instances_machines_hot_buffer: EnvVarsDict, + ec2_client: EC2Client, + initialized_app: FastAPI, + app_settings: ApplicationSettings, + ec2_instance_custom_tags: dict[str, str], + buffer_count: int, + create_buffer_machines: Callable[ + [int, InstanceTypeType, InstanceStateNameType, list[DockerGenericTag] | None], + Awaitable[list[str]], + ], + spied_cluster_analysis: MockType, + instance_type_filters: Sequence[FilterTypeDef], + mock_find_node_with_name_returns_fake_node: mock.Mock, + mock_compute_node_used_resources: mock.Mock, + mock_docker_tag_node: mock.Mock, +): + # pre-requisites + assert app_settings.AUTOSCALING_EC2_INSTANCES + assert app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER > 0 + + # we have nothing running now + all_instances = await ec2_client.describe_instances() + assert not all_instances["Reservations"] + + # have a few warm buffers ready with the same type as the hot buffer machines + buffer_machines = await create_buffer_machines( + buffer_count, + cast( + InstanceTypeType, + next( + iter(app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_ALLOWED_TYPES) + ), + ), + "stopped", + None, + ) + await assert_autoscaled_dynamic_warm_pools_ec2_instances( + ec2_client, + expected_num_reservations=1, + expected_num_instances=buffer_count, + expected_instance_type=cast( + InstanceTypeType, + next( + iter(app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_ALLOWED_TYPES) + ), + ), + expected_instance_state="stopped", + expected_additional_tag_keys=list(ec2_instance_custom_tags), + expected_pre_pulled_images=None, + instance_filters=None, + ) + + # let's autoscale, this should move the warm buffers to hot buffers + await auto_scale_cluster( + app=initialized_app, auto_scaling_mode=DynamicAutoscaling() + ) + mock_docker_tag_node.assert_not_called() + # at analysis time, we had no machines running + analyzed_cluster = assert_cluster_state( + spied_cluster_analysis, + expected_calls=1, + expected_num_machines=0, + ) + assert not analyzed_cluster.active_nodes + assert analyzed_cluster.buffer_ec2s + assert len(analyzed_cluster.buffer_ec2s) == len(buffer_machines) + + # now we should have a warm buffer moved to the hot buffer + await assert_autoscaled_dynamic_ec2_instances( + ec2_client, + expected_num_reservations=1, + expected_num_instances=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, + expected_instance_type=cast( + InstanceTypeType, + next( + iter(app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_ALLOWED_TYPES) + ), + ), + expected_instance_state="running", + expected_additional_tag_keys=[ + *list(ec2_instance_custom_tags), + BUFFER_MACHINE_TAG_KEY, + ], + instance_filters=instance_type_filters, + expected_user_data=[], + ) + + # let's autoscale again, to check the cluster analysis and tag the nodes + await auto_scale_cluster( + app=initialized_app, auto_scaling_mode=DynamicAutoscaling() + ) + mock_docker_tag_node.assert_called() + assert ( + mock_docker_tag_node.call_count + == app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER + ) + # at analysis time, we had no machines running + analyzed_cluster = assert_cluster_state( + spied_cluster_analysis, + expected_calls=1, + expected_num_machines=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, + ) + assert not analyzed_cluster.active_nodes + assert len(analyzed_cluster.buffer_ec2s) == max( + 0, + buffer_count + - app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER, + ), ( + "the warm buffers were not used as expected there should be" + f" {buffer_count - app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER} remaining, " + f"found {len(analyzed_cluster.buffer_ec2s)}" + ) + assert ( + len(analyzed_cluster.pending_ec2s) + == app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER + ) diff --git a/services/autoscaling/tests/unit/test_modules_buffer_machine_core.py b/services/autoscaling/tests/unit/test_modules_buffer_machine_core.py index 24a552f342b..26375418417 100644 --- a/services/autoscaling/tests/unit/test_modules_buffer_machine_core.py +++ b/services/autoscaling/tests/unit/test_modules_buffer_machine_core.py @@ -16,9 +16,7 @@ import pytest import tenacity -from aws_library.ec2 import AWSTagKey, EC2InstanceBootSpecific -from common_library.json_serialization import json_dumps -from faker import Faker +from aws_library.ec2 import AWSTagKey from fastapi import FastAPI from fastapi.encoders import jsonable_encoder from models_library.docker import DockerGenericTag @@ -30,68 +28,15 @@ from pytest_simcore.helpers.logging_tools import log_context from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict from simcore_service_autoscaling.constants import PRE_PULLED_IMAGES_EC2_TAG_KEY -from simcore_service_autoscaling.core.settings import ApplicationSettings from simcore_service_autoscaling.modules.auto_scaling_mode_dynamic import ( DynamicAutoscaling, ) from simcore_service_autoscaling.modules.buffer_machines_pool_core import ( monitor_buffer_machines, ) -from simcore_service_autoscaling.utils.buffer_machines_pool_core import ( - get_deactivated_buffer_ec2_tags, -) from types_aiobotocore_ec2 import EC2Client from types_aiobotocore_ec2.literals import InstanceStateNameType, InstanceTypeType -from types_aiobotocore_ec2.type_defs import FilterTypeDef, TagTypeDef - - -@pytest.fixture -def fake_pre_pull_images() -> list[DockerGenericTag]: - return TypeAdapter(list[DockerGenericTag]).validate_python( - [ - "nginx:latest", - "itisfoundation/my-very-nice-service:latest", - "simcore/services/dynamic/another-nice-one:2.4.5", - "asd", - ] - ) - - -@pytest.fixture -def ec2_instances_allowed_types_with_only_1_buffered( - faker: Faker, - fake_pre_pull_images: list[DockerGenericTag], - external_ec2_instances_allowed_types: None | dict[str, EC2InstanceBootSpecific], -) -> dict[InstanceTypeType, EC2InstanceBootSpecific]: - if not external_ec2_instances_allowed_types: - return { - "t2.micro": EC2InstanceBootSpecific( - ami_id=faker.pystr(), - pre_pull_images=fake_pre_pull_images, - buffer_count=faker.pyint(min_value=1, max_value=10), - ) - } - - allowed_ec2_types = external_ec2_instances_allowed_types - allowed_ec2_types_with_buffer_defined = dict( - filter( - lambda instance_type_and_settings: instance_type_and_settings[ - 1 - ].buffer_count - > 0, - allowed_ec2_types.items(), - ) - ) - assert ( - allowed_ec2_types_with_buffer_defined - ), "one type with buffer is needed for the tests!" - assert ( - len(allowed_ec2_types_with_buffer_defined) == 1 - ), "more than one type with buffer is disallowed in this test!" - return { - TypeAdapter(InstanceTypeType).validate_python(k): v - for k, v in allowed_ec2_types_with_buffer_defined.items() - } +from types_aiobotocore_ec2.type_defs import FilterTypeDef @pytest.fixture @@ -345,96 +290,6 @@ async def test_monitor_buffer_machines( ) -@pytest.fixture -async def create_buffer_machines( - ec2_client: EC2Client, - aws_ami_id: str, - app_settings: ApplicationSettings, - initialized_app: FastAPI, -) -> Callable[ - [int, InstanceTypeType, InstanceStateNameType, list[DockerGenericTag]], - Awaitable[list[str]], -]: - async def _do( - num: int, - instance_type: InstanceTypeType, - instance_state_name: InstanceStateNameType, - pre_pull_images: list[DockerGenericTag], - ) -> list[str]: - assert app_settings.AUTOSCALING_EC2_INSTANCES - - assert instance_state_name in [ - "running", - "stopped", - ], "only 'running' and 'stopped' are supported for testing" - - resource_tags: list[TagTypeDef] = [ - {"Key": tag_key, "Value": tag_value} - for tag_key, tag_value in get_deactivated_buffer_ec2_tags( - initialized_app, DynamicAutoscaling() - ).items() - ] - if pre_pull_images is not None and instance_state_name == "stopped": - resource_tags.append( - { - "Key": PRE_PULLED_IMAGES_EC2_TAG_KEY, - "Value": f"{json_dumps(pre_pull_images)}", - } - ) - with log_context( - logging.INFO, f"creating {num} buffer machines of {instance_type}" - ): - instances = await ec2_client.run_instances( - ImageId=aws_ami_id, - MaxCount=num, - MinCount=num, - InstanceType=instance_type, - KeyName=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_KEY_NAME, - SecurityGroupIds=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_SECURITY_GROUP_IDS, - SubnetId=app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_SUBNET_ID, - IamInstanceProfile={ - "Arn": app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_ATTACHED_IAM_PROFILE - }, - TagSpecifications=[ - {"ResourceType": "instance", "Tags": resource_tags}, - {"ResourceType": "volume", "Tags": resource_tags}, - {"ResourceType": "network-interface", "Tags": resource_tags}, - ], - UserData="echo 'I am pytest'", - ) - instance_ids = [ - i["InstanceId"] for i in instances["Instances"] if "InstanceId" in i - ] - - waiter = ec2_client.get_waiter("instance_exists") - await waiter.wait(InstanceIds=instance_ids) - instances = await ec2_client.describe_instances(InstanceIds=instance_ids) - assert "Reservations" in instances - assert instances["Reservations"] - assert "Instances" in instances["Reservations"][0] - assert len(instances["Reservations"][0]["Instances"]) == num - for instance in instances["Reservations"][0]["Instances"]: - assert "State" in instance - assert "Name" in instance["State"] - assert instance["State"]["Name"] == "running" - - if instance_state_name == "stopped": - await ec2_client.stop_instances(InstanceIds=instance_ids) - instances = await ec2_client.describe_instances(InstanceIds=instance_ids) - assert "Reservations" in instances - assert instances["Reservations"] - assert "Instances" in instances["Reservations"][0] - assert len(instances["Reservations"][0]["Instances"]) == num - for instance in instances["Reservations"][0]["Instances"]: - assert "State" in instance - assert "Name" in instance["State"] - assert instance["State"]["Name"] == "stopped" - - return instance_ids - - return _do - - @dataclass class _BufferMachineParams: instance_state_name: InstanceStateNameType @@ -652,29 +507,6 @@ async def test_monitor_buffer_machines_terminates_unneeded_pool( ) -@pytest.fixture -def buffer_count( - ec2_instances_allowed_types_with_only_1_buffered: dict[ - InstanceTypeType, EC2InstanceBootSpecific - ], -) -> int: - def _by_buffer_count( - instance_type_and_settings: tuple[InstanceTypeType, EC2InstanceBootSpecific] - ) -> bool: - _, boot_specific = instance_type_and_settings - return boot_specific.buffer_count > 0 - - allowed_ec2_types = ec2_instances_allowed_types_with_only_1_buffered - allowed_ec2_types_with_buffer_defined = dict( - filter(_by_buffer_count, allowed_ec2_types.items()) - ) - assert allowed_ec2_types_with_buffer_defined, "you need one type with buffer" - assert ( - len(allowed_ec2_types_with_buffer_defined) == 1 - ), "more than one type with buffer is disallowed in this test!" - return next(iter(allowed_ec2_types_with_buffer_defined.values())).buffer_count - - @pytest.fixture def pre_pull_images( ec2_instances_allowed_types_with_only_1_buffered: dict[InstanceTypeType, Any] diff --git a/services/autoscaling/tests/unit/test_utils_auto_scaling_core.py b/services/autoscaling/tests/unit/test_utils_auto_scaling_core.py index f576292ec6b..5a5a3240057 100644 --- a/services/autoscaling/tests/unit/test_utils_auto_scaling_core.py +++ b/services/autoscaling/tests/unit/test_utils_auto_scaling_core.py @@ -323,7 +323,7 @@ def test_sort_empty_drained_nodes( def test_sort_drained_nodes( - mock_machines_buffer: int, + with_instances_machines_hot_buffer: EnvVarsDict, minimal_configuration: None, app_settings: ApplicationSettings, random_fake_available_instances: list[EC2InstanceType], @@ -332,7 +332,9 @@ def test_sort_drained_nodes( ): machine_buffer_type = get_machine_buffer_type(random_fake_available_instances) _NUM_DRAINED_NODES = 20 - _NUM_NODE_WITH_TYPE_BUFFER = 3 * mock_machines_buffer + _NUM_NODE_WITH_TYPE_BUFFER = ( + 3 * app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER + ) _NUM_NODES_TERMINATING = 13 fake_drained_nodes = [] for _ in range(_NUM_DRAINED_NODES): @@ -388,10 +390,6 @@ def test_sort_drained_nodes( app_settings, fake_drained_nodes, random_fake_available_instances ) assert app_settings.AUTOSCALING_EC2_INSTANCES - assert ( - mock_machines_buffer - == app_settings.AUTOSCALING_EC2_INSTANCES.EC2_INSTANCES_MACHINES_BUFFER - ) assert len(sorted_drained_nodes) == ( _NUM_DRAINED_NODES + _NUM_NODE_WITH_TYPE_BUFFER From 854af6e5fb0ca3bc5daa4402986ee84f37710d91 Mon Sep 17 00:00:00 2001 From: Odei Maiz <33152403+odeimaiz@users.noreply.github.com> Date: Mon, 16 Dec 2024 09:37:01 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20[Frontend]=20Drag&Drop:=20Proje?= =?UTF-8?q?cts=20and=20Folders=20(#6957)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../source/class/osparc/auth/ui/LoginView.js | 2 + .../source/class/osparc/dashboard/CardBase.js | 52 ++++- ...gleButtonContainer.js => CardContainer.js} | 34 ++-- .../class/osparc/dashboard/DragDropHelpers.js | 188 ++++++++++++++++++ .../class/osparc/dashboard/DragWidget.js | 103 ++++++++++ .../osparc/dashboard/FolderButtonBase.js | 2 +- .../osparc/dashboard/FolderButtonItem.js | 63 +++++- .../class/osparc/dashboard/FolderButtonNew.js | 29 ++- .../class/osparc/dashboard/GridButtonBase.js | 5 +- .../class/osparc/dashboard/GridButtonItem.js | 43 +--- .../osparc/dashboard/GridButtonLoadMore.js | 4 - .../class/osparc/dashboard/GridButtonNew.js | 4 - .../osparc/dashboard/GridButtonPlaceholder.js | 7 - ...onContainer.js => GroupedCardContainer.js} | 8 +- .../class/osparc/dashboard/ListButtonBase.js | 9 +- .../class/osparc/dashboard/ListButtonItem.js | 50 +---- .../osparc/dashboard/ListButtonLoadMore.js | 4 - .../class/osparc/dashboard/ListButtonNew.js | 4 - .../osparc/dashboard/ListButtonPlaceholder.js | 7 - .../class/osparc/dashboard/MoveResourceTo.js | 2 +- .../class/osparc/dashboard/NewStudies.js | 6 +- .../osparc/dashboard/ResourceBrowserBase.js | 10 + .../dashboard/ResourceContainerManager.js | 20 +- .../class/osparc/dashboard/ServiceBrowser.js | 2 +- .../class/osparc/dashboard/StudyBrowser.js | 157 +++++++++------ .../class/osparc/dashboard/TemplateBrowser.js | 1 - .../osparc/dashboard/WorkspaceButtonBase.js | 13 +- .../osparc/dashboard/WorkspaceButtonItem.js | 13 +- .../osparc/dashboard/WorkspaceButtonNew.js | 37 ++-- .../dashboard/WorkspacesAndFoldersTree.js | 10 +- .../dashboard/WorkspacesAndFoldersTreeItem.js | 81 +++++++- .../class/osparc/desktop/account/MyAccount.js | 23 ++- .../class/osparc/file/FileLabelWithActions.js | 2 +- .../source/class/osparc/pricing/UnitEditor.js | 11 +- .../class/osparc/service/ServiceList.js | 59 +++--- .../class/osparc/service/ServiceListItem.js | 10 +- .../source/class/osparc/theme/Appearance.js | 24 +++ .../class/osparc/workbench/ServiceCatalog.js | 2 +- 38 files changed, 777 insertions(+), 324 deletions(-) rename services/static-webserver/client/source/class/osparc/dashboard/{ToggleButtonContainer.js => CardContainer.js} (67%) create mode 100644 services/static-webserver/client/source/class/osparc/dashboard/DragDropHelpers.js create mode 100644 services/static-webserver/client/source/class/osparc/dashboard/DragWidget.js rename services/static-webserver/client/source/class/osparc/dashboard/{GroupedToggleButtonContainer.js => GroupedCardContainer.js} (95%) diff --git a/services/static-webserver/client/source/class/osparc/auth/ui/LoginView.js b/services/static-webserver/client/source/class/osparc/auth/ui/LoginView.js index 56972d7eb6f..fdbcebeaec2 100644 --- a/services/static-webserver/client/source/class/osparc/auth/ui/LoginView.js +++ b/services/static-webserver/client/source/class/osparc/auth/ui/LoginView.js @@ -140,6 +140,8 @@ qx.Class.define("osparc.auth.ui.LoginView", { `; } const disclaimer = osparc.announcement.AnnouncementUIFactory.createLoginAnnouncement(this.tr("Disclaimer"), text); + disclaimer.getChildren()[0].setFont("text-14"); // title + disclaimer.getChildren()[1].setFont("text-12"); // description this.add(disclaimer); this.add(new qx.ui.core.Spacer(), { diff --git a/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js b/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js index 0d058644bce..c9171ebc2e2 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js @@ -16,7 +16,7 @@ ************************************************************************ */ qx.Class.define("osparc.dashboard.CardBase", { - extend: qx.ui.form.ToggleButton, + extend: qx.ui.core.Widget, implement: [qx.ui.form.IModel, osparc.filter.IFilterable], include: [qx.ui.form.MModelProperty, osparc.filter.MFilterable], type: "abstract", @@ -33,6 +33,8 @@ qx.Class.define("osparc.dashboard.CardBase", { "pointerout", "focusout" ].forEach(e => this.addListener(e, this._onPointerOut, this)); + + this.addListener("changeSelected", this.__evalSelectedButton, this); }, events: { @@ -237,6 +239,20 @@ qx.Class.define("osparc.dashboard.CardBase", { nullable: true }, + selected: { + check: "Boolean", + init: false, + nullable: false, + event: "changeSelected", + }, + + icon: { + check: "String", + init: null, + nullable: true, + apply: "_applyIcon", + }, + resourceData: { check: "Object", nullable: false, @@ -246,7 +262,8 @@ qx.Class.define("osparc.dashboard.CardBase", { resourceType: { check: ["study", "template", "service"], - nullable: false, + init: true, + nullable: true, event: "changeResourceType" }, @@ -365,7 +382,7 @@ qx.Class.define("osparc.dashboard.CardBase", { check: "Boolean", init: false, nullable: false, - apply: "_applyMultiSelectionMode" + apply: "__applyMultiSelectionMode" }, fetching: { @@ -444,6 +461,35 @@ qx.Class.define("osparc.dashboard.CardBase", { }); }, + __applyMultiSelectionMode: function(value) { + if (!value) { + this.setSelected(false); + } + this.__evalSelectedButton(); + }, + + __evalSelectedButton: function() { + if ( + this.hasChildControl("menu-button") && + this.hasChildControl("tick-selected") && + this.hasChildControl("tick-unselected") + ) { + const menuButton = this.getChildControl("menu-button"); + const tick = this.getChildControl("tick-selected"); + const untick = this.getChildControl("tick-unselected"); + if (this.isResourceType("study") && this.isMultiSelectionMode()) { + const selected = this.getSelected(); + menuButton.setVisibility("excluded"); + tick.setVisibility(selected ? "visible" : "excluded"); + untick.setVisibility(selected ? "excluded" : "visible"); + } else { + menuButton.setVisibility("visible"); + tick.setVisibility("excluded"); + untick.setVisibility("excluded"); + } + } + }, + __applyUuid: function(value, old) { const resourceType = this.getResourceType() || "study"; osparc.utils.Utils.setIdToWidget(this, resourceType + "BrowserListItem_" + value); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ToggleButtonContainer.js b/services/static-webserver/client/source/class/osparc/dashboard/CardContainer.js similarity index 67% rename from services/static-webserver/client/source/class/osparc/dashboard/ToggleButtonContainer.js rename to services/static-webserver/client/source/class/osparc/dashboard/CardContainer.js index bbabe433161..047b047e8f7 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ToggleButtonContainer.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/CardContainer.js @@ -6,9 +6,9 @@ */ /** - * Container for GridButtonItems and ListButtonItems (ToggleButtons), with some convenient methods. + * Container for GridButtons and ListButtons (CardBase, FolderButtonBase and WorkspaceButtonBase), with some convenient methods. */ -qx.Class.define("osparc.dashboard.ToggleButtonContainer", { +qx.Class.define("osparc.dashboard.CardContainer", { extend: qx.ui.container.Composite, construct: function() { @@ -22,20 +22,30 @@ qx.Class.define("osparc.dashboard.ToggleButtonContainer", { "changeVisibility": "qx.event.type.Data" }, + statics: { + isValidCard: function(widget) { + return ( + widget instanceof osparc.dashboard.CardBase || + widget instanceof osparc.dashboard.FolderButtonBase || + widget instanceof osparc.dashboard.WorkspaceButtonBase + ); + }, + }, + members: { __lastSelectedIdx: null, // overridden add: function(child, options) { - if (child instanceof qx.ui.form.ToggleButton) { + if (this.self().isValidCard(child)) { if (osparc.dashboard.ResourceContainerManager.cardExists(this, child)) { return; } this.base(arguments, child, options); - child.addListener("changeValue", () => this.fireDataEvent("changeSelection", this.getSelection()), this); + child.addListener("changeSelected", () => this.fireDataEvent("changeSelection", this.getSelection()), this); child.addListener("changeVisibility", () => this.fireDataEvent("changeVisibility", this.__getVisibles()), this); } else { - console.error("ToggleButtonContainer only allows ToggleButton as its children."); + console.error("CardContainer only allows CardBase as its children."); } }, @@ -43,7 +53,7 @@ qx.Class.define("osparc.dashboard.ToggleButtonContainer", { * Resets the selection so no toggle button is checked. */ resetSelection: function() { - this.getChildren().map(button => button.setValue(false)); + this.getChildren().map(button => button.setSelected(false)); this.__lastSelectedIdx = null; this.fireDataEvent("changeSelection", this.getSelection()); }, @@ -52,7 +62,7 @@ qx.Class.define("osparc.dashboard.ToggleButtonContainer", { * Returns an array that contains all buttons that are checked. */ getSelection: function() { - return this.getChildren().filter(button => button.getValue()); + return this.getChildren().filter(button => button.getSelected()); }, /** @@ -63,18 +73,18 @@ qx.Class.define("osparc.dashboard.ToggleButtonContainer", { }, /** - * Sets the given button's value to true (checks it) and unchecks all other buttons. If the given button is not present, - * every button in the container will get a false value (unchecked). - * @param {qx.ui.form.ToggleButton} child Button that will be checked + * Sets the given button's select prop to true (checks it) and unchecks all other buttons. If the given button is not present, + * every button in the container will get a unselected (unchecked). + * @param {qx.ui.form.CardBase} child Button that will be checked */ selectOne: function(child) { - this.getChildren().map(button => button.setValue(button === child)); + this.getChildren().map(button => button.setSelected(button === child)); this.setLastSelectedIndex(this.getIndex(child)); }, /** * Gets the index in the container of the given button. - * @param {qx.ui.form.ToggleButton} child Button that will be checked + * @param {qx.ui.form.CardBase} child Button that will be checked */ getIndex: function(child) { return this.getChildren().findIndex(button => button === child); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/DragDropHelpers.js b/services/static-webserver/client/source/class/osparc/dashboard/DragDropHelpers.js new file mode 100644 index 00000000000..83aed499cb4 --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/dashboard/DragDropHelpers.js @@ -0,0 +1,188 @@ +/* ************************************************************************ + + osparc - the simcore frontend + + https://osparc.io + + Copyright: + 2024 IT'IS Foundation, https://itis.swiss + + License: + MIT: https://opensource.org/licenses/MIT + + Authors: + * Odei Maiz (odeimaiz) + +************************************************************************ */ + +qx.Class.define("osparc.dashboard.DragDropHelpers", { + type: "static", + + statics: { + moveStudy: { + dragStart: function(event, studyItem, studyDataOrigin) { + event.addAction("move"); + event.addType("osparc-moveStudy"); + event.addData("osparc-moveStudy", { + "studyDataOrigin": studyDataOrigin, + }); + + // init drag indicator + const dragWidget = osparc.dashboard.DragWidget.getInstance(); + dragWidget.getChildControl("dragged-resource").set({ + label: studyDataOrigin["name"], + icon: "@FontAwesome5Solid/file/16", + }); + dragWidget.start(); + + // make it semi transparent while being dragged + studyItem.setOpacity(0.2); + }, + + dragOver: function(event, folderItem, workspaceDestId) { + let compatible = false; + const studyDataOrigin = event.getData("osparc-moveStudy")["studyDataOrigin"]; + const workspaceIdOrigin = studyDataOrigin["workspaceId"]; + const workspaceOrigin = osparc.store.Workspaces.getInstance().getWorkspace(workspaceIdOrigin); + const workspaceDest = osparc.store.Workspaces.getInstance().getWorkspace(workspaceDestId); + // Compatibility checks: + // - Drag over "Shared Workspaces" (0) + // - No + // - My Workspace -> My Workspace (1) + // - Yes + // - My Workspace -> Shared Workspace (2) + // - Delete on Study + // - Write on dest Workspace + // - Shared Workspace -> My Workspace (3) + // - Delete on origin Workspace + // - Shared Workspace -> Shared Workspace (4) + // - Delete on origin Workspace + // - Write on dest Workspace + if (workspaceDestId === -1) { // (0) + compatible = false; + } else if (workspaceIdOrigin === null && workspaceDestId === null) { // (1) + compatible = true; + } else if (workspaceIdOrigin === null && workspaceDest) { // (2) + compatible = osparc.data.model.Study.canIDelete(studyDataOrigin["accessRights"]) && workspaceDest.getMyAccessRights()["write"]; + } else if (workspaceOrigin && workspaceDestId === null) { // (3) + compatible = workspaceOrigin.getMyAccessRights()["delete"]; + } else if (workspaceOrigin && workspaceDest) { // (4) + compatible = workspaceOrigin.getMyAccessRights()["delete"] && workspaceDest.getMyAccessRights()["write"]; + } + + if (!compatible) { + // do not allow + event.preventDefault(); + } + + const dragWidget = osparc.dashboard.DragWidget.getInstance(); + dragWidget.setDropAllowed(compatible); + + folderItem.getChildControl("icon").setTextColor(compatible ? "strong-main" : "text"); + }, + + drop: function(event, folderItem, destWorkspaceId, destFolderId) { + const studyData = event.getData("osparc-moveStudy")["studyDataOrigin"]; + const studyToFolderData = { + studyData, + destWorkspaceId, + destFolderId, + }; + folderItem.getChildControl("icon").resetTextColor(); + return studyToFolderData; + }, + }, + + moveFolder: { + dragStart: function(event, folderItem, folderOrigin) { + event.addAction("move"); + event.addType("osparc-moveFolder"); + event.addData("osparc-moveFolder", { + "folderOrigin": folderOrigin, + }); + + // init drag indicator + const dragWidget = osparc.dashboard.DragWidget.getInstance(); + dragWidget.getChildControl("dragged-resource").set({ + label: folderOrigin.getName(), + icon: "@FontAwesome5Solid/folder/16", + }); + dragWidget.start(); + + // make it semi transparent while being dragged + folderItem.setOpacity(0.2); + }, + + dragOver: function(event, folderItem, workspaceDestId, folderDestId) { + let compatible = false; + const folderOrigin = event.getData("osparc-moveFolder")["folderOrigin"]; + const workspaceIdOrigin = folderOrigin.getWorkspaceId(); + const workspaceOrigin = osparc.store.Workspaces.getInstance().getWorkspace(workspaceIdOrigin); + const workspaceDest = osparc.store.Workspaces.getInstance().getWorkspace(workspaceDestId); + // Compatibility checks: + // - Drag over "Shared Workspaces" (0) + // - No + // - My Workspace -> My Workspace (1) + // - Yes + // - My Workspace -> Shared Workspace (2) + // - ~~Delete on Study~~ + // - Write on dest Workspace + // - Shared Workspace -> My Workspace (3) + // - Delete on origin Workspace + // - Shared Workspace -> Shared Workspace (4) + // - Delete on origin Workspace + // - Write on dest Workspace + if (workspaceDestId === -1) { // (0) + compatible = false; + } else if (folderOrigin.getFolderId() === folderDestId) { + compatible = false; + } else if (workspaceIdOrigin === null && workspaceDestId === null) { // (1) + compatible = true; + } else if (workspaceIdOrigin === null && workspaceDest) { // (2) + compatible = workspaceDest.getMyAccessRights()["write"]; + } else if (workspaceOrigin && workspaceDestId === null) { // (3) + compatible = workspaceOrigin.getMyAccessRights()["delete"]; + } else if (workspaceOrigin && workspaceDest) { // (4) + compatible = workspaceOrigin.getMyAccessRights()["delete"] && workspaceDest.getMyAccessRights()["write"]; + } + + if (!compatible) { + // do not allow + event.preventDefault(); + } + + const dragWidget = osparc.dashboard.DragWidget.getInstance(); + dragWidget.setDropAllowed(compatible); + + folderItem.getChildControl("icon").setTextColor(compatible ? "strong-main" : "text"); + }, + + drop: function(event, folderItem, destWorkspaceId, destFolderId) { + const folderOrigin = event.getData("osparc-moveFolder")["folderOrigin"]; + const folderToFolderData = { + folderId: folderOrigin.getFolderId(), + destWorkspaceId, + destFolderId, + }; + folderItem.getChildControl("icon").resetTextColor(); + return folderToFolderData; + }, + }, + + dragLeave: function(item) { + const dragWidget = osparc.dashboard.DragWidget.getInstance(); + dragWidget.setDropAllowed(false); + + item.getChildControl("icon").resetTextColor(); + }, + + dragEnd: function(draggedItem) { + // bring back opacity after drag + draggedItem.setOpacity(1); + + // hide drag indicator + const dragWidget = osparc.dashboard.DragWidget.getInstance(); + dragWidget.end(); + } + } +}); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/DragWidget.js b/services/static-webserver/client/source/class/osparc/dashboard/DragWidget.js new file mode 100644 index 00000000000..64a1c188f1f --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/dashboard/DragWidget.js @@ -0,0 +1,103 @@ +/* ************************************************************************ + + osparc - the simcore frontend + + https://osparc.io + + Copyright: + 2024 IT'IS Foundation, https://itis.swiss + + License: + MIT: https://opensource.org/licenses/MIT + + Authors: + * Odei Maiz (odeimaiz) + +************************************************************************ */ + +qx.Class.define("osparc.dashboard.DragWidget", { + extend: qx.ui.core.Widget, + type: "singleton", + + construct: function() { + this.base(arguments); + + this._setLayout(new qx.ui.layout.HBox(10).set({ + alignY: "middle", + })); + + this.set({ + opacity: 0.9, + padding: 10, + zIndex: 1000, + backgroundColor: "strong-main", + decorator: "rounded", + visibility: "excluded", + }); + + const root = qx.core.Init.getApplication().getRoot(); + root.add(this); + + this.initDropAllowed(); + }, + + properties: { + dropAllowed: { + check: "Boolean", + nullable: false, + init: null, + apply: "__dropAllowed", + }, + }, + + members: { + _createChildControlImpl: function(id) { + let control; + switch (id) { + case "allowed-icon": + control = new qx.ui.basic.Image(); + this._add(control); + break; + case "dragged-resource": + control = new qx.ui.basic.Atom().set({ + font: "text-14", + }); + this._add(control); + break; + } + return control || this.base(arguments, id); + }, + + __dropAllowed: function(allowed) { + this.getChildControl("allowed-icon").set({ + source: allowed ? "@FontAwesome5Solid/check/14" : "@FontAwesome5Solid/times/14", + textColor: allowed ? "text" : "danger-red", + }); + }, + + __onMouseMoveDragging: function(e) { + if (this.getContentElement()) { + // place it next to the "dragdrop-own-cursor" indicator + const domEl = this.getContentElement().getDomElement(); + domEl.style.left = `${e.pageX + 15}px`; + domEl.style.top = `${e.pageY + 5}px`; + } + }, + + start: function() { + this.show(); + document.addEventListener("mousemove", this.__onMouseMoveDragging.bind(this), false); + + const cursor = qx.ui.core.DragDropCursor.getInstance(); + cursor.setAppearance("dragdrop-no-cursor"); + }, + + end: function() { + this.exclude(); + document.removeEventListener("mousemove", this.__onMouseMoveDragging.bind(this), false); + + const cursor = qx.ui.core.DragDropCursor.getInstance(); + cursor.setAppearance("dragdrop-cursor"); + }, + } +}); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonBase.js b/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonBase.js index ff567a659cb..435e63b2129 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonBase.js @@ -16,7 +16,7 @@ ************************************************************************ */ qx.Class.define("osparc.dashboard.FolderButtonBase", { - extend: qx.ui.form.ToggleButton, + extend: qx.ui.core.Widget, implement: [qx.ui.form.IModel, osparc.filter.IFilterable], include: [qx.ui.form.MModelProperty, osparc.filter.MFilterable], type: "abstract", diff --git a/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonItem.js b/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonItem.js index ac919b73579..4d11a423c34 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonItem.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonItem.js @@ -33,7 +33,7 @@ qx.Class.define("osparc.dashboard.FolderButtonItem", { appearance: "pb-study" }); - this.addListener("changeValue", e => this.__itemSelected(e.getData()), this); + this.addListener("tap", this.__itemSelected, this); this.setPriority(osparc.dashboard.CardBase.CARD_PRIORITY.ITEM); @@ -50,6 +50,8 @@ qx.Class.define("osparc.dashboard.FolderButtonItem", { "untrashFolderRequested": "qx.event.type.Data", "deleteFolderRequested": "qx.event.type.Data", "changeContext": "qx.event.type.Data", + "studyToFolderRequested": "qx.event.type.Data", + "folderToFolderRequested": "qx.event.type.Data", }, properties: { @@ -152,6 +154,54 @@ qx.Class.define("osparc.dashboard.FolderButtonItem", { osparc.utils.Utils.setIdToWidget(this, "folderItem_" + folder.getFolderId()); this.__addMenuButton(); + + this.__attachDragHandlers(); + this.__attachDropHandlers(); + }, + + __attachDragHandlers: function() { + this.setDraggable(true); + + this.addListener("dragstart", e => { + const folderOrigin = this.getFolder(); + osparc.dashboard.DragDropHelpers.moveFolder.dragStart(e, this, folderOrigin); + }); + + this.addListener("dragend", () => { + osparc.dashboard.DragDropHelpers.dragEnd(this); + }); + }, + + __attachDropHandlers: function() { + this.setDroppable(true); + + this.addListener("dragover", e => { + const folderDest = this.getFolder(); + if (e.supportsType("osparc-moveStudy")) { + osparc.dashboard.DragDropHelpers.moveStudy.dragOver(e, this, folderDest.getWorkspaceId(), folderDest.getFolderId()); + } else if (e.supportsType("osparc-moveFolder")) { + osparc.dashboard.DragDropHelpers.moveFolder.dragOver(e, this, folderDest.getWorkspaceId(), folderDest.getFolderId()); + } + }); + + this.addListener("dragleave", () => { + osparc.dashboard.DragDropHelpers.dragLeave(this); + }); + + this.addListener("dragend", () => { + osparc.dashboard.DragDropHelpers.dragLeave(this); + }); + + this.addListener("drop", e => { + const folderDest = this.getFolder(); + if (e.supportsType("osparc-moveStudy")) { + const studyToFolderData = osparc.dashboard.DragDropHelpers.moveStudy.drop(e, this, folderDest.getWorkspaceId(), folderDest.getFolderId()); + this.fireDataEvent("studyToFolderRequested", studyToFolderData); + } else if (e.supportsType("osparc-moveFolder")) { + const folderToFolderData = osparc.dashboard.DragDropHelpers.moveFolder.drop(e, this, folderDest.getWorkspaceId(), folderDest.getFolderId()); + this.fireDataEvent("folderToFolderRequested", folderToFolderData); + } + }); }, __applyWorkspaceId: function(workspaceId) { @@ -188,9 +238,9 @@ qx.Class.define("osparc.dashboard.FolderButtonItem", { const menuButton = this.getChildControl("menu-button"); menuButton.setVisibility("visible"); - const menu = new qx.ui.menu.Menu().set({ - position: "bottom-right" - }); + const menu = new qx.ui.menu.Menu(); + menu.setPosition("bottom-right"); + osparc.utils.Utils.prettifyMenu(menu); const studyBrowserContext = osparc.store.Store.getInstance().getStudyBrowserContext(); if ( @@ -240,13 +290,12 @@ qx.Class.define("osparc.dashboard.FolderButtonItem", { menuButton.setMenu(menu); }, - __itemSelected: function(newVal) { + __itemSelected: function() { const studyBrowserContext = osparc.store.Store.getInstance().getStudyBrowserContext(); // do not allow selecting workspace - if (studyBrowserContext !== "trash" && newVal) { + if (studyBrowserContext !== "trash") { this.fireDataEvent("folderSelected", this.getFolderId()); } - this.setValue(false); }, __editFolder: function() { diff --git a/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonNew.js b/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonNew.js index 6fe4c7d9bba..42bdb7128b4 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonNew.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/FolderButtonNew.js @@ -30,7 +30,7 @@ qx.Class.define("osparc.dashboard.FolderButtonNew", { appearance: "pb-new" }); - this.addListener("changeValue", e => this.__itemSelected(e.getData()), this); + this.addListener("tap", this.__itemSelected, this); this.setPriority(osparc.dashboard.CardBase.CARD_PRIORITY.NEW); @@ -77,22 +77,19 @@ qx.Class.define("osparc.dashboard.FolderButtonNew", { this.getChildControl("title"); }, - __itemSelected: function(newVal) { - if (newVal) { - const newFolder = true; - const folderEditor = new osparc.editor.FolderEditor(newFolder); - const title = this.tr("New Folder"); - const win = osparc.ui.window.Window.popUpInWindow(folderEditor, title, 300, 120); - folderEditor.addListener("createFolder", () => { - const name = folderEditor.getLabel(); - this.fireDataEvent("createFolder", { - name, - }); - win.close(); + __itemSelected: function() { + const newFolder = true; + const folderEditor = new osparc.editor.FolderEditor(newFolder); + const title = this.tr("New Folder"); + const win = osparc.ui.window.Window.popUpInWindow(folderEditor, title, 300, 120); + folderEditor.addListener("createFolder", () => { + const name = folderEditor.getLabel(); + this.fireDataEvent("createFolder", { + name, }); - folderEditor.addListener("cancel", () => win.close()); - } - this.setValue(false); + win.close(); + }); + folderEditor.addListener("cancel", () => win.close()); } } }); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonBase.js b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonBase.js index ad0a78c20c1..e1b7c72ff71 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonBase.js @@ -28,6 +28,8 @@ qx.Class.define("osparc.dashboard.GridButtonBase", { construct: function() { this.base(arguments); + this._setLayout(new qx.ui.layout.Canvas()); + this.set({ width: this.self().ITEM_WIDTH, height: this.self().ITEM_HEIGHT, @@ -35,8 +37,6 @@ qx.Class.define("osparc.dashboard.GridButtonBase", { allowGrowX: false }); - this._setLayout(new qx.ui.layout.Canvas()); - this.getChildControl("main-layout"); }, @@ -107,7 +107,6 @@ qx.Class.define("osparc.dashboard.GridButtonBase", { }, members: { - // overridden _createChildControlImpl: function(id) { let layout; diff --git a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonItem.js b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonItem.js index e9019262342..003648f7629 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonItem.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonItem.js @@ -31,8 +31,6 @@ qx.Class.define("osparc.dashboard.GridButtonItem", { this.base(arguments); this.setPriority(osparc.dashboard.CardBase.CARD_PRIORITY.ITEM); - - this.addListener("changeValue", this.__itemSelected, this); }, statics: { @@ -176,45 +174,6 @@ qx.Class.define("osparc.dashboard.GridButtonItem", { return control || this.base(arguments, id); }, - // overridden - _applyMultiSelectionMode: function(value) { - if (value) { - const menuButton = this.getChildControl("menu-button"); - menuButton.setVisibility("excluded"); - this.__itemSelected(); - } else { - this.__showMenuOnly(); - } - }, - - __itemSelected: function() { - if (this.isItemNotClickable()) { - this.setValue(false); - return; - } - - if (this.isResourceType("study") && this.isMultiSelectionMode()) { - const selected = this.getValue(); - - const tick = this.getChildControl("tick-selected"); - tick.setVisibility(selected ? "visible" : "excluded"); - - const untick = this.getChildControl("tick-unselected"); - untick.setVisibility(selected ? "excluded" : "visible"); - } else { - this.__showMenuOnly(); - } - }, - - __showMenuOnly: function() { - const menuButton = this.getChildControl("menu-button"); - menuButton.setVisibility("visible"); - const tick = this.getChildControl("tick-selected"); - tick.setVisibility("excluded"); - const untick = this.getChildControl("tick-unselected"); - untick.setVisibility("excluded"); - }, - // overridden _applyLastChangeDate: function(value, old) { if (value && (this.isResourceType("study") || this.isResourceType("template"))) { @@ -277,7 +236,7 @@ qx.Class.define("osparc.dashboard.GridButtonItem", { const menuButton = this.getChildControl("menu-button"); if (menu) { menuButton.setMenu(menu); - menu.setPosition("top-left"); + menu.setPosition("bottom-left"); osparc.utils.Utils.prettifyMenu(menu); osparc.utils.Utils.setIdToWidget(menu, "studyItemMenuMenu"); this.evaluateMenuButtons(); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonLoadMore.js b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonLoadMore.js index a10d57dcaa2..af3bf1ae666 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonLoadMore.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonLoadMore.js @@ -50,10 +50,6 @@ qx.Class.define("osparc.dashboard.GridButtonLoadMore", { this.setEnabled(!value); }, - _onToggleChange: function(e) { - this.setValue(false); - }, - _shouldApplyFilter: function() { return false; }, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonNew.js b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonNew.js index 4a2a3577e31..3cb8a8c92b7 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonNew.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonNew.js @@ -67,10 +67,6 @@ qx.Class.define("osparc.dashboard.GridButtonNew", { }, members: { - _onToggleChange: function(e) { - this.setValue(false); - }, - _shouldApplyFilter: function(data) { return false; }, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonPlaceholder.js b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonPlaceholder.js index 89f9c94270a..b6eb9906ef7 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/GridButtonPlaceholder.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/GridButtonPlaceholder.js @@ -23,9 +23,6 @@ qx.Class.define("osparc.dashboard.GridButtonPlaceholder", { this.setPriority(osparc.dashboard.CardBase.CARD_PRIORITY.PLACEHOLDER); - // make unselectable - this.addListener("changeValue", () => this.setValue(false), this); - this.set({ cursor: "not-allowed" }); @@ -122,10 +119,6 @@ qx.Class.define("osparc.dashboard.GridButtonPlaceholder", { return true; }, - _onToggleChange: function() { - this.setValue(false); - }, - _shouldApplyFilter: function(data) { if (data.text) { const checks = [ diff --git a/services/static-webserver/client/source/class/osparc/dashboard/GroupedToggleButtonContainer.js b/services/static-webserver/client/source/class/osparc/dashboard/GroupedCardContainer.js similarity index 95% rename from services/static-webserver/client/source/class/osparc/dashboard/GroupedToggleButtonContainer.js rename to services/static-webserver/client/source/class/osparc/dashboard/GroupedCardContainer.js index d5dc5505d09..2223517302c 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/GroupedToggleButtonContainer.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/GroupedCardContainer.js @@ -15,7 +15,7 @@ ************************************************************************ */ -qx.Class.define("osparc.dashboard.GroupedToggleButtonContainer", { +qx.Class.define("osparc.dashboard.GroupedCardContainer", { extend: qx.ui.core.Widget, construct: function() { @@ -118,7 +118,7 @@ qx.Class.define("osparc.dashboard.GroupedToggleButtonContainer", { const expanded = this.isExpanded(); const showAllBtn = this.__showAllButton; if (expanded) { - contentContainer = new osparc.dashboard.ToggleButtonContainer(); + contentContainer = new osparc.dashboard.CardContainer(); showAllBtn.show(); } else { const spacing = osparc.dashboard.GridButtonBase.SPACING; @@ -176,7 +176,7 @@ qx.Class.define("osparc.dashboard.GroupedToggleButtonContainer", { // overridden add: function(child, idx) { - if (child instanceof qx.ui.form.ToggleButton) { + if (osparc.dashboard.CardContainer.isValidCard(child)) { const container = this.getContentContainer(); if (osparc.dashboard.ResourceContainerManager.cardExists(container, child)) { return; @@ -189,7 +189,7 @@ qx.Class.define("osparc.dashboard.GroupedToggleButtonContainer", { } this.__childVisibilityChanged(); } else { - console.error("ToggleButtonContainer only allows ToggleButton as its children."); + console.error("CardContainer only allows CardBase as its children."); } }, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonBase.js b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonBase.js index 86decb00157..d99d33f6608 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonBase.js @@ -27,16 +27,17 @@ qx.Class.define("osparc.dashboard.ListButtonBase", { construct: function() { this.base(arguments); - this.set({ - minHeight: osparc.dashboard.ListButtonBase.ITEM_HEIGHT, - allowGrowX: true - }); const layout = new qx.ui.layout.Grid(); layout.setSpacing(10); layout.setColumnFlex(osparc.dashboard.ListButtonBase.POS.SPACER, 1); this._setLayout(layout); + this.set({ + minHeight: osparc.dashboard.ListButtonBase.ITEM_HEIGHT, + allowGrowX: true + }); + this.getChildControl("spacer"); }, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonItem.js b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonItem.js index 9c433550185..5a80947d803 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonItem.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonItem.js @@ -28,8 +28,6 @@ qx.Class.define("osparc.dashboard.ListButtonItem", { this.base(arguments); this.setPriority(osparc.dashboard.CardBase.CARD_PRIORITY.ITEM); - - this.addListener("changeValue", this.__itemSelected, this); }, statics: { @@ -262,48 +260,16 @@ qx.Class.define("osparc.dashboard.ListButtonItem", { }); }, - // overridden - _applyMultiSelectionMode: function(value) { - if (value) { - const menuButton = this.getChildControl("menu-button"); - menuButton.setVisibility("excluded"); - this.__itemSelected(); - } else { - this.__showMenuOnly(); - } - }, - - __itemSelected: function() { - if (this.isItemNotClickable()) { - this.setValue(false); - return; - } - - if (this.isResourceType("study") && this.isMultiSelectionMode()) { - const selected = this.getValue(); - - const tick = this.getChildControl("tick-selected"); - tick.setVisibility(selected ? "visible" : "excluded"); - - const untick = this.getChildControl("tick-unselected"); - untick.setVisibility(selected ? "excluded" : "visible"); - } else { - this.__showMenuOnly(); - } - }, - - __showMenuOnly: function() { - const menu = this.getChildControl("menu-button"); - this.getChildControl("menu-selection-stack").setSelection([menu]); - }, - - _applyMenu: function(value, old) { + _applyMenu: function(menu, old) { const menuButton = this.getChildControl("menu-button"); - if (value) { - menuButton.setMenu(value); - osparc.utils.Utils.setIdToWidget(value, "studyItemMenuMenu"); + if (menu) { + menuButton.setMenu(menu); + menu.setPosition("bottom-left"); + osparc.utils.Utils.prettifyMenu(menu); + osparc.utils.Utils.setIdToWidget(menu, "studyItemMenuMenu"); + this.evaluateMenuButtons(); } - menuButton.setVisibility(value ? "visible" : "excluded"); + menuButton.setVisibility(menu ? "visible" : "excluded"); } } }); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonLoadMore.js b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonLoadMore.js index cbf818c8cdc..1f0fad3e4a6 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonLoadMore.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonLoadMore.js @@ -49,10 +49,6 @@ qx.Class.define("osparc.dashboard.ListButtonLoadMore", { this.setEnabled(!value); }, - _onToggleChange: function(e) { - this.setValue(false); - }, - _shouldApplyFilter: function() { return false; }, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonNew.js b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonNew.js index d9bb0679f46..7ae28a96cf4 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonNew.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonNew.js @@ -51,10 +51,6 @@ qx.Class.define("osparc.dashboard.ListButtonNew", { }, members: { - _onToggleChange: function(e) { - this.setValue(false); - }, - _shouldApplyFilter: function(data) { return false; }, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonPlaceholder.js b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonPlaceholder.js index 7074ded3194..d813261ef3c 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ListButtonPlaceholder.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ListButtonPlaceholder.js @@ -23,9 +23,6 @@ qx.Class.define("osparc.dashboard.ListButtonPlaceholder", { this.setPriority(osparc.dashboard.CardBase.CARD_PRIORITY.PLACEHOLDER); - // make unselectable - this.addListener("changeValue", () => this.setValue(false), this); - this.__layout = this.getChildControl("progress-layout") this.set({ appearance: "pb-new", @@ -108,10 +105,6 @@ qx.Class.define("osparc.dashboard.ListButtonPlaceholder", { return true; }, - _onToggleChange: function() { - this.setValue(false); - }, - _shouldApplyFilter: function(data) { if (data.text) { const checks = [ diff --git a/services/static-webserver/client/source/class/osparc/dashboard/MoveResourceTo.js b/services/static-webserver/client/source/class/osparc/dashboard/MoveResourceTo.js index ecd26def377..aefa81ef810 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/MoveResourceTo.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/MoveResourceTo.js @@ -73,7 +73,7 @@ qx.Class.define("osparc.dashboard.MoveResourceTo", { switch (id) { case "current-location": { control = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); - const intro = new qx.ui.basic.Label(this.tr("Current location")); + const intro = new qx.ui.basic.Label(this.tr("Current location:")); control.add(intro); const workspace = osparc.store.Workspaces.getInstance().getWorkspace(this.__currentWorkspaceId); const workspaceText = workspace ? workspace.getName() : "My Workspace"; diff --git a/services/static-webserver/client/source/class/osparc/dashboard/NewStudies.js b/services/static-webserver/client/source/class/osparc/dashboard/NewStudies.js index 7c01ff5c74d..0e0d92b61b6 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/NewStudies.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/NewStudies.js @@ -27,7 +27,7 @@ qx.Class.define("osparc.dashboard.NewStudies", { this._setLayout(new qx.ui.layout.VBox(10)); - const flatList = this.__flatList = new osparc.dashboard.ToggleButtonContainer(); + const flatList = this.__flatList = new osparc.dashboard.CardContainer(); [ "changeSelection", "changeVisibility" @@ -86,7 +86,7 @@ qx.Class.define("osparc.dashboard.NewStudies", { this._add(groupContainer); }); } else { - const flatList = this.__flatList = new osparc.dashboard.ToggleButtonContainer(); + const flatList = this.__flatList = new osparc.dashboard.CardContainer(); osparc.utils.Utils.setIdToWidget(flatList, listId); [ "changeSelection", @@ -138,7 +138,7 @@ qx.Class.define("osparc.dashboard.NewStudies", { }, __createGroupContainer: function(groupId, headerLabel, headerColor = "text") { - const groupContainer = new osparc.dashboard.GroupedToggleButtonContainer().set({ + const groupContainer = new osparc.dashboard.GroupedCardContainer().set({ groupId: groupId.toString(), headerLabel, headerIcon: "", diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js b/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js index c007ca05f7e..8c3cfd23637 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js @@ -279,6 +279,8 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", { resourcesContainer.addListener("trashFolderRequested", e => this._trashFolderRequested(e.getData())); resourcesContainer.addListener("untrashFolderRequested", e => this._untrashFolderRequested(e.getData())); resourcesContainer.addListener("deleteFolderRequested", e => this._deleteFolderRequested(e.getData())); + resourcesContainer.addListener("studyToFolderRequested", e => this._studyToFolderRequested(e.getData())); + resourcesContainer.addListener("folderToFolderRequested", e => this._folderToFolderRequested(e.getData())); resourcesContainer.addListener("folderSelected", e => { const folderId = e.getData(); this._folderSelected(folderId); @@ -524,6 +526,14 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", { throw new Error("Abstract method called!"); }, + _studyToFolderRequested: function(studyId) { + throw new Error("Abstract method called!"); + }, + + _folderToFolderRequested: function(folderId) { + throw new Error("Abstract method called!"); + }, + _workspaceSelected: function(workspaceId) { throw new Error("Abstract method called!"); }, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ResourceContainerManager.js b/services/static-webserver/client/source/class/osparc/dashboard/ResourceContainerManager.js index 679c2b45cf1..55ac1f85697 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ResourceContainerManager.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ResourceContainerManager.js @@ -33,10 +33,10 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", { this.__groupedContainersList = []; if (resourceType === "study") { - const workspacesContainer = this.__workspacesContainer = new osparc.dashboard.ToggleButtonContainer(); + const workspacesContainer = this.__workspacesContainer = new osparc.dashboard.CardContainer(); this._add(workspacesContainer); - const foldersContainer = this.__foldersContainer = new osparc.dashboard.ToggleButtonContainer(); + const foldersContainer = this.__foldersContainer = new osparc.dashboard.CardContainer(); this._add(foldersContainer); } @@ -83,6 +83,8 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", { "untrashWorkspaceRequested": "qx.event.type.Data", "deleteWorkspaceRequested": "qx.event.type.Data", "changeContext": "qx.event.type.Data", + "studyToFolderRequested": "qx.event.type.Data", + "folderToFolderRequested": "qx.event.type.Data", }, statics: { @@ -118,7 +120,7 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", { __groupedContainers: null, addNonResourceCard: function(card) { - if (card instanceof qx.ui.form.ToggleButton) { + if (osparc.dashboard.CardContainer.isValidCard(card)) { if (this.getGroupBy()) { // it will always go to the no-group group const noGroupContainer = this.__getGroupContainer("no-group"); @@ -129,12 +131,12 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", { this.self().sortListByPriority(this.__nonGroupedContainer); } } else { - console.error("ToggleButtonContainer only allows ToggleButton as its children."); + console.error("CardContainer only allows CardBase as its children."); } }, removeNonResourceCard: function(card) { - if (card instanceof qx.ui.form.ToggleButton) { + if (osparc.dashboard.CardContainer.isValidCard(card)) { if (this.getGroupBy()) { const noGroupContainer = this.__getGroupContainer("no-group"); if (noGroupContainer.getContentContainer().getChildren().indexOf(card) > -1) { @@ -144,7 +146,7 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", { this.__nonGroupedContainer.remove(card); } } else { - console.error("ToggleButtonContainer only allows ToggleButton as its children."); + console.error("CardContainer only allows CardBase as its children."); } }, @@ -161,7 +163,7 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", { }, __createGroupContainer: function(groupId, headerLabel, headerColor = "text") { - const groupContainer = new osparc.dashboard.GroupedToggleButtonContainer().set({ + const groupContainer = new osparc.dashboard.GroupedCardContainer().set({ groupId: groupId.toString(), headerLabel, headerIcon: "", @@ -317,7 +319,7 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", { }, __createFlatList: function() { - const flatList = new osparc.dashboard.ToggleButtonContainer(); + const flatList = new osparc.dashboard.CardContainer(); const setContainerSpacing = () => { const spacing = this.getMode() === "grid" ? osparc.dashboard.GridButtonBase.SPACING : osparc.dashboard.ListButtonBase.SPACING; flatList.getLayout().set({ @@ -429,6 +431,8 @@ qx.Class.define("osparc.dashboard.ResourceContainerManager", { "untrashFolderRequested", "deleteFolderRequested", "changeContext", + "studyToFolderRequested", + "folderToFolderRequested", ].forEach(eName => card.addListener(eName, e => this.fireDataEvent(eName, e.getData()))); return card; }, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js index 5fbaa4ebaf7..7ae65ff0bd1 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js @@ -86,7 +86,7 @@ qx.Class.define("osparc.dashboard.ServiceBrowser", { const cards = this._resourcesContainer.reloadCards("services"); cards.forEach(card => { card.setMultiSelectionMode(this.getMultiSelection()); - card.addListener("execute", () => this.__itemClicked(card), this); + card.addListener("tap", () => this.__itemClicked(card), this); this._populateCardMenu(card); }); osparc.filter.UIFilterController.dispatch("searchBarFilter"); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js index 0a25257f247..081d63a2a96 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js @@ -48,7 +48,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { }, events: { - "publishTemplate": "qx.event.type.Data" + "publishTemplate": "qx.event.type.Data", }, properties: { @@ -100,6 +100,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { __foldersList: null, __loadingFolders: null, __loadingWorkspaces: null, + __dragWidget: null, // overridden initResources: function() { @@ -564,6 +565,13 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { return win; }, + __doMoveFolder: function(folderId, destWorkspaceId, destFolderId) { + osparc.store.Folders.getInstance().moveFolderToWorkspace(folderId, destWorkspaceId) // first move to workspace + .then(() => osparc.store.Folders.getInstance().moveFolderToFolder(folderId, destFolderId)) // then move to folder + .then(() => this.__reloadFolders()) + .catch(err => console.error(err)); + }, + _moveFolderToRequested: function(folderId) { const currentWorkspaceId = this.getCurrentWorkspaceId(); const currentFolderId = this.getCurrentFolderId(); @@ -575,19 +583,13 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { const data = e.getData(); const destWorkspaceId = data["workspaceId"]; const destFolderId = data["folderId"]; - const moveFolder = () => { - osparc.store.Folders.getInstance().moveFolderToWorkspace(folderId, destWorkspaceId) // first move to workspace - .then(() => osparc.store.Folders.getInstance().moveFolderToFolder(folderId, destFolderId)) // then move to folder - .then(() => this.__reloadFolders()) - .catch(err => console.error(err)); - } if (destWorkspaceId === currentWorkspaceId) { - moveFolder(); + this.__doMoveFolder(folderId, destWorkspaceId, destFolderId); } else { const confirmationWin = this.__showMoveToWorkspaceWarningMessage(); confirmationWin.addListener("close", () => { if (confirmationWin.getConfirmed()) { - moveFolder(); + this.__doMoveFolder(folderId, destWorkspaceId, destFolderId); } }, this); } @@ -595,6 +597,15 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { moveFolderTo.addListener("cancel", () => win.close()); }, + _folderToFolderRequested: function(data) { + const { + folderId, + destWorkspaceId, + destFolderId, + } = data; + this.__doMoveFolder(folderId, destWorkspaceId, destFolderId); + }, + _trashFolderRequested: function(folderId) { osparc.store.Folders.getInstance().trashFolder(folderId, this.getCurrentWorkspaceId()) .then(() => { @@ -638,34 +649,48 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { __configureStudyCards: function(cards) { cards.forEach(card => { card.setMultiSelectionMode(this.getMultiSelection()); - card.addListener("tap", e => { - if (card.isItemNotClickable()) { - card.setValue(false); - } else { - this.__itemClicked(card, e.getNativeEvent().shiftKey); - } - }, this); + card.addListener("tap", e => this.__studyCardClicked(card, e.getNativeEvent().shiftKey), this); this._populateCardMenu(card); + + this.__attachDragHandlers(card); }); }, - __itemClicked: function(item, isShiftPressed) { - const studiesCont = this._resourcesContainer.getFlatList(); + __attachDragHandlers: function(card) { + card.setDraggable(true); - if (isShiftPressed) { - const lastIdx = studiesCont.getLastSelectedIndex(); - const currentIdx = studiesCont.getIndex(item); - const minMax = [Math.min(lastIdx, currentIdx), Math.max(lastIdx, currentIdx)]; - for (let i=minMax[0]; i<=minMax[1]; i++) { - const card = studiesCont.getChildren()[i]; - if (card.isVisible()) { - card.setValue(true); - } - } + card.addListener("dragstart", e => { + const studyDataOrigin = card.getResourceData(); + osparc.dashboard.DragDropHelpers.moveStudy.dragStart(e, card, studyDataOrigin); + }); + + card.addListener("dragend", () => { + osparc.dashboard.DragDropHelpers.dragEnd(card); + }); + }, + + __studyCardClicked: function(item, isShiftPressed) { + if (item.isItemNotClickable()) { + item.setSelected(false); + return; } - studiesCont.setLastSelectedIndex(studiesCont.getIndex(item)); - if (!item.isMultiSelectionMode()) { + if (item.isMultiSelectionMode()) { + item.setSelected(!item.getSelected()); + const studiesCont = this._resourcesContainer.getFlatList(); + if (isShiftPressed) { + const lastIdx = studiesCont.getLastSelectedIndex(); + const currentIdx = studiesCont.getIndex(item); + const minMax = [Math.min(lastIdx, currentIdx), Math.max(lastIdx, currentIdx)]; + for (let i=minMax[0]; i<=minMax[1]; i++) { + const card = studiesCont.getChildren()[i]; + if (card.isVisible()) { + card.setSelected(true); + } + } + } + studiesCont.setLastSelectedIndex(studiesCont.getIndex(item)); + } else { const studyData = this.__getStudyData(item.getUuid(), false); this._openResourceDetails(studyData); this.resetSelection(); @@ -860,7 +885,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { newStudyBtn.setCardKey("new-study"); newStudyBtn.subscribeToFilterGroup("searchBarFilter"); osparc.utils.Utils.setIdToWidget(newStudyBtn, "newStudyBtn"); - newStudyBtn.addListener("execute", () => this.__newStudyBtnClicked(newStudyBtn)); + newStudyBtn.addListener("tap", () => this.__newStudyBtnClicked(newStudyBtn)); this._resourcesContainer.addNonResourceCard(newStudyBtn); }, @@ -880,8 +905,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { if (product in newStudiesData) { newStudyBtn.setEnabled(true); - newStudyBtn.addListener("execute", () => { - newStudyBtn.setValue(false); + newStudyBtn.addListener("tap", () => { osparc.data.Resources.get("templates") .then(templates => { if (templates) { @@ -930,7 +954,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { const newStudyFromServiceButton = (mode === "grid") ? new osparc.dashboard.GridButtonNew(title, desc) : new osparc.dashboard.ListButtonNew(title, desc); newStudyFromServiceButton.setCardKey("new-"+key); osparc.utils.Utils.setIdToWidget(newStudyFromServiceButton, newButtonInfo.idToWidget); - newStudyFromServiceButton.addListener("execute", () => this.__newStudyFromServiceBtnClicked(newStudyFromServiceButton, latestMetadata["key"], latestMetadata["version"], newButtonInfo.newStudyLabel)); + newStudyFromServiceButton.addListener("tap", () => this.__newStudyFromServiceBtnClicked(newStudyFromServiceButton, latestMetadata["key"], latestMetadata["version"], newButtonInfo.newStudyLabel)); this._resourcesContainer.addNonResourceCard(newStudyFromServiceButton); }) } @@ -987,6 +1011,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { this._addResourceFilter(); this.__connectContexts(); + this.__connectDropHandlers(); this.__addNewStudyButtons(); @@ -1134,6 +1159,16 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { this._resourceFilter.contextChanged(context, workspaceId, folderId); }, + __connectDropHandlers: function() { + const workspacesAndFoldersTree = this._resourceFilter.getWorkspacesAndFoldersTree(); + workspacesAndFoldersTree.addListener("studyToFolderRequested", e => { + this._studyToFolderRequested(e.getData()); + }); + workspacesAndFoldersTree.addListener("folderToFolderRequested", e => { + this._folderToFolderRequested(e.getData()); + }); + }, + __addSortByButton: function() { const sortByButton = new osparc.dashboard.SortedByMenuButton(); sortByButton.set({ @@ -1153,8 +1188,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { const loadMoreBtn = this._loadingResourcesBtn = (mode === "grid") ? new osparc.dashboard.GridButtonLoadMore() : new osparc.dashboard.ListButtonLoadMore(); loadMoreBtn.setCardKey("load-more"); osparc.utils.Utils.setIdToWidget(loadMoreBtn, "studiesLoading"); - loadMoreBtn.addListener("execute", () => { - loadMoreBtn.setValue(false); + loadMoreBtn.addListener("tap", () => { this._moreResourcesRequired(); }); return loadMoreBtn; @@ -1209,13 +1243,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { const selection = this._resourcesContainer.getSelection(); selection.forEach(button => { const studyData = button.getResourceData(); - this.__moveStudyToWorkspace(studyData, destWorkspaceId) // first move to workspace - .then(() => this.__moveStudyToFolder(studyData, destFolderId)) // then move to folder - .then(() => this.__removeFromStudyList(studyData["uuid"])) - .catch(err => { - console.error(err); - osparc.FlashMessenger.logAs(err.message, "ERROR"); - }); + this.__doMoveStudy(studyData, destWorkspaceId, destFolderId); }); this.resetSelection(); this.setMultiSelection(false); @@ -1324,7 +1352,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { if (osparc.dashboard.ResourceBrowserBase.isCardButtonItem(studyItem)) { studyItem.setMultiSelectionMode(value); if (value === false) { - studyItem.setValue(false); + studyItem.setSelected(false); } } }); @@ -1347,7 +1375,6 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { }, __newStudyBtnClicked: function(button) { - button.setValue(false); const minStudyData = osparc.data.model.Study.createMinStudyObject(); const existingNames = this._resourcesList.map(study => study["name"]); const title = osparc.utils.Utils.getUniqueName(minStudyData.name, existingNames); @@ -1388,7 +1415,6 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { }, __newStudyFromServiceBtnClicked: function(button, key, version, newStudyLabel) { - button.setValue(false); this._showLoadingPage(this.tr("Creating ") + osparc.product.Utils.getStudyAlias()); const contextProps = { workspaceId: this.getCurrentWorkspaceId(), @@ -1616,6 +1642,16 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { return studyBillingSettingsButton; }, + __doMoveStudy: function(studyData, destWorkspaceId, destFolderId) { + this.__moveStudyToWorkspace(studyData, destWorkspaceId) // first move to workspace + .then(() => this.__moveStudyToFolder(studyData, destFolderId)) // then move to folder + .then(() => this.__removeFromStudyList(studyData["uuid"])) + .catch(err => { + console.error(err); + osparc.FlashMessenger.logAs(err.message, "ERROR"); + }); + }, + __getMoveStudyToMenuButton: function(studyData) { const moveToButton = new qx.ui.menu.Button(this.tr("Move to..."), "@FontAwesome5Solid/folder/12"); moveToButton["moveToButton"] = true; @@ -1630,22 +1666,14 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { const data = e.getData(); const destWorkspaceId = data["workspaceId"]; const destFolderId = data["folderId"]; - const moveStudy = () => { - this.__moveStudyToWorkspace(studyData, destWorkspaceId) // first move to workspace - .then(() => this.__moveStudyToFolder(studyData, destFolderId)) // then move to folder - .then(() => this.__removeFromStudyList(studyData["uuid"])) - .catch(err => { - console.error(err); - osparc.FlashMessenger.logAs(err.message, "ERROR"); - }); - }; + if (destWorkspaceId === currentWorkspaceId) { - moveStudy(); + this.__doMoveStudy(studyData, destWorkspaceId, destFolderId); } else { const confirmationWin = this.__showMoveToWorkspaceWarningMessage(); confirmationWin.addListener("close", () => { if (confirmationWin.getConfirmed()) { - moveStudy(); + this.__doMoveStudy(studyData, destWorkspaceId, destFolderId); } }, this); } @@ -1686,11 +1714,16 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { } }; return osparc.data.Resources.fetch("studies", "moveToFolder", params) - .then(() => studyData["folderId"] = destFolderId) - .catch(err => { - console.error(err); - osparc.FlashMessenger.logAs(err.message, "ERROR"); - }); + .then(() => studyData["folderId"] = destFolderId); + }, + + _studyToFolderRequested: function(data) { + const { + studyData, + destWorkspaceId, + destFolderId, + } = data; + this.__doMoveStudy(studyData, destWorkspaceId, destFolderId); }, __getDuplicateMenuButton: function(studyData) { diff --git a/services/static-webserver/client/source/class/osparc/dashboard/TemplateBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/TemplateBrowser.js index 0b6fc8ccd26..fab2dc1eb94 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/TemplateBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/TemplateBrowser.js @@ -125,7 +125,6 @@ qx.Class.define("osparc.dashboard.TemplateBrowser", { __itemClicked: function(card) { if (!card.getBlocked()) { - card.setValue(false); const templateData = this.__getTemplateData(card.getUuid()); this._openResourceDetails(templateData); } diff --git a/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonBase.js b/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonBase.js index c0c93cc9508..a6fb451fc2d 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonBase.js @@ -16,7 +16,7 @@ ************************************************************************ */ qx.Class.define("osparc.dashboard.WorkspaceButtonBase", { - extend: qx.ui.form.ToggleButton, + extend: qx.ui.core.Widget, implement: [qx.ui.form.IModel, osparc.filter.IFilterable], include: [qx.ui.form.MModelProperty, osparc.filter.MFilterable], type: "abstract", @@ -24,14 +24,14 @@ qx.Class.define("osparc.dashboard.WorkspaceButtonBase", { construct: function() { this.base(arguments); + this._setLayout(new qx.ui.layout.Canvas()); + this.set({ width: this.self().ITEM_WIDTH, height: this.self().ITEM_HEIGHT, padding: 0 }); - this._setLayout(new qx.ui.layout.Canvas()); - this.getChildControl("main-layout"); [ @@ -51,6 +51,13 @@ qx.Class.define("osparc.dashboard.WorkspaceButtonBase", { nullable: true }, + icon: { + check: "String", + init: null, + nullable: true, + apply: "_applyIcon", + }, + resourceType: { check: ["workspace"], init: "workspace", diff --git a/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonItem.js b/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonItem.js index eb777ca5dd7..91ab3a26233 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonItem.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonItem.js @@ -33,7 +33,7 @@ qx.Class.define("osparc.dashboard.WorkspaceButtonItem", { appearance: "pb-listitem" }); - this.addListener("changeValue", e => this.__itemSelected(e.getData()), this); + this.addListener("tap", this.__itemSelected, this); this.setPriority(osparc.dashboard.CardBase.CARD_PRIORITY.ITEM); @@ -181,9 +181,9 @@ qx.Class.define("osparc.dashboard.WorkspaceButtonItem", { const menuButton = this.getChildControl("menu-button"); menuButton.setVisibility("visible"); - const menu = new qx.ui.menu.Menu().set({ - position: "bottom-right" - }); + const menu = new qx.ui.menu.Menu(); + menu.setPosition("bottom-right"); + osparc.utils.Utils.prettifyMenu(menu); const studyBrowserContext = osparc.store.Store.getInstance().getStudyBrowserContext(); if ( @@ -253,13 +253,12 @@ qx.Class.define("osparc.dashboard.WorkspaceButtonItem", { }) }, - __itemSelected: function(newVal) { + __itemSelected: function() { const studyBrowserContext = osparc.store.Store.getInstance().getStudyBrowserContext(); // do not allow selecting workspace - if (studyBrowserContext !== "trash" && newVal) { + if (studyBrowserContext !== "trash") { this.fireDataEvent("workspaceSelected", this.getWorkspaceId()); } - this.setValue(false); }, __openShareWith: function() { diff --git a/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonNew.js b/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonNew.js index dd65702503b..aa8425858a8 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonNew.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/WorkspaceButtonNew.js @@ -30,7 +30,7 @@ qx.Class.define("osparc.dashboard.WorkspaceButtonNew", { appearance: "pb-new" }); - this.addListener("changeValue", e => this.__itemSelected(e.getData()), this); + this.addListener("tap", this.__itemSelected, this); this.setPriority(osparc.dashboard.CardBase.CARD_PRIORITY.NEW); @@ -54,25 +54,22 @@ qx.Class.define("osparc.dashboard.WorkspaceButtonNew", { }, members: { - __itemSelected: function(newVal) { - if (newVal) { - const workspaceEditor = new osparc.editor.WorkspaceEditor(); - const title = this.tr("New Workspace"); - const win = osparc.ui.window.Window.popUpInWindow(workspaceEditor, title, 500, 500).set({ - modal: true, - clickAwayClose: false, - }); - workspaceEditor.addListener("workspaceCreated", () => this.fireEvent("workspaceCreated")); - workspaceEditor.addListener("workspaceDeleted", () => this.fireEvent("workspaceDeleted")); - workspaceEditor.addListener("workspaceUpdated", () => { - win.close(); - this.fireEvent("workspaceUpdated"); - }, this); - workspaceEditor.addListener("updateAccessRights", () => this.fireEvent("workspaceUpdated")); - win.getChildControl("close-button").addListener("tap", () => workspaceEditor.cancel()); - workspaceEditor.addListener("cancel", () => win.close()); - } - this.setValue(false); + __itemSelected: function() { + const workspaceEditor = new osparc.editor.WorkspaceEditor(); + const title = this.tr("New Workspace"); + const win = osparc.ui.window.Window.popUpInWindow(workspaceEditor, title, 500, 500).set({ + modal: true, + clickAwayClose: false, + }); + workspaceEditor.addListener("workspaceCreated", () => this.fireEvent("workspaceCreated")); + workspaceEditor.addListener("workspaceDeleted", () => this.fireEvent("workspaceDeleted")); + workspaceEditor.addListener("workspaceUpdated", () => { + win.close(); + this.fireEvent("workspaceUpdated"); + }, this); + workspaceEditor.addListener("updateAccessRights", () => this.fireEvent("workspaceUpdated")); + win.getChildControl("close-button").addListener("tap", () => workspaceEditor.cancel()); + workspaceEditor.addListener("cancel", () => win.close()); } } }); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTree.js b/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTree.js index 01cea4d878c..604d5e2e7b0 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTree.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTree.js @@ -85,6 +85,8 @@ qx.Class.define("osparc.dashboard.WorkspacesAndFoldersTree", { events: { "openChanged": "qx.event.type.Event", "locationChanged": "qx.event.type.Data", + "studyToFolderRequested": "qx.event.type.Data", + "folderToFolderRequested": "qx.event.type.Data", }, properties: { @@ -133,7 +135,13 @@ qx.Class.define("osparc.dashboard.WorkspacesAndFoldersTree", { item.addListener("changeModel", e => { const model = e.getData(); osparc.utils.Utils.setIdToWidget(item, `workspacesAndFoldersTreeItem_${model.getWorkspaceId()}_${model.getFolderId()}`); - }) + }); + [ + "studyToFolderRequested", + "folderToFolderRequested", + ].forEach(ev => { + item.addListener(ev, e => this.fireDataEvent(ev, e.getData())); + }); } }); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTreeItem.js b/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTreeItem.js index 75f120a86c5..05a4e44a9c0 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTreeItem.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/WorkspacesAndFoldersTreeItem.js @@ -30,6 +30,14 @@ qx.Class.define("osparc.dashboard.WorkspacesAndFoldersTreeItem", { this.setNotHoveredStyle(); this.__attachEventHandlers(); + + this.__attachDragHandlers(); + this.__attachDropHandlers(); + }, + + events: { + "studyToFolderRequested": "qx.event.type.Data", + "folderToFolderRequested": "qx.event.type.Data", }, members: { @@ -48,6 +56,77 @@ qx.Class.define("osparc.dashboard.WorkspacesAndFoldersTreeItem", { setNotHoveredStyle: function() { osparc.utils.Utils.hideBorder(this); - } + }, + + __getFolder: function() { + const folderId = this.getModel().getFolderId(); + if (folderId === null) { + return null; + } + return osparc.store.Folders.getInstance().getFolder(folderId); + }, + + __attachDragHandlers: function() { + this.setDraggable(true); + + this.addListener("dragstart", e => { + const folderOrigin = this.__getFolder(); + // only folders can be dragged + if (folderOrigin == null) { + e.preventDefault(); + return; + } + osparc.dashboard.DragDropHelpers.moveFolder.dragStart(e, this, folderOrigin); + }); + + this.addListener("dragend", () => { + osparc.dashboard.DragDropHelpers.dragEnd(this); + }); + }, + + __attachDropHandlers: function() { + this.setDroppable(true); + + let draggingOver = false; + this.addListener("dragover", e => { + const workspaceDestId = this.getModel().getWorkspaceId(); + const folderDestId = this.getModel().getFolderId(); + if (e.supportsType("osparc-moveStudy")) { + osparc.dashboard.DragDropHelpers.moveStudy.dragOver(e, this, workspaceDestId, folderDestId); + } else if (e.supportsType("osparc-moveFolder")) { + osparc.dashboard.DragDropHelpers.moveFolder.dragOver(e, this, workspaceDestId, folderDestId); + } + + draggingOver = true; + setTimeout(() => { + if (draggingOver) { + this.setOpen(true); + draggingOver = false; + } + }, 1000); + }); + + this.addListener("dragleave", () => { + osparc.dashboard.DragDropHelpers.dragLeave(this); + draggingOver = false; + }); + this.addListener("dragend", () => { + osparc.dashboard.DragDropHelpers.dragLeave(this); + draggingOver = false; + }); + + this.addListener("drop", e => { + const workspaceDestId = this.getModel().getWorkspaceId(); + const folderDestId = this.getModel().getFolderId(); + if (e.supportsType("osparc-moveStudy")) { + const studyToFolderData = osparc.dashboard.DragDropHelpers.moveStudy.drop(e, this, workspaceDestId, folderDestId); + this.fireDataEvent("studyToFolderRequested", studyToFolderData); + } else if (e.supportsType("osparc-moveFolder")) { + const folderToFolderData = osparc.dashboard.DragDropHelpers.moveFolder.drop(e, this, workspaceDestId, folderDestId); + this.fireDataEvent("folderToFolderRequested", folderToFolderData); + } + draggingOver = false; + }); + }, }, }); diff --git a/services/static-webserver/client/source/class/osparc/desktop/account/MyAccount.js b/services/static-webserver/client/source/class/osparc/desktop/account/MyAccount.js index 40a3e5b5918..1d19d05f390 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/account/MyAccount.js +++ b/services/static-webserver/client/source/class/osparc/desktop/account/MyAccount.js @@ -64,17 +64,24 @@ qx.Class.define("osparc.desktop.account.MyAccount", { authData.bind("username", usernameLabel, "value"); layout.add(usernameLabel); - const name = new qx.ui.basic.Label().set({ + const nameLabel = new qx.ui.basic.Label().set({ font: "text-13", alignX: "center" }); - layout.add(name); - authData.bind("firstName", name, "value", { - converter: firstName => firstName + " " + authData.getLastName() - }); - authData.bind("lastName", name, "value", { - converter: lastName => authData.getFirstName() + " " + lastName - }); + layout.add(nameLabel); + const updateName = () => { + let name = ""; + if (authData.getFirstName()) { + name += authData.getFirstName(); + } + if (authData.getLastName()) { + name += " " + authData.getLastName(); + } + nameLabel.setValue(name); + } + updateName(); + authData.addListener("changeFirstName", updateName); + authData.addListener("changeLastName", updateName); if (authData.getRole() !== "user") { const role = authData.getFriendlyRole(); diff --git a/services/static-webserver/client/source/class/osparc/file/FileLabelWithActions.js b/services/static-webserver/client/source/class/osparc/file/FileLabelWithActions.js index 15eec413914..35837fff2c7 100644 --- a/services/static-webserver/client/source/class/osparc/file/FileLabelWithActions.js +++ b/services/static-webserver/client/source/class/osparc/file/FileLabelWithActions.js @@ -198,7 +198,7 @@ qx.Class.define("osparc.file.FileLabelWithActions", { request .then(data => { this.fireDataEvent("fileDeleted", data); - osparc.FlashMessenger.getInstance().logAs(this.tr("File successfully deleted"), "ERROR"); + osparc.FlashMessenger.getInstance().logAs(this.tr("File successfully deleted"), "INFO"); }); } } diff --git a/services/static-webserver/client/source/class/osparc/pricing/UnitEditor.js b/services/static-webserver/client/source/class/osparc/pricing/UnitEditor.js index 26469666570..38f9022172e 100644 --- a/services/static-webserver/client/source/class/osparc/pricing/UnitEditor.js +++ b/services/static-webserver/client/source/class/osparc/pricing/UnitEditor.js @@ -36,7 +36,6 @@ qx.Class.define("osparc.pricing.UnitEditor", { const manager = this.__validator = new qx.ui.form.validation.Manager(); unitName.setRequired(true); costPerUnit.setRequired(true); - specificInfo.setRequired(true); unitExtraInfoCPU.setRequired(true); unitExtraInfoRAM.setRequired(true); unitExtraInfoVRAM.setRequired(true); @@ -114,8 +113,8 @@ qx.Class.define("osparc.pricing.UnitEditor", { specificInfo: { check: "String", - init: "t2.medium", - nullable: false, + init: null, + nullable: true, event: "changeSpecificInfo" }, @@ -307,7 +306,11 @@ qx.Class.define("osparc.pricing.UnitEditor", { const unitName = this.getUnitName(); const costPerUnit = this.getCostPerUnit(); const comment = this.getComment(); + const awsEc2Instances = []; const specificInfo = this.getSpecificInfo(); + if (specificInfo) { + awsEc2Instances.push(specificInfo); + } const extraInfo = {}; extraInfo["CPU"] = this.getUnitExtraInfoCPU(); extraInfo["RAM"] = this.getUnitExtraInfoRAM(); @@ -323,7 +326,7 @@ qx.Class.define("osparc.pricing.UnitEditor", { "costPerUnit": costPerUnit, "comment": comment, "specificInfo": { - "aws_ec2_instances": [specificInfo] + "aws_ec2_instances": awsEc2Instances }, "unitExtraInfo": extraInfo, "default": isDefault diff --git a/services/static-webserver/client/source/class/osparc/service/ServiceList.js b/services/static-webserver/client/source/class/osparc/service/ServiceList.js index 06ca9bca1e7..72d1164f6dd 100644 --- a/services/static-webserver/client/source/class/osparc/service/ServiceList.js +++ b/services/static-webserver/client/source/class/osparc/service/ServiceList.js @@ -36,7 +36,7 @@ qx.Class.define("osparc.service.ServiceList", { }, events: { - "changeValue": "qx.event.type.Data", + "changeSelected": "qx.event.type.Data", "serviceAdd": "qx.event.type.Data" }, @@ -53,33 +53,26 @@ qx.Class.define("osparc.service.ServiceList", { }, members: { - __buttonGroup: null, __filterGroup: null, _applyModel: function(model) { this._removeAll(); - const group = this.__buttonGroup = new qx.ui.form.RadioGroup().set({ - allowEmptySelection: true - }); + this.__serviceListItem = []; model.toArray().forEach(service => { - const button = new osparc.service.ServiceListItem(service); + const item = new osparc.service.ServiceListItem(service); if (this.__filterGroup !== null) { - button.subscribeToFilterGroup(this.__filterGroup); + item.subscribeToFilterGroup(this.__filterGroup); } - group.add(button); - this._add(button); - button.addListener("dbltap", () => { - this.fireDataEvent("serviceAdd", button.getService()); - }, this); - button.addListener("keypress", e => { + this._add(item); + item.addListener("tap", () => this.__setSelected(item)); + item.addListener("dbltap", () => this.fireDataEvent("serviceAdd", item.getService()), this); + item.addListener("keypress", e => { if (e.getKeyIdentifier() === "Enter") { - this.fireDataEvent("serviceAdd", button.getService()); + this.fireDataEvent("serviceAdd", item.getService()); } }, this); }); - - group.addListener("changeValue", e => this.dispatchEvent(e.clone()), this); }, /** @@ -88,37 +81,41 @@ qx.Class.define("osparc.service.ServiceList", { * @return Returns the model of the selected service or null if selection is empty. */ getSelected: function() { - if (this.__buttonGroup && this.__buttonGroup.getSelection().length) { - return this.__buttonGroup.getSelection()[0].getService(); + const items = this._getChildren(); + for (let i=0; i item.setSelected(item === selectedItem)); + this.fireDataEvent("changeSelected", selectedItem); + }, + /** * Function checking if the selection is empty or not * * @return True if no item is selected, false if there one or more item selected. */ isSelectionEmpty: function() { - if (this.__buttonGroup == null) { - return true; - } - return this.__buttonGroup.getSelection().length === 0; + const selecetedItems = this._getChildren().filter(item => item.getSelected()); + selecetedItems.length === 0; }, /** * Function that selects the first visible button. */ selectFirstVisible: function() { - if (this._hasChildren()) { - const buttons = this._getChildren(); - let current = buttons[0]; - let i = 1; - while (i this.__itemSelected(e.getData())); + this.bind("selected", this, "backgroundColor", { + converter: selected => selected ? "strong-main" : "info" + }); }, properties: { diff --git a/services/static-webserver/client/source/class/osparc/theme/Appearance.js b/services/static-webserver/client/source/class/osparc/theme/Appearance.js index 16facaa2949..7f6f8ccfe51 100644 --- a/services/static-webserver/client/source/class/osparc/theme/Appearance.js +++ b/services/static-webserver/client/source/class/osparc/theme/Appearance.js @@ -19,6 +19,30 @@ qx.Theme.define("osparc.theme.Appearance", { extend: osparc.theme.common.Appearance, appearances: { + "dragdrop-no-cursor": { + style: () => { + return { + source: "", + } + } + }, + + "dragdrop-own-cursor": { + style: states => { + let icon = ""; + if (states.move) { + icon = "@FontAwesome5Solid/check/14"; + } else { + icon = "@FontAwesome5Solid/times/14"; + } + return { + source: icon, + position: "right-top", + offset: [12, 0, 0, 12], + } + } + }, + "material-button-invalid": {}, "pb-list": { include: "list", diff --git a/services/static-webserver/client/source/class/osparc/workbench/ServiceCatalog.js b/services/static-webserver/client/source/class/osparc/workbench/ServiceCatalog.js index b9dd0867a4c..682e2d00fe0 100644 --- a/services/static-webserver/client/source/class/osparc/workbench/ServiceCatalog.js +++ b/services/static-webserver/client/source/class/osparc/workbench/ServiceCatalog.js @@ -136,7 +136,7 @@ qx.Class.define("osparc.workbench.ServiceCatalog", { }); scrolledServices.add(serviceList); - this.__serviceList.addListener("changeValue", e => { + this.__serviceList.addListener("changeSelected", e => { if (e.getData() && e.getData().getService()) { const selectedService = e.getData().getService(); this.__changedSelection(selectedService.getKey());