From 47eb2d15144f55b3745bdb51646f90cc66571045 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Tue, 12 May 2026 17:36:14 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8(backend)=20expose=20operator=20me?= =?UTF-8?q?trics=20dashboard=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add metrics endpoints scoped to an operator with filtering by service/key/organization/account_type and optional aggregation or grouping. Powers the frontend metrics dashboard. --- src/backend/core/api/serializers.py | 54 +++++++ src/backend/core/api/viewsets/metrics.py | 171 +++++++++++++++++++++++ src/backend/core/urls.py | 6 + 3 files changed, 231 insertions(+) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 24c22d3..e33915b 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -905,3 +905,57 @@ def get_service_links(self, obj): } entry["roles"][link.role] = {"scope": link.scope or {}} return list(by_service.values()) + +class MetricAccountSerializer(serializers.ModelSerializer): + """Lightweight serializer for account in metric context.""" + + class Meta: + model = models.Account + fields = ["id", "email", "external_id", "type"] + read_only_fields = fields + + +class MetricOrganizationSerializer(serializers.ModelSerializer): + """Lightweight serializer for organization in metric context.""" + + class Meta: + model = models.Organization + fields = ["id", "name"] + read_only_fields = fields + + +class MetricSerializer(serializers.ModelSerializer): + """Serialize metrics with account and organization details.""" + + account = MetricAccountSerializer(read_only=True) + organization = MetricOrganizationSerializer(read_only=True) + + class Meta: + model = models.Metric + fields = ["id", "key", "value", "timestamp", "account", "organization"] + read_only_fields = fields + + +class AggregatedMetricSerializer(serializers.Serializer): + """Serialize aggregated metric result.""" + + key = serializers.CharField(help_text="Metric key") + service_id = serializers.UUIDField(help_text="Service ID") + aggregation = serializers.ChoiceField( + choices=["sum", "avg"], + help_text="Aggregation type applied", + ) + value = serializers.DecimalField( + max_digits=20, + decimal_places=6, + help_text="Aggregated value", + ) + count = serializers.IntegerField(help_text="Number of metrics aggregated") + + def create(self, validated_data): + """Not implemented - this serializer is read-only.""" + raise NotImplementedError("This serializer is read-only") + + def update(self, instance, validated_data): + """Not implemented - this serializer is read-only.""" + raise NotImplementedError("This serializer is read-only") diff --git a/src/backend/core/api/viewsets/metrics.py b/src/backend/core/api/viewsets/metrics.py index 9e33864..22982c5 100644 --- a/src/backend/core/api/viewsets/metrics.py +++ b/src/backend/core/api/viewsets/metrics.py @@ -1,4 +1,17 @@ """Metrics API viewsets.""" +""" +API endpoints for Metrics model. +""" + +from django.db.models import Avg, Sum + +from rest_framework import status, viewsets +from rest_framework.settings import api_settings + +from core import models +from core.authentication import ExternalManagementApiKeyAuthentication + +from .. import permissions, serializers as core_serializers from rest_framework import serializers from rest_framework.response import Response @@ -62,3 +75,161 @@ def get(self, request): "results": results, } ) + +class OperatorMetricsViewSet(viewsets.ViewSet): + """ViewSet for Metrics model nested under Operator. + + GET /api/v1.0/operators//metrics/ + Return the list of metrics for the given operator based on filters. + Supports filtering by key, service, organizations, accounts, account_type. + Supports aggregation via agg=sum|avg query param. + + Required query params: + - key: Metric key to filter on (single value) + - service: Service ID to filter on (single value) + + Optional query params: + - organizations: Comma-separated organization IDs. Defaults to all orgs the operator has access to. + - accounts: Comma-separated account IDs. If omitted, returns all accounts (including null). + - account_type: Filter by account type (e.g., "user", "mailbox"). + - agg: Aggregation type (sum|avg). If provided, returns aggregated value. + - group_by: Group results by 'organization'. Returns sum per organization. + """ + + authentication_classes = [ + ExternalManagementApiKeyAuthentication, + ] + list(api_settings.DEFAULT_AUTHENTICATION_CLASSES) + permission_classes = [ + permissions.IsAuthenticatedWithAnyMethod, + permissions.OperatorAccessPermission, + ] + + def _get_operator_organizations(self, operator_id): + """Get all organization IDs that the operator has access to.""" + return models.Organization.objects.filter( + operator_roles__operator_id=operator_id + ).values_list("id", flat=True) + + def _parse_comma_separated_ids(self, param_value): + """Parse comma-separated IDs from query param.""" + if not param_value: + return None + return [id.strip() for id in param_value.split(",") if id.strip()] + + def list(self, request, operator_id=None): + """List metrics with filtering and optional aggregation.""" + # Validate required params + key = request.query_params.get("key") + service_id = request.query_params.get("service") + + if not key: + return Response( + {"error": "Query parameter 'key' is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not service_id: + return Response( + {"error": "Query parameter 'service' is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate service exists + try: + service = models.Service.objects.get(id=service_id) + except models.Service.DoesNotExist: + return Response( + {"error": f"Service with id '{service_id}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get allowed organizations for this operator (convert UUIDs to strings for comparison) + allowed_org_ids = set( + str(org_id) for org_id in self._get_operator_organizations(operator_id) + ) + + # Parse optional organization filter + org_ids_param = request.query_params.get("organizations") + if org_ids_param: + requested_org_ids = set(self._parse_comma_separated_ids(org_ids_param)) + # Filter to only allowed organizations + org_ids = list(requested_org_ids & allowed_org_ids) + if not org_ids: + return Response( + {"error": "No valid organizations found for the given IDs."}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # Default to all organizations the operator has access to + org_ids = list(allowed_org_ids) + + # Build base queryset + queryset = models.Metric.objects.filter( + key=key, + service=service, + organization_id__in=org_ids, + ) + + # Filter by account_type if provided + account_type = request.query_params.get("account_type") + if account_type: + queryset = queryset.filter(account__type=account_type) + + # Filter by accounts if provided + accounts_param = request.query_params.get("accounts") + if accounts_param: + account_ids = self._parse_comma_separated_ids(accounts_param) + queryset = queryset.filter(account_id__in=account_ids) + + # Handle aggregation + agg = request.query_params.get("agg") + if agg: + if agg not in ("sum", "avg"): + return Response( + {"error": "Query parameter 'agg' must be 'sum' or 'avg'."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if agg == "sum": + result = queryset.aggregate(value=Sum("value")) + else: # avg + result = queryset.aggregate(value=Avg("value")) + + serializer = core_serializers.AggregatedMetricSerializer( + { + "key": key, + "service_id": service_id, + "aggregation": agg, + "value": result["value"] or 0, + "count": queryset.count(), + } + ) + return Response(serializer.data) + + # Handle group_by parameter + group_by = request.query_params.get("group_by") + if group_by == "organization": + # Group by organization and sum values + grouped = ( + queryset.values("organization__id", "organization__name") + .annotate(value=Sum("value")) + .order_by("organization__name") + ) + results = [ + { + "organization": { + "id": str(item["organization__id"]), + "name": item["organization__name"], + }, + "value": str(item["value"]), + } + for item in grouped + ] + return Response({"results": results, "grouped_by": "organization"}) + + # Return list of metrics + queryset = queryset.select_related("account", "organization").order_by( + "organization__name", "account__email" + ) + serializer = core_serializers.MetricSerializer(queryset, many=True) + return Response({"results": serializer.data}) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 01979e7..58521c7 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -24,6 +24,7 @@ ServiceLogoViewSet, SubscriptionEntitlementViewSet, ) +from .api.viewsets.metrics import OperatorMetricsViewSet from .api.viewsets.user import UserViewSet # Create router and register viewsets @@ -86,6 +87,11 @@ include( [ *operator_organization_router.urls, + path( + "metrics/", + OperatorMetricsViewSet.as_view({"get": "list"}), + name="operator-metrics", + ), re_path( r"^organizations/(?P[0-9a-z-]*)/", include( From 09d30ce5ea955a0cf449397d274fdbad30cdc65e Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Tue, 12 May 2026 17:37:09 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20operator=20metr?= =?UTF-8?q?ics=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render filtered/aggregated metrics with recharts, including a unit selector (raw, MB, GB, TB) so storage values can be read in a relevant scale without flooding the chart with bytes. --- src/frontend/package-lock.json | 328 +++++++++++++++ src/frontend/package.json | 1 + src/frontend/src/features/api/Repository.ts | 84 ++++ .../src/features/i18n/translations.json | 38 ++ src/frontend/src/hooks/useQueries.tsx | 25 +- .../[operator_id]/metrics/index.scss | 83 ++++ .../operators/[operator_id]/metrics/index.tsx | 388 ++++++++++++++++++ src/frontend/src/styles/globals.scss | 1 + 8 files changed, 942 insertions(+), 6 deletions(-) create mode 100644 src/frontend/src/pages/operators/[operator_id]/metrics/index.scss create mode 100644 src/frontend/src/pages/operators/[operator_id]/metrics/index.tsx diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 4c5b123..b13c9e3 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -23,6 +23,7 @@ "react-hook-form": "7.67.0", "react-i18next": "15.4.1", "react-toastify": "11.0.2", + "recharts": "2.15.3", "sass": "1.85.0" }, "devDependencies": { @@ -4498,6 +4499,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5711,6 +5775,127 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5796,6 +5981,12 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5909,6 +6100,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/downshift": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.0.9.tgz", @@ -6565,6 +6766,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/exenv": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", @@ -6577,6 +6784,15 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -7138,6 +7354,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "10.7.18", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", @@ -7713,6 +7938,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8621,6 +8852,21 @@ "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-stately": { "version": "3.37.0", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.37.0.tgz", @@ -8693,6 +8939,22 @@ "react-dom": "^18 || ^19" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/react-window": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", @@ -8723,6 +8985,44 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz", + "integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -9359,6 +9659,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9729,6 +10035,28 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index 12332a0..619a3d0 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -26,6 +26,7 @@ "react-hook-form": "7.67.0", "react-i18next": "15.4.1", "react-toastify": "11.0.2", + "recharts": "2.15.3", "sass": "1.85.0" }, "devDependencies": { diff --git a/src/frontend/src/features/api/Repository.ts b/src/frontend/src/features/api/Repository.ts index c66478e..a69e1be 100644 --- a/src/frontend/src/features/api/Repository.ts +++ b/src/frontend/src/features/api/Repository.ts @@ -350,3 +350,87 @@ export const updateAccountServiceLink = async ( ); return (await response.json()) as AccountServiceLink; }; + +// Metrics types +export type MetricAccount = { + id: string; + email: string; + external_id: string; + type: string; +}; + +export type MetricOrganization = { + id: string; + name: string; +}; + +export type Metric = { + id: number; + key: string; + value: string; + timestamp: string; + account: MetricAccount | null; + organization: MetricOrganization; +}; + +export type MetricsResponse = { + results: Metric[]; +}; + +export type GroupedMetricItem = { + organization: MetricOrganization; + value: string; +}; + +export type GroupedMetricsResponse = { + results: GroupedMetricItem[]; + grouped_by: "organization"; +}; + +export type AggregatedMetric = { + key: string; + service_id: string; + aggregation: "sum" | "avg"; + value: string; + count: number; +}; + +export type MetricsParams = { + key: string; + service: string; + organizations?: string[]; + accounts?: string[]; + account_type?: string; + agg?: "sum" | "avg"; + group_by?: "organization"; +}; + +export const getOperatorMetrics = async ( + operatorId: string, + params: MetricsParams +): Promise => { + const url = new URL(`/`, window.location.origin); + url.searchParams.append("key", params.key); + url.searchParams.append("service", params.service); + + if (params.organizations && params.organizations.length > 0) { + url.searchParams.append("organizations", params.organizations.join(",")); + } + if (params.accounts && params.accounts.length > 0) { + url.searchParams.append("accounts", params.accounts.join(",")); + } + if (params.account_type) { + url.searchParams.append("account_type", params.account_type); + } + if (params.agg) { + url.searchParams.append("agg", params.agg); + } + if (params.group_by) { + url.searchParams.append("group_by", params.group_by); + } + + const response = await fetchAPI( + `operators/${operatorId}/metrics/` + url.search + ); + return (await response.json()) as MetricsResponse | GroupedMetricsResponse | AggregatedMetric; +}; diff --git a/src/frontend/src/features/i18n/translations.json b/src/frontend/src/features/i18n/translations.json index 6978f1b..d6dac74 100644 --- a/src/frontend/src/features/i18n/translations.json +++ b/src/frontend/src/features/i18n/translations.json @@ -247,6 +247,44 @@ "error": { "unexpected": "Une erreur inattendue est survenue" } + }, + "metrics": { + "title": "Métriques", + "subtitle": "Visualisez les métriques de vos services", + "filters": { + "service": "Service", + "service_placeholder": "Sélectionner un service", + "key": "Métrique", + "key_placeholder": "Sélectionner une métrique", + "organizations": "Organisations", + "organizations_placeholder": "Toutes les organisations", + "account_type": "Type de compte", + "account_type_placeholder": "Tous les types", + "accounts": "Comptes", + "accounts_placeholder": "Tous les comptes", + "aggregation": "Agrégation", + "aggregation_options": { + "none": "Aucune", + "sum": "Somme", + "avg": "Moyenne" + }, + "unit": "Unité", + "unit_options": { + "none": "Aucune" + } + }, + "chart": { + "no_data": "Aucune donnée à afficher", + "select_filters": "Sélectionnez un service et une métrique pour afficher les données", + "aggregated_value": "Valeur agrégée", + "value_axis": "Valeur", + "account_axis": "Compte", + "results_count": "{{count}} résultat(s)" + }, + "account_types": { + "user": "Utilisateur", + "mailbox": "Boîte mail" + } } } } diff --git a/src/frontend/src/hooks/useQueries.tsx b/src/frontend/src/hooks/useQueries.tsx index fd73334..a5086db 100644 --- a/src/frontend/src/hooks/useQueries.tsx +++ b/src/frontend/src/hooks/useQueries.tsx @@ -15,6 +15,8 @@ import { Account, getOperatorServices, updateOperatorOrganizationRole, + getOperatorMetrics, + MetricsParams, } from "@/features/api/Repository"; import { getOrganization } from "@/features/api/Repository"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -44,7 +46,7 @@ export const useOrganization = (operatorId: string, organizationId: string) => { export const useOperatorOrganizations = ( operatorId: string, - params: Parameters[1] + params: Parameters[1], ) => { return useQuery({ queryKey: [ @@ -59,7 +61,7 @@ export const useOperatorOrganizations = ( export const useOrganizationServices = ( operatorId: string, - organizationId: string + organizationId: string, ) => { return useQuery({ queryKey: [ @@ -88,7 +90,7 @@ export const useMutationDeleteOrganizationServiceSubscription = () => { return deleteOrganizationServiceSubscription( operatorId, organizationId, - serviceId + serviceId, ); }, onSuccess: (data, variables) => { @@ -120,7 +122,7 @@ export const useMutationUpdateOrganizationServiceSubscription = () => { operatorId, organizationId, serviceId, - data + data, ); }, onSuccess: (data, variables) => { @@ -169,7 +171,7 @@ export const useMutationUpdateEntitlement = () => { export const useOrganizationAccounts = ( operatorId: string, organizationId: string, - params: Parameters[2] + params: Parameters[2], ) => { return useQuery({ queryKey: [ @@ -299,7 +301,7 @@ export const useMutationUpdateAccountServiceLink = () => { export const useMessagesAdminCount = ( operatorId: string, organizationId: string, - serviceId: string + serviceId: string, ) => { return useQuery({ queryKey: [ @@ -356,4 +358,15 @@ export const useMutationUpdateOperatorOrganizationRole = () => { }); }; +export const useOperatorMetrics = ( + operatorId: string, + params: MetricsParams | null, +) => { + return useQuery({ + queryKey: ["operators", operatorId, "metrics", JSON.stringify(params)], + queryFn: () => getOperatorMetrics(operatorId, params!), + enabled: !!operatorId && !!params?.key && !!params?.service, + }); +}; + export default useOperator; diff --git a/src/frontend/src/pages/operators/[operator_id]/metrics/index.scss b/src/frontend/src/pages/operators/[operator_id]/metrics/index.scss new file mode 100644 index 0000000..ba243fd --- /dev/null +++ b/src/frontend/src/pages/operators/[operator_id]/metrics/index.scss @@ -0,0 +1,83 @@ +.dc__metrics { + &__filters { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1rem; + padding: 1.5rem; + background: var(--c--theme--colors--greyscale-100); + border-radius: 8px; + + > * { + min-width: 200px; + flex: 1; + } + } + + &__results-count { + margin-bottom: 1rem; + padding: 0.5rem 0; + font-size: 0.875rem; + color: var(--c--theme--colors--greyscale-600); + font-weight: 500; + } + + &__chart-container { + background: var(--c--theme--colors--greyscale-000); + border: 1px solid var(--c--theme--colors--greyscale-200); + border-radius: 8px; + padding: 1.5rem; + min-height: 400px; + display: flex; + flex-direction: column; + + &__placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--c--theme--colors--greyscale-500); + font-size: 1rem; + text-align: center; + } + } + + &__scrollable-chart { + overflow-y: auto; + max-height: 600px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: var(--c--theme--colors--greyscale-100); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--c--theme--colors--greyscale-400); + border-radius: 4px; + } + } + + &__aggregated-chart { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + + &__value { + font-size: 3rem; + font-weight: 700; + color: var(--c--theme--colors--primary-600); + margin-bottom: 0.5rem; + } + + &__label { + font-size: 1rem; + color: var(--c--theme--colors--greyscale-600); + } + } +} diff --git a/src/frontend/src/pages/operators/[operator_id]/metrics/index.tsx b/src/frontend/src/pages/operators/[operator_id]/metrics/index.tsx new file mode 100644 index 0000000..aa4f473 --- /dev/null +++ b/src/frontend/src/pages/operators/[operator_id]/metrics/index.tsx @@ -0,0 +1,388 @@ +import { useRouter } from "next/router"; +import { + getGlobalExplorerLayout, + useOperatorContext, +} from "@/features/layouts/components/GlobalLayout"; +import { Container } from "@/features/layouts/components/container/Container"; +import { useTranslation } from "react-i18next"; +import { Select } from "@openfun/cunningham-react"; +import { Icon } from "@gouvfr-lasuite/ui-kit"; +import { Breadcrumbs } from "@/features/ui/components/breadcrumbs/Breadcrumbs"; +import { useMemo, useState } from "react"; +import { + useOperatorMetrics, + useOperatorOrganizations, + useOperatorServices, +} from "@/hooks/useQueries"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import { + AggregatedMetric, + GroupedMetricsResponse, + MetricsParams, + MetricsResponse, +} from "@/features/api/Repository"; + +// Available metric keys - these could be fetched from an API in the future +const METRIC_KEYS = [ + "storage_used", + "storage_quota", + "user_count", + "mailbox_count", + "active_users", + "message_count", +]; + +const ACCOUNT_TYPES = ["user", "mailbox"]; + +type Unit = "" | "MB" | "GB" | "TB"; + +const UNIT_DIVISORS: Record, number> = { + MB: 1024 ** 2, + GB: 1024 ** 3, + TB: 1024 ** 4, +}; + +export default function MetricsPage() { + const router = useRouter(); + const operatorId = router.query.operator_id as string; + const { t } = useTranslation(); + + useOperatorContext(); + + // Filter states + const [serviceFilter, setServiceFilter] = useState(""); + const [keyFilter, setKeyFilter] = useState(""); + const [organizationFilter, setOrganizationFilter] = useState([]); + const [accountTypeFilter, setAccountTypeFilter] = useState(""); + const [aggregation, setAggregation] = useState<"" | "sum" | "avg">(""); + const [unit, setUnit] = useState(""); + + // Fetch operator services for the service filter + const { data: operatorServices } = useOperatorServices(operatorId); + + // Fetch organizations for the organization filter + const { data: organizations } = useOperatorOrganizations(operatorId, {}); + + // Build metrics params only when we have required filters + const metricsParams: MetricsParams | null = useMemo(() => { + if (!serviceFilter || !keyFilter) return null; + + // When no organization filter is set and no aggregation, group by organization + const shouldGroupByOrg = organizationFilter.length === 0 && !aggregation; + + return { + key: keyFilter, + service: serviceFilter, + organizations: + organizationFilter.length > 0 ? organizationFilter : undefined, + account_type: accountTypeFilter || undefined, + agg: aggregation || undefined, + group_by: shouldGroupByOrg ? "organization" : undefined, + }; + }, [ + serviceFilter, + keyFilter, + organizationFilter, + accountTypeFilter, + aggregation, + ]); + + // Fetch metrics + const { data: metricsData, isLoading: isMetricsLoading } = useOperatorMetrics( + operatorId, + metricsParams + ); + + // Determine response type + const isAggregated = aggregation && metricsData && "aggregation" in metricsData; + const isGroupedByOrg = metricsData && "grouped_by" in metricsData && metricsData.grouped_by === "organization"; + + // Get results count + const resultsCount = useMemo(() => { + if (!metricsData) return 0; + if (isAggregated) { + return (metricsData as AggregatedMetric).count; + } + if (isGroupedByOrg) { + return (metricsData as GroupedMetricsResponse).results.length; + } + return (metricsData as MetricsResponse).results.length; + }, [metricsData, isAggregated, isGroupedByOrg]); + + // Prepare chart data + const chartData = useMemo(() => { + if (!metricsData) return []; + + const convert = (raw: string) => { + const parsed = parseFloat(raw); + return unit ? parsed / UNIT_DIVISORS[unit] : parsed; + }; + + if (isAggregated) { + const aggData = metricsData as AggregatedMetric; + return [ + { + name: t(`metrics.filters.aggregation_options.${aggData.aggregation}`), + value: convert(aggData.value), + }, + ]; + } + + if (isGroupedByOrg) { + const response = metricsData as GroupedMetricsResponse; + return response.results.map((item) => ({ + name: item.organization.name, + value: convert(item.value), + })); + } + + const response = metricsData as MetricsResponse; + return response.results.map((metric) => ({ + name: metric.account + ? metric.account.email || metric.account.external_id + : metric.organization.name, + value: convert(metric.value), + accountType: metric.account?.type, + organization: metric.organization.name, + })); + }, [metricsData, isAggregated, isGroupedByOrg, t, unit]); + + // Calculate dynamic chart height based on data + const chartHeight = useMemo(() => { + if (isAggregated) return 200; + const minHeight = 300; + const barHeight = 40; + return Math.max(minHeight, chartData.length * barHeight); + }, [chartData, isAggregated]); + + // Chart colors + const chartColors = [ + "#000091", // Primary blue + "#6a6af4", + "#009099", + "#c9191e", + "#b34000", + "#1f8d49", + ]; + + return ( + + + router.push(`/operators/${operatorId}/metrics`) + } + > + + {t("metrics.title")} + + ), + }, + ]} + /> +
+ {t("metrics.subtitle")} +
+ + } + > + {/* Filters */} +
+ setKeyFilter((e.target.value as string) || "")} + disabled={!serviceFilter} + options={[ + { label: t("metrics.filters.key_placeholder"), value: "" }, + ...METRIC_KEYS.map((key) => ({ + label: key, + value: key, + })), + ]} + /> + + setAccountTypeFilter((e.target.value as string) || "")} + options={[ + { label: t("metrics.filters.account_type_placeholder"), value: "" }, + ...ACCOUNT_TYPES.map((type) => ({ + label: t(`metrics.account_types.${type}`), + value: type, + })), + ]} + /> + + setUnit((e.target.value as Unit) || "")} + options={[ + { label: t("metrics.filters.unit_options.none"), value: "" }, + { label: "MB", value: "MB" }, + { label: "GB", value: "GB" }, + { label: "TB", value: "TB" }, + ]} + /> +
+ + {/* Results count */} + {metricsData && resultsCount > 0 && ( +
+ {t("metrics.chart.results_count", { count: resultsCount })} +
+ )} + + {/* Chart */} +
+ {!serviceFilter || !keyFilter ? ( +
+ {t("metrics.chart.select_filters")} +
+ ) : isMetricsLoading ? ( +
+ Loading... +
+ ) : chartData.length === 0 ? ( +
+ {t("metrics.chart.no_data")} +
+ ) : isAggregated ? ( + // Aggregated view - single value +
+
+ {chartData[0] !== undefined + ? unit + ? `${chartData[0].value.toLocaleString(undefined, { + maximumFractionDigits: 2, + })} ${unit}` + : chartData[0].value.toLocaleString() + : ""} +
+
+ {t("metrics.chart.aggregated_value")} ({chartData[0]?.name}) +
+
+ ) : ( + // Non-aggregated view - vertical bars (horizontal layout) per account +
+ + + + unit + ? value.toLocaleString(undefined, { + maximumFractionDigits: 2, + }) + : value.toLocaleString() + } + /> + + [ + unit + ? `${value.toLocaleString(undefined, { + maximumFractionDigits: 2, + })} ${unit}` + : value.toLocaleString(), + t("metrics.chart.value_axis"), + ]} + labelFormatter={(label) => label} + /> + + {chartData.map((_, index) => ( + + ))} + + + +
+ )} +
+
+ ); +} + +MetricsPage.getLayout = getGlobalExplorerLayout; diff --git a/src/frontend/src/styles/globals.scss b/src/frontend/src/styles/globals.scss index da9ae10..4a83e0c 100644 --- a/src/frontend/src/styles/globals.scss +++ b/src/frontend/src/styles/globals.scss @@ -13,6 +13,7 @@ @use "./../pages/operators/index.scss" as *; @use "./../pages/operators/[operator_id]/index.scss" as *; @use "./../pages/operators/[operator_id]/organizations/index.scss" as *; +@use "./../pages/operators/[operator_id]/metrics/index.scss" as *; body { margin: 0; From acc1684363e646a65469da8b3eb09a7b99321ae1 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Tue, 12 May 2026 17:37:30 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8(backend)=20add=20create=5Fdemo=5F?= =?UTF-8?q?operator=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed a complete operator with services, organizations and metrics so the dashboard can be exercised end-to-end in local and demo environments without hand-crafting fixtures. --- .../commands/create_demo_operator.py | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 src/backend/core/management/commands/create_demo_operator.py diff --git a/src/backend/core/management/commands/create_demo_operator.py b/src/backend/core/management/commands/create_demo_operator.py new file mode 100644 index 0000000..0e156db --- /dev/null +++ b/src/backend/core/management/commands/create_demo_operator.py @@ -0,0 +1,368 @@ +""" +Management command to create a demo operator with services, organizations, and metrics. + +This command sets up a complete demo environment for testing purposes: +- Creates an operator and links it to a user +- Creates messages and drive services +- Links random organizations to the operator +- Generates accounts and metrics for each organization +""" + +import random +import uuid +from decimal import Decimal +from logging import getLogger + +from django.core.management.base import BaseCommand, CommandError + +from core.models import ( + Account, + Metric, + Operator, + OperatorOrganizationRole, + OperatorServiceConfig, + Organization, + Service, + User, + UserOperatorRole, +) + +logger = getLogger(__name__) + +# Constants +ORGANIZATIONS_TO_LINK = 10 +METRICS_PER_SERVICE = 200 +METRIC_MIN_VALUE = 0 +METRIC_MAX_VALUE = 10_000_000 + + +def create_operator(name: str) -> Operator: + """ + Create and return a new Operator. + + Args: + name: The name for the new operator. + + Returns: + The created Operator instance. + """ + operator = Operator.objects.create( + name=name, + is_active=True, + ) + logger.info("Created operator: %s", operator.name) + return operator + + +def get_or_create_user(email: str) -> User: + """ + Get or create a user with the given email. + + Args: + email: The email address for the user. + + Returns: + The User instance. + """ + user, created = User.objects.get_or_create( + email=email, + defaults={"full_name": f"Demo User ({email})"}, + ) + if created: + logger.info("Created user: %s", email) + else: + logger.info("Found existing user: %s", email) + return user + + +def create_user_operator_role(user: User, operator: Operator) -> UserOperatorRole: + """ + Link a user to an operator with admin role. + + Args: + user: The user to link. + operator: The operator to link to. + + Returns: + The created UserOperatorRole instance. + """ + role = UserOperatorRole.objects.create( + user=user, + operator=operator, + role="admin", + ) + logger.info("Created user operator role: %s -> %s", user.email, operator.name) + return role + + +def create_services(operator: Operator) -> tuple[Service, Service]: + """ + Create messages and drive services and link them to the operator. + + Args: + operator: The operator to link services to. + + Returns: + A tuple of (messages_service, drive_service). + """ + messages_service = Service.objects.create( + type="messages", + name=f"Messages ({operator.name})", + instance_name=f"messages-{operator.name.lower().replace(' ', '-')}", + url=f"https://messages.{operator.name.lower().replace(' ', '-')}.example.com", + description="Demo messages service", + is_active=True, + ) + logger.info("Created messages service: %s", messages_service.name) + + drive_service = Service.objects.create( + type="drive", + name=f"Drive ({operator.name})", + instance_name=f"drive-{operator.name.lower().replace(' ', '-')}", + url=f"https://drive.{operator.name.lower().replace(' ', '-')}.example.com", + description="Demo drive service", + is_active=True, + ) + logger.info("Created drive service: %s", drive_service.name) + + # Link services to operator via OperatorServiceConfig + OperatorServiceConfig.objects.create( + operator=operator, + service=messages_service, + display_priority=1, + ) + OperatorServiceConfig.objects.create( + operator=operator, + service=drive_service, + display_priority=2, + ) + logger.info("Linked services to operator: %s", operator.name) + + return messages_service, drive_service + + +def link_organizations(operator: Operator, count: int) -> list[Organization]: + """ + Randomly select and link organizations to the operator. + + Uses an efficient approach that avoids loading all organizations into memory: + fetches only the PKs, samples from them, then fetches the selected records. + + Args: + operator: The operator to link organizations to. + count: Number of organizations to link. + + Returns: + List of linked Organization instances. + """ + # Only fetch PKs to minimize memory usage + all_pks = list(Organization.objects.values_list("pk", flat=True)) + total_count = len(all_pks) + + if total_count < count: + raise CommandError( + f"Not enough organizations in database. Found {total_count}, need {count}." + ) + + # Sample random PKs and fetch only those organizations + selected_pks = random.sample(all_pks, count) + selected_organizations = list(Organization.objects.filter(pk__in=selected_pks)) + + for organization in selected_organizations: + OperatorOrganizationRole.objects.create( + operator=operator, + organization=organization, + role="admin", + ) + logger.info("Linked organization: %s -> %s", organization.name, operator.name) + + return selected_organizations + + +def generate_random_email() -> str: + """Generate a random email address.""" + random_id = uuid.uuid4().hex[:8] + return f"user-{random_id}@demo.example.com" + + +def generate_random_external_id() -> str: + """Generate a random external ID.""" + return uuid.uuid4().hex + + +def generate_random_metric_value() -> Decimal: + """Generate a random metric value between 0 and 10 million.""" + return Decimal(random.randint(METRIC_MIN_VALUE, METRIC_MAX_VALUE)) + + +def create_accounts_and_metrics( + organization: Organization, + service: Service, + account_type: str, + count: int, + include_email: bool, +) -> tuple[list[Account], list[Metric]]: + """ + Create accounts and metrics for an organization and service. + + Args: + organization: The organization to create accounts for. + service: The service to create metrics for. + account_type: The type of account ("user" or "mailbox"). + count: Number of accounts and metrics to create. + include_email: Whether to include email in accounts. + + Returns: + A tuple of (accounts_list, metrics_list). + """ + accounts = [] + metrics = [] + + for _ in range(count): + email = generate_random_email() if include_email else "" + external_id = generate_random_external_id() + + account = Account( + email=email, + external_id=external_id, + type=account_type, + organization=organization, + ) + accounts.append(account) + + # Bulk create accounts + Account.objects.bulk_create(accounts) + logger.info( + "Created %d accounts (type=%s) for organization: %s", + len(accounts), + account_type, + organization.name, + ) + + # Create metrics for each account + for account in accounts: + metric = Metric( + key="storage_used", + value=generate_random_metric_value(), + service=service, + organization=organization, + account=account, + ) + metrics.append(metric) + + # Bulk create metrics + Metric.objects.bulk_create(metrics) + logger.info( + "Created %d metrics for service %s, organization: %s", + len(metrics), + service.name, + organization.name, + ) + + return accounts, metrics + + +class Command(BaseCommand): + """Management command to create a demo operator with full setup.""" + + help = """ + Create a demo operator with services, organizations, and metrics. + + This command sets up: + - A new operator linked to the specified user + - Messages and Drive services + - 10 randomly selected organizations linked to the operator + - 200 accounts and metrics per organization per service + + Example: + python manage.py create_demo_operator --email admin@example.com + """ + + def add_arguments(self, parser): + """Add command line arguments.""" + parser.add_argument( + "--email", + required=True, + help="Email address for the user to link to the operator", + ) + parser.add_argument( + "--operator-name", + default=None, + help="Name for the operator (defaults to 'Demo Operator ')", + ) + + def handle(self, *args, **options): + """Execute the command.""" + email = options["email"] + operator_name = options["operator_name"] or f"Demo Operator {uuid.uuid4().hex[:6]}" + + self.stdout.write(f"Creating demo operator: {operator_name}") + self.stdout.write(f"User email: {email}") + self.stdout.write("") + + # Step 1: Create operator + self.stdout.write("Step 1: Creating operator...") + operator = create_operator(operator_name) + self.stdout.write(self.style.SUCCESS(f" Created operator: {operator.name}")) + + # Step 2: Get or create user and link to operator + self.stdout.write("Step 2: Setting up user and role...") + user = get_or_create_user(email) + create_user_operator_role(user, operator) + self.stdout.write(self.style.SUCCESS(f" Linked user {email} to operator")) + + # Step 3: Create services + self.stdout.write("Step 3: Creating services...") + messages_service, drive_service = create_services(operator) + self.stdout.write(self.style.SUCCESS(f" Created messages service: {messages_service.name}")) + self.stdout.write(self.style.SUCCESS(f" Created drive service: {drive_service.name}")) + + # Step 4: Link organizations + self.stdout.write(f"Step 4: Linking {ORGANIZATIONS_TO_LINK} organizations...") + try: + organizations = link_organizations(operator, ORGANIZATIONS_TO_LINK) + self.stdout.write( + self.style.SUCCESS(f" Linked {len(organizations)} organizations") + ) + except CommandError as e: + self.stdout.write(self.style.ERROR(str(e))) + raise + + # Step 5: Create accounts and metrics for each organization + self.stdout.write("Step 5: Creating accounts and metrics...") + total_accounts = 0 + total_metrics = 0 + + for organization in organizations: + self.stdout.write(f" Processing organization: {organization.name}") + + # Drive service: accounts with type "user" (with email and external_id) + drive_accounts, drive_metrics = create_accounts_and_metrics( + organization=organization, + service=drive_service, + account_type="user", + count=METRICS_PER_SERVICE, + include_email=True, + ) + total_accounts += len(drive_accounts) + total_metrics += len(drive_metrics) + + # Messages service: accounts with type "mailbox" (no email, only external_id) + messages_accounts, messages_metrics = create_accounts_and_metrics( + organization=organization, + service=messages_service, + account_type="mailbox", + count=METRICS_PER_SERVICE, + include_email=False, + ) + total_accounts += len(messages_accounts) + total_metrics += len(messages_metrics) + + self.stdout.write("") + self.stdout.write(self.style.SUCCESS("Demo operator created successfully!")) + self.stdout.write(self.style.SUCCESS(f" Operator: {operator.name} (ID: {operator.id})")) + self.stdout.write(self.style.SUCCESS(f" User: {email}")) + self.stdout.write(self.style.SUCCESS(f" Services: {messages_service.name}, {drive_service.name}")) + self.stdout.write(self.style.SUCCESS(f" Organizations linked: {len(organizations)}")) + self.stdout.write(self.style.SUCCESS(f" Total accounts created: {total_accounts}")) + self.stdout.write(self.style.SUCCESS(f" Total metrics created: {total_metrics}")) From a87de804d2c91af903168cdb906095ba1270f76d Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Tue, 12 May 2026 17:37:42 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20desktop=20left?= =?UTF-8?q?=20panel=20with=20primary=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface "Adhérents" and "Métriques" entries on every operator-scoped page so users can move between sections without going through the breadcrumb. --- .../src/features/i18n/translations.json | 5 ++ .../layouts/components/GlobalLayout.tsx | 5 +- .../components/left-panel/LeftPanel.scss | 75 +++++++++++++++++++ .../components/left-panel/LeftPanel.tsx | 74 ++++++++++++++++++ src/frontend/src/styles/globals.scss | 1 + 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/frontend/src/features/layouts/components/left-panel/LeftPanel.scss create mode 100644 src/frontend/src/features/layouts/components/left-panel/LeftPanel.tsx diff --git a/src/frontend/src/features/i18n/translations.json b/src/frontend/src/features/i18n/translations.json index d6dac74..2bf0cc3 100644 --- a/src/frontend/src/features/i18n/translations.json +++ b/src/frontend/src/features/i18n/translations.json @@ -6,6 +6,11 @@ "save": "Valider" }, "app_title": "Espace Opérateur", + "left_panel": { + "aria_label": "Navigation principale", + "organizations": "Adhérents", + "metrics": "Métriques" + }, "home": { "title": "Espace Opérateur", "subtitle": "Activez et gérez simplement les services de tous vos adhérents.", diff --git a/src/frontend/src/features/layouts/components/GlobalLayout.tsx b/src/frontend/src/features/layouts/components/GlobalLayout.tsx index 025fd34..89fc4c3 100644 --- a/src/frontend/src/features/layouts/components/GlobalLayout.tsx +++ b/src/frontend/src/features/layouts/components/GlobalLayout.tsx @@ -2,7 +2,7 @@ import { Auth } from "@/features/auth/Auth"; import { MainLayout } from "@gouvfr-lasuite/ui-kit"; import { HeaderRight } from "./header/Header"; import { HeaderIcon } from "./header/Header"; -import { LeftPanelMobile } from "./left-panel/LeftPanelMobile"; +import { LeftPanel } from "./left-panel/LeftPanel"; import { Toaster } from "@/features/ui/components/toaster/Toaster"; import { FeedbackWidget } from "@/features/ui/components/feedback-widget"; import { createContext, useContext } from "react"; @@ -31,8 +31,7 @@ export const GlobalExplorerLayout = ({ } + leftPanelContent={} enableResize icon={} rightHeaderContent={} diff --git a/src/frontend/src/features/layouts/components/left-panel/LeftPanel.scss b/src/frontend/src/features/layouts/components/left-panel/LeftPanel.scss new file mode 100644 index 0000000..96ad224 --- /dev/null +++ b/src/frontend/src/features/layouts/components/left-panel/LeftPanel.scss @@ -0,0 +1,75 @@ +.dc__left-panel { + display: flex; + flex-direction: column; + height: 100%; + padding: 1rem 0.75rem; + gap: 1rem; + + &__nav { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; + + &__link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: 6px; + color: var(--c--theme--colors--greyscale-700); + text-decoration: none; + font-size: 0.9375rem; + font-weight: 500; + transition: + background-color 120ms ease, + color 120ms ease; + + &:hover { + background-color: var(--c--theme--colors--greyscale-100); + color: var(--c--theme--colors--greyscale-900); + } + + &--active { + background-color: var(--c--theme--colors--primary-100); + color: var(--c--theme--colors--primary-700); + + &:hover { + background-color: var(--c--theme--colors--primary-100); + color: var(--c--theme--colors--primary-700); + } + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + .material-symbols-outlined, + .material-icons { + font-size: 1.25rem; + line-height: 1; + } + } + } + } + + &__auth { + display: flex; + justify-content: center; + padding-top: 0.5rem; + border-top: 1px solid var(--c--theme--colors--greyscale-200); + } +} diff --git a/src/frontend/src/features/layouts/components/left-panel/LeftPanel.tsx b/src/frontend/src/features/layouts/components/left-panel/LeftPanel.tsx new file mode 100644 index 0000000..03fd2fc --- /dev/null +++ b/src/frontend/src/features/layouts/components/left-panel/LeftPanel.tsx @@ -0,0 +1,74 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@gouvfr-lasuite/ui-kit"; +import { useAuth } from "@/features/auth/Auth"; +import { LogoutButton } from "@/features/auth/components/LogoutButton"; +import { LoginButton } from "@/features/auth/components/LoginButton"; + +type NavItem = { + label: string; + href: string; + icon: { type: "material"; name: string } | { type: "image"; src: string }; + isActive: (pathname: string) => boolean; +}; + +export const LeftPanel = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { user } = useAuth(); + const operatorId = router.query.operator_id as string; + + const items: NavItem[] = operatorId + ? [ + { + label: t("left_panel.organizations"), + href: `/operators/${operatorId}`, + icon: { type: "image", src: "/assets/icons/organization.svg" }, + isActive: (pathname) => + pathname === "/operators/[operator_id]" || + pathname.startsWith("/operators/[operator_id]/organizations"), + }, + { + label: t("left_panel.metrics"), + href: `/operators/${operatorId}/metrics`, + icon: { type: "material", name: "bar_chart" }, + isActive: (pathname) => + pathname.startsWith("/operators/[operator_id]/metrics"), + }, + ] + : []; + + return ( + + ); +}; diff --git a/src/frontend/src/styles/globals.scss b/src/frontend/src/styles/globals.scss index 4a83e0c..48076e1 100644 --- a/src/frontend/src/styles/globals.scss +++ b/src/frontend/src/styles/globals.scss @@ -7,6 +7,7 @@ @use "./../features/ui/components/service/entitlements/fields/StoragePickerEntitlementField.scss" as *; @use "./../features/layouts/components/container/Container.scss" as *; +@use "./../features/layouts/components/left-panel/LeftPanel.scss" as *; @use "./../features/ui/components/spinner/SpinnerPage.scss"; @use "./../pages/index.scss" as *;