diff --git a/packages/models-library/src/models_library/shared_user_preferences.py b/packages/models-library/src/models_library/shared_user_preferences.py new file mode 100644 index 00000000000..57f291999ab --- /dev/null +++ b/packages/models-library/src/models_library/shared_user_preferences.py @@ -0,0 +1,6 @@ +from .user_preferences import FrontendUserPreference + + +class AllowMetricsCollectionFrontendUserPreference(FrontendUserPreference): + preference_identifier: str = "allowMetricsCollection" + value: bool = True diff --git a/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/projects_networks.py b/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/projects_networks.py index 4e234b43c02..28e5144bede 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/projects_networks.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/projects_networks.py @@ -29,7 +29,7 @@ async def upsert_projects_networks( self, project_id: ProjectID, networks_with_aliases: NetworksWithAliases ) -> None: projects_networks_to_insert = ProjectsNetworks.parse_obj( - dict(project_uuid=project_id, networks_with_aliases=networks_with_aliases) + {"project_uuid": project_id, "networks_with_aliases": networks_with_aliases} ) async with self.db_engine.acquire() as conn: diff --git a/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/user_preferences_frontend.py b/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/user_preferences_frontend.py new file mode 100644 index 00000000000..01d7fdcce61 --- /dev/null +++ b/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/user_preferences_frontend.py @@ -0,0 +1,35 @@ +from models_library.products import ProductName +from models_library.user_preferences import FrontendUserPreference, PreferenceName +from models_library.users import UserID +from simcore_postgres_database.utils_user_preferences import FrontendUserPreferencesRepo + +from ._base import BaseRepository + + +def _get_user_preference_name(user_id: UserID, preference_name: PreferenceName) -> str: + return f"{user_id}/{preference_name}" + + +class UserPreferencesFrontendRepository(BaseRepository): + async def get_user_preference( + self, + *, + user_id: UserID, + product_name: ProductName, + preference_class: type[FrontendUserPreference], + ) -> FrontendUserPreference | None: + async with self.db_engine.acquire() as conn: + preference_payload: dict | None = await FrontendUserPreferencesRepo.load( + conn, + user_id=user_id, + preference_name=_get_user_preference_name( + user_id, preference_class.get_preference_name() + ), + product_name=product_name, + ) + + return ( + None + if preference_payload is None + else preference_class.parse_obj(preference_payload) + ) diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/sidecar.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/sidecar.py index 461a4a3e6f1..b1b396d4160 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/sidecar.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/docker_service_specs/sidecar.py @@ -27,7 +27,7 @@ update_service_params_from_settings, ) -log = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) def extract_service_port_service_settings( @@ -42,6 +42,7 @@ def _get_environment_variables( app_settings: AppSettings, *, allow_internet_access: bool, + metrics_collection_allowed: bool, ) -> dict[str, str]: registry_settings = app_settings.DIRECTOR_V2_DOCKER_REGISTRY rabbit_settings = app_settings.DIRECTOR_V2_RABBITMQ @@ -53,6 +54,16 @@ def _get_environment_variables( if scheduler_data.paths_mapping.state_exclude is not None: state_exclude = scheduler_data.paths_mapping.state_exclude + callbacks_mapping: CallbacksMapping = scheduler_data.callbacks_mapping + + if not metrics_collection_allowed: + _logger.info( + "user=%s disabled metrics collection, disable prometheus metrics for node_id=%s", + scheduler_data.user_id, + scheduler_data.node_uuid, + ) + callbacks_mapping.metrics = None + return { # These environments will be captured by # services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/core/settings.py::ApplicationSettings @@ -64,7 +75,7 @@ def _get_environment_variables( "DY_SIDECAR_RUN_ID": scheduler_data.run_id, "DY_SIDECAR_USER_SERVICES_HAVE_INTERNET_ACCESS": f"{allow_internet_access}", "DY_SIDECAR_STATE_EXCLUDE": json_dumps(f"{x}" for x in state_exclude), - "DY_SIDECAR_CALLBACKS_MAPPING": scheduler_data.callbacks_mapping.json(), + "DY_SIDECAR_CALLBACKS_MAPPING": callbacks_mapping.json(), "DY_SIDECAR_STATE_PATHS": json_dumps( f"{x}" for x in scheduler_data.paths_mapping.state_paths ), @@ -139,8 +150,10 @@ def get_dynamic_sidecar_spec( swarm_network_id: str, settings: SimcoreServiceSettingsLabel, app_settings: AppSettings, + *, has_quota_support: bool, allow_internet_access: bool, + metrics_collection_allowed: bool, ) -> AioDockerServiceSpec: """ The dynamic-sidecar is responsible for managing the lifecycle @@ -329,6 +342,7 @@ def get_dynamic_sidecar_spec( scheduler_data, app_settings, allow_internet_access=allow_internet_access, + metrics_collection_allowed=metrics_collection_allowed, ), "Hosts": [], "Image": dynamic_sidecar_settings.DYNAMIC_SIDECAR_IMAGE, diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_events.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_events.py index bf4988abe01..017503b3dc5 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_events.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_events.py @@ -65,6 +65,7 @@ are_all_user_services_containers_running, attach_project_networks, attempt_pod_removal_and_data_saving, + get_allow_metrics_collection, get_director_v0_client, parse_containers_inspect, prepare_services_environment, @@ -183,6 +184,12 @@ async def action(cls, app: FastAPI, scheduler_data: SchedulerData) -> None: swarm_network_id: NetworkId = swarm_network["Id"] swarm_network_name: str = swarm_network["Name"] + metrics_collection_allowed: bool = await get_allow_metrics_collection( + app, + user_id=scheduler_data.user_id, + product_name=scheduler_data.product_name, + ) + # start dynamic-sidecar and run the proxy on the same node # Each time a new dynamic-sidecar service is created @@ -200,6 +207,7 @@ async def action(cls, app: FastAPI, scheduler_data: SchedulerData) -> None: app_settings=app.state.settings, has_quota_support=dynamic_services_scheduler_settings.DYNAMIC_SIDECAR_ENABLE_VOLUME_LIMITS, allow_internet_access=allow_internet_access, + metrics_collection_allowed=metrics_collection_allowed, ) catalog_client = CatalogClient.instance(app) diff --git a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_events_utils.py b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_events_utils.py index 08d3dd060ec..e675d86a07e 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_events_utils.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/dynamic_sidecar/scheduler/_core/_events_utils.py @@ -2,16 +2,22 @@ import json import logging -from typing import Any +from typing import Any, cast from fastapi import FastAPI +from models_library.products import ProductName from models_library.projects_networks import ProjectsNetworks from models_library.projects_nodes import NodeID from models_library.projects_nodes_io import NodeIDStr from models_library.rabbitmq_messages import InstrumentationRabbitMessage from models_library.service_settings_labels import SimcoreServiceLabels from models_library.services import ServiceKeyVersion +from models_library.shared_user_preferences import ( + AllowMetricsCollectionFrontendUserPreference, +) from models_library.sidecar_volumes import VolumeCategory, VolumeStatus +from models_library.user_preferences import FrontendUserPreference +from models_library.users import UserID from servicelib.fastapi.long_running_tasks.client import ( ProgressCallback, TaskClientResultError, @@ -40,6 +46,9 @@ from ....api_keys_manager import safe_remove from ....db.repositories.projects import ProjectsRepository from ....db.repositories.projects_networks import ProjectsNetworksRepository +from ....db.repositories.user_preferences_frontend import ( + UserPreferencesFrontendRepository, +) from ....director_v0 import DirectorV0Client from ...api_client import ( BaseClientHTTPError, @@ -442,3 +451,24 @@ async def prepare_services_environment( ) scheduler_data.dynamic_sidecar.is_service_environment_ready = True + + +async def get_allow_metrics_collection( + app: FastAPI, user_id: UserID, product_name: ProductName +) -> bool: + repo = get_repository(app, UserPreferencesFrontendRepository) + preference: FrontendUserPreference | None = await repo.get_user_preference( + user_id=user_id, + product_name=product_name, + preference_class=AllowMetricsCollectionFrontendUserPreference, + ) + + if preference is None: + return cast( + bool, AllowMetricsCollectionFrontendUserPreference.get_default_value() + ) + + allow_metrics_collection = AllowMetricsCollectionFrontendUserPreference.parse_obj( + preference + ) + return cast(bool, allow_metrics_collection.value) diff --git a/services/director-v2/tests/unit/test_modules_dynamic_sidecar_docker_service_specs_sidecar.py b/services/director-v2/tests/unit/test_modules_dynamic_sidecar_docker_service_specs_sidecar.py index 41bd578d47a..8117f0d9f60 100644 --- a/services/director-v2/tests/unit/test_modules_dynamic_sidecar_docker_service_specs_sidecar.py +++ b/services/director-v2/tests/unit/test_modules_dynamic_sidecar_docker_service_specs_sidecar.py @@ -77,6 +77,7 @@ def test_dynamic_sidecar_env_vars( scheduler_data_from_http_request, app_settings, allow_internet_access=False, + metrics_collection_allowed=True, ) print("dynamic_sidecar_env_vars:", dynamic_sidecar_env_vars) diff --git a/services/director-v2/tests/unit/with_dbs/test_modules_dynamic_sidecar_docker_service_specs.py b/services/director-v2/tests/unit/with_dbs/test_modules_dynamic_sidecar_docker_service_specs.py index 4917496ef3e..e531c7e456a 100644 --- a/services/director-v2/tests/unit/with_dbs/test_modules_dynamic_sidecar_docker_service_specs.py +++ b/services/director-v2/tests/unit/with_dbs/test_modules_dynamic_sidecar_docker_service_specs.py @@ -418,6 +418,7 @@ def test_get_dynamic_proxy_spec( app_settings=minimal_app.state.settings, has_quota_support=False, allow_internet_access=False, + metrics_collection_allowed=True, ) exclude_keys: Mapping[int | str, Any] = { @@ -508,6 +509,7 @@ async def test_merge_dynamic_sidecar_specs_with_user_specific_specs( app_settings=minimal_app.state.settings, has_quota_support=False, allow_internet_access=False, + metrics_collection_allowed=True, ) assert dynamic_sidecar_spec dynamic_sidecar_spec_dict = dynamic_sidecar_spec.dict() diff --git a/services/static-webserver/client/source/class/osparc/Preferences.js b/services/static-webserver/client/source/class/osparc/Preferences.js index 6e12c18b23a..d52b9e8b7e7 100644 --- a/services/static-webserver/client/source/class/osparc/Preferences.js +++ b/services/static-webserver/client/source/class/osparc/Preferences.js @@ -114,6 +114,14 @@ qx.Class.define("osparc.Preferences", { init: 4, event: "changeJobConcurrencyLimit", apply: "__patchPreference" + }, + + allowMetricsCollection: { + nullable: false, + init: true, + check: "Boolean", + event: "changeAllowMetricsCollection", + apply: "__patchPreference" } }, diff --git a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/BasePage.js b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/BasePage.js index 90e147295d9..90ea9a58769 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/BasePage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/BasePage.js @@ -88,12 +88,12 @@ qx.Class.define("osparc.desktop.preferences.pages.BasePage", { /** * Common layout for tooltip label */ - _createHelpLabel: function(message=null) { + _createHelpLabel: function(message=null, font="text-13") { const label = new qx.ui.basic.Label().set({ value: message, alignX: "left", rich: true, - font: "text-13" + font: font }); return label; } diff --git a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/GeneralPage.js b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/GeneralPage.js index d04843fe1f0..a6c9f4f7cad 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/GeneralPage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/GeneralPage.js @@ -24,11 +24,10 @@ qx.Class.define("osparc.desktop.preferences.pages.GeneralPage", { const title = this.tr("General Settings"); this.base(arguments, title, iconSrc); - const walletIndicatorSettings = this.__createCreditsIndicatorSettings(); - this.add(walletIndicatorSettings); - + this.add(this.__createCreditsIndicatorSettings()); this.add(this.__createInactivitySetting()); this.add(this.__createJobConcurrencySetting()); + this.add(this.__createUserPrivacySettings()); }, statics: { @@ -57,11 +56,6 @@ qx.Class.define("osparc.desktop.preferences.pages.GeneralPage", { // layout const box = this._createSectionBox(this.tr("Credits Indicator")); - const label = this._createHelpLabel(this.tr( - "Choose when you want the Credits Indicator to be shown in the navigation bar:" - )); - box.add(label); - const form = new qx.ui.form.Form(); const preferencesSettings = osparc.Preferences.getInstance(); @@ -89,7 +83,7 @@ qx.Class.define("osparc.desktop.preferences.pages.GeneralPage", { const selectable = e.getData(); this.self().patchPreference("walletIndicatorVisibility", walletIndicatorVisibilitySB, selectable.getModel()); }); - form.add(walletIndicatorVisibilitySB, this.tr("Show it")); + form.add(walletIndicatorVisibilitySB, this.tr("Show indicator")); const creditsWarningThresholdField = new qx.ui.form.Spinner().set({ minimum: 100, @@ -99,15 +93,15 @@ qx.Class.define("osparc.desktop.preferences.pages.GeneralPage", { }); preferencesSettings.bind("creditsWarningThreshold", creditsWarningThresholdField, "value"); creditsWarningThresholdField.addListener("changeValue", e => this.self().patchPreference("creditsWarningThreshold", creditsWarningThresholdField, e.getData())); - form.add(creditsWarningThresholdField, this.tr("Warning threshold")); + form.add(creditsWarningThresholdField, this.tr("Show warning when credits below")); box.add(new qx.ui.form.renderer.Single(form)); return box; }, __createInactivitySetting: function() { - const box = this._createSectionBox(this.tr("Inactivity shutdown")); - const label = this._createHelpLabel(this.tr("Choose after how long should inactive studies be closed. A value of zero disables this function.")); + const box = this._createSectionBox(this.tr("Automatic Shutdown of Idle Instances")); + const label = this._createHelpLabel(this.tr("Enter 0 to disable this function"), "text-13-italic"); box.add(label); const form = new qx.ui.form.Form(); const inactivitySpinner = new qx.ui.form.Spinner().set({ @@ -126,9 +120,7 @@ qx.Class.define("osparc.desktop.preferences.pages.GeneralPage", { return box; }, __createJobConcurrencySetting: function() { - const box = this._createSectionBox(this.tr("Job concurrency")); - const label = this._createHelpLabel(this.tr("Choose how many jobs can run at the same time.")); - box.add(label); + const box = this._createSectionBox(this.tr("Job Concurrency")); const form = new qx.ui.form.Form(); const jobConcurrencySpinner = new qx.ui.form.Spinner().set({ minimum: 1, @@ -140,8 +132,23 @@ qx.Class.define("osparc.desktop.preferences.pages.GeneralPage", { const preferences = osparc.Preferences.getInstance(); preferences.bind("jobConcurrencyLimit", jobConcurrencySpinner, "value"); jobConcurrencySpinner.addListener("changeValue", e => this.self().patchPreference("jobConcurrencyLimit", jobConcurrencySpinner, e.getData())); - form.add(jobConcurrencySpinner, this.tr("Maximum concurrent jobs")); + form.add(jobConcurrencySpinner, this.tr("Maximum number of concurrent jobs")); box.add(new qx.ui.form.renderer.Single(form)); + return box; + }, + __createUserPrivacySettings: function() { + const box = this._createSectionBox("Privacy Settings"); + + const label = this._createHelpLabel(this.tr("Help us improve Sim4Life user experience"), "text-13-italic"); + box.add(label); + + const preferencesSettings = osparc.Preferences.getInstance(); + + const cbAllowMetricsCollection = new qx.ui.form.CheckBox(this.tr("Share usage data")); + preferencesSettings.bind("allowMetricsCollection", cbAllowMetricsCollection, "value"); + cbAllowMetricsCollection.addListener("changeValue", e => this.self().patchPreference("allowMetricsCollection", cbAllowMetricsCollection, e.getData())); + box.add(cbAllowMetricsCollection); + return box; } } diff --git a/services/static-webserver/client/source/class/osparc/theme/Font.js b/services/static-webserver/client/source/class/osparc/theme/Font.js index 6fb9c64f749..ac39945be5c 100644 --- a/services/static-webserver/client/source/class/osparc/theme/Font.js +++ b/services/static-webserver/client/source/class/osparc/theme/Font.js @@ -83,6 +83,12 @@ qx.Theme.define("osparc.theme.Font", { size: 13 }, + "text-13-italic": { + include: "defaults", + size: 13, + italic: true + }, + "text-12": { include: "defaults", size: 12 diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_db.py b/services/web/server/src/simcore_service_webserver/users/_preferences_db.py index aa07390b883..45903403af9 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_db.py +++ b/services/web/server/src/simcore_service_webserver/users/_preferences_db.py @@ -1,6 +1,6 @@ from aiohttp import web from models_library.products import ProductName -from models_library.user_preferences import AnyUserPreference, PreferenceName +from models_library.user_preferences import FrontendUserPreference, PreferenceName from models_library.users import UserID from simcore_postgres_database.utils_user_preferences import FrontendUserPreferencesRepo @@ -16,8 +16,8 @@ async def get_user_preference( *, user_id: UserID, product_name: ProductName, - preference_class: type[AnyUserPreference], -) -> AnyUserPreference | None: + preference_class: type[FrontendUserPreference], +) -> FrontendUserPreference | None: async with get_database_engine(app).acquire() as conn: preference_payload: dict | None = await FrontendUserPreferencesRepo.load( conn, @@ -40,7 +40,7 @@ async def set_user_preference( *, user_id: UserID, product_name: ProductName, - preference: AnyUserPreference, + preference: FrontendUserPreference, ) -> None: async with get_database_engine(app).acquire() as conn: await FrontendUserPreferencesRepo.save( diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_models.py b/services/web/server/src/simcore_service_webserver/users/_preferences_models.py index 06bf7a9fa91..1ed4a14eed9 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_preferences_models.py @@ -1,5 +1,8 @@ from typing import Final +from models_library.shared_user_preferences import ( + AllowMetricsCollectionFrontendUserPreference, +) from models_library.user_preferences import ( FrontendUserPreference, PreferenceIdentifier, @@ -67,7 +70,7 @@ class PreferredWalletIdFrontendUserPreference(FrontendUserPreference): class CreditsWarningThresholdFrontendUserPreference(FrontendUserPreference): preference_identifier = "creditsWarningThreshold" - value: int | None = 200 + value: int = 200 class WalletIndicatorVisibilityFrontendUserPreference(FrontendUserPreference): @@ -77,7 +80,8 @@ class WalletIndicatorVisibilityFrontendUserPreference(FrontendUserPreference): class UserInactivityThresholdFrontendUserPreference(FrontendUserPreference): preference_identifier = "userInactivityThreshold" - value: int | None = 30 * _MINUTE # in seconds + value: int = 30 * _MINUTE # in seconds + class JobConcurrencyLimitFrontendUserPreference(FrontendUserPreference): preference_identifier = "jobConcurrencyLimit" @@ -100,6 +104,7 @@ class JobConcurrencyLimitFrontendUserPreference(FrontendUserPreference): WalletIndicatorVisibilityFrontendUserPreference, UserInactivityThresholdFrontendUserPreference, JobConcurrencyLimitFrontendUserPreference, + AllowMetricsCollectionFrontendUserPreference, ]