Skip to content

[POC] Provide a metric dashboard - #30

Open
NathanVss wants to merge 4 commits into
mainfrom
feat/metrics-dashboard
Open

[POC] Provide a metric dashboard#30
NathanVss wants to merge 4 commits into
mainfrom
feat/metrics-dashboard

Conversation

@NathanVss

@NathanVss NathanVss commented Jan 30, 2026

Copy link
Copy Markdown
Contributor

Purpose

Provide a metric dashboard. It is a first shot waiting for feedbacks.

To get a sample or data in order to test this dashboard run python manage.py create_demo_operator

Todo

  • Write tests
  • Make sure permissions are bullet proof on API
  • Clean code

Proposal

Capture d’écran 2026-05-12 à 17 32 45 Capture d’écran 2026-05-12 à 17 32 35

Summary by CodeRabbit

  • New Features

    • Operator metrics dashboard with filters (service, metric key, organizations, account type, accounts).
    • Aggregation (sum/avg) and grouping (by organization) with results count.
    • Interactive charts for aggregated and per-item views; responsive, scrollable display.
  • Chores

    • Added charting library.
    • French translations and global styles for the new metrics UI.

Review Change Stack

@coderabbitai

coderabbitai Bot commented Jan 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds an operator-scoped metrics feature: backend serializers and a DRF viewset supporting filtering, aggregation, and grouping; URL routing; a demo-data management command; frontend types, client, hook, page, styles, translations; and Recharts for visualization.

Changes

Cohort / File(s) Summary
Backend Serializers
src/backend/core/api/serializers.py
Added read-only metric serializers: MetricAccountSerializer, MetricOrganizationSerializer, MetricSerializer (nests account/organization), and AggregatedMetricSerializer (create/update raise NotImplementedError).
Backend API Endpoint
src/backend/core/api/viewsets/metrics.py
New OperatorMetricsViewSet with list endpoint requiring key and service, supporting optional organizations, accounts, account_type, agg (sum/avg), and group_by=organization; includes auth/permission checks, service validation, operator org scoping, aggregation/grouping logic, and serialization.
Backend URL Routing
src/backend/core/urls.py
Imported and registered operator-scoped metrics/ route mapping GET to OperatorMetricsViewSet.list.
Demo Data Generation
src/backend/core/management/commands/create_demo_operator.py
New management command to scaffold demo operator, user/role, two services, link organizations, bulk-create accounts and metrics with random-data helpers and CLI options.
Frontend Dependency
src/frontend/package.json
Added recharts@2.15.3 to dependencies for charting.
Frontend API Types & Client
src/frontend/src/features/api/Repository.ts
Added TypeScript types for metrics payloads and getOperatorMetrics to call operators/{operatorId}/metrics/ with constructed query params and return unioned response types (detailed, grouped, aggregated).
Frontend Translations
src/frontend/src/features/i18n/translations.json
Added French metrics translations (titles, filters, chart labels, account types).
Frontend React Query Hook
src/frontend/src/hooks/useQueries.tsx
Added useOperatorMetrics hook that queries operator metrics with key ["operators", operatorId, "metrics", JSON.stringify(params)] and enables only when operatorId, params.key, and params.service are present.
Frontend Metrics Page & Styles
src/frontend/src/pages/operators/[operator_id]/metrics/index.tsx, .../index.scss, src/frontend/src/styles/globals.scss
New Next.js metrics page with filter controls, result-count display, conditional aggregated/grouped/detailed chart rendering, SCSS for layout and chart panels, and global stylesheet import.

Sequence Diagram

sequenceDiagram
    actor User
    participant Frontend as MetricsPage
    participant API as OperatorMetricsViewSet
    participant Service as ServiceModel
    participant Perms as OperatorAccessPermission
    participant DB as Database
    participant Serializer as MetricSerializer/AggregatedMetricSerializer

    User->>Frontend: select filters (key, service, ...)
    Frontend->>API: GET /operators/{id}/metrics?key=...&service=...
    API->>Service: validate service exists
    Service-->>API: service record
    API->>Perms: compute allowed organization ids
    Perms-->>API: allowed org ids
    API->>DB: query metrics with filters/agg/grouping
    DB-->>API: metric rows or aggregates
    API->>Serializer: serialize results
    Serializer-->>API: JSON payload
    API-->>Frontend: MetricsResponse | GroupedMetricsResponse | AggregatedMetric
    Frontend->>User: render chart/UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • sylvinus
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 94.74% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title '[POC] Provide a metric dashboard' directly corresponds to the main change: a comprehensive metrics dashboard implementation with backend serializers, viewsets, frontend UI components, and styling.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/backend/core/management/commands/create_demo_operator.py`:
- Line 94: The log statement at logger.info("Created user operator role: %s ->
%s", user.email, operator.name) exposes PII (user.email); update it to avoid
logging the email by replacing user.email with a non-PII identifier such as
user.id or user.pk (or an obfuscated/masked version) in the
create_demo_operator.py code path where the logger.info call lives so the
message still records the created mapping without exposing the user's email.
- Around line 72-74: The current logging statements in create_demo_operator.py
call logger.info with the raw email variable, which exposes PII; update the
logger calls in the block that uses logger.info("Created user: %s", email) and
logger.info("Found existing user: %s", email) to avoid logging the full email
(variable email). Replace them with a non-PII alternative such as logging the
created user's internal id (user.id) or a masked/anonymized identifier (e.g.,
mask_email(email) or email[:1] + "***" + domain) or simply logging a user count
message ("Created demo user" / "Found existing demo user") so no raw email is
emitted.

In `@src/frontend/src/pages/operators/`[operator_id]/metrics/index.tsx:
- Around line 267-270: The "Loading..." literal should be replaced with the i18n
translation function: import and call useTranslation in the component (const { t
} = useTranslation()) if not already present, then change the placeholder
content from "Loading..." to {t('common.loading')} (or your project's preferred
key like 'loading'), keeping the surrounding JSX and the isMetricsLoading
conditional intact; ensure the useTranslation import comes from your i18n
library (e.g., react-i18next) and that the translation key exists in your locale
files.
🧹 Nitpick comments (6)
src/frontend/src/features/i18n/translations.json (1)

214-217: Consider reusing existing account type translations.

The account_types translations duplicate values already defined in accounts.filter.types (lines 132-135). If the UI allows, consider referencing a shared key to avoid maintaining duplicate strings.

src/frontend/src/pages/operators/[operator_id]/metrics/index.tsx (2)

32-42: Consider fetching metric keys and account types from the backend.

The comment acknowledges this limitation. Hardcoding these values creates a maintenance burden and potential frontend/backend drift.

For now this is acceptable for WIP, but consider adding a /api/.../operators/{operator_id}/metrics/schema/ endpoint or similar in the future to provide available keys and account types dynamically.


161-176: Breadcrumb button navigates to the current page.

The breadcrumb button navigates to /operators/${operatorId}/metrics, which is the current page. This creates a redundant navigation action.

🔧 Consider removing the click handler or updating the path
           <Breadcrumbs
             items={[
               {
-                content: <button className="c__breadcrumbs__button" onClick={() => router.push(`/operators/${operatorId}/metrics`)}>{t("metrics.title")}</button>,
+                content: <span className="c__breadcrumbs__button">{t("metrics.title")}</span>,
               },
             ]}
           />
src/backend/core/api/viewsets/metrics.py (3)

85-87: Use a set comprehension instead of a generator.

Per static analysis, this can be simplified.

♻️ Suggested fix
         # 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)
-        )
+        allowed_org_ids = {
+            str(org_id) for org_id in self._get_operator_organizations(operator_id)
+        }

57-82: Consider adding logging for observability.

As per coding guidelines, the application should log expected and unexpected actions. Consider adding logging for:

  • When a service is not found (INFO level)
  • When invalid/unauthorized organization IDs are requested (DEBUG level)
  • Optionally, successful metric queries at DEBUG level for troubleshooting
📝 Example
import logging

logger = logging.getLogger(__name__)

# In list method, after service not found:
logger.info("Service %s not found for metrics request", service_id)

# After filtering unauthorized orgs:
if requested_org_ids - allowed_org_ids:
    logger.debug(
        "Filtered out unauthorized org IDs: %s",
        requested_org_ids - allowed_org_ids
    )

131-144: Optimize to avoid duplicate database queries.

The current implementation issues two separate queries: one for the aggregation and one for the count. These can be combined into a single query.

♻️ Suggested fix
+from django.db.models import Avg, Count, Sum
 
 # ... in list method:
             if agg == "sum":
-                result = queryset.aggregate(value=Sum("value"))
+                result = queryset.aggregate(value=Sum("value"), count=Count("id"))
             else:  # avg
-                result = queryset.aggregate(value=Avg("value"))
+                result = queryset.aggregate(value=Avg("value"), count=Count("id"))
 
             serializer = serializers.AggregatedMetricSerializer(
                 {
                     "key": key,
                     "service_id": service_id,
                     "aggregation": agg,
                     "value": result["value"] or 0,
-                    "count": queryset.count(),
+                    "count": result["count"],
                 }
             )

Comment on lines +72 to +74
logger.info("Created user: %s", email)
else:
logger.info("Found existing user: %s", email)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid logging email addresses (PII).

Per coding guidelines, logging should not include PII (Personal Identifiable Information). Consider logging a user count or anonymized identifier instead.

🛡️ Proposed fix
     if created:
-        logger.info("Created user: %s", email)
+        logger.info("Created new user for demo operator")
     else:
-        logger.info("Found existing user: %s", email)
+        logger.info("Found existing user for demo operator")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.info("Created user: %s", email)
else:
logger.info("Found existing user: %s", email)
if created:
logger.info("Created new user for demo operator")
else:
logger.info("Found existing user for demo operator")
🤖 Prompt for AI Agents
In `@src/backend/core/management/commands/create_demo_operator.py` around lines 72
- 74, The current logging statements in create_demo_operator.py call logger.info
with the raw email variable, which exposes PII; update the logger calls in the
block that uses logger.info("Created user: %s", email) and logger.info("Found
existing user: %s", email) to avoid logging the full email (variable email).
Replace them with a non-PII alternative such as logging the created user's
internal id (user.id) or a masked/anonymized identifier (e.g., mask_email(email)
or email[:1] + "***" + domain) or simply logging a user count message ("Created
demo user" / "Found existing demo user") so no raw email is emitted.

operator=operator,
role="admin",
)
logger.info("Created user operator role: %s -> %s", user.email, operator.name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid logging email addresses (PII).

Same PII concern as noted above.

🛡️ Proposed fix
-    logger.info("Created user operator role: %s -> %s", user.email, operator.name)
+    logger.info("Created user operator role for operator: %s", operator.name)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.info("Created user operator role: %s -> %s", user.email, operator.name)
logger.info("Created user operator role for operator: %s", operator.name)
🤖 Prompt for AI Agents
In `@src/backend/core/management/commands/create_demo_operator.py` at line 94, The
log statement at logger.info("Created user operator role: %s -> %s", user.email,
operator.name) exposes PII (user.email); update it to avoid logging the email by
replacing user.email with a non-PII identifier such as user.id or user.pk (or an
obfuscated/masked version) in the create_demo_operator.py code path where the
logger.info call lives so the message still records the created mapping without
exposing the user's email.

Comment on lines +267 to +270
) : isMetricsLoading ? (
<div className="dc__metrics__chart-container__placeholder">
Loading...
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use i18n for loading text.

The "Loading..." text should use the translation function for consistency with the rest of the page.

♻️ Proposed fix
         ) : isMetricsLoading ? (
           <div className="dc__metrics__chart-container__placeholder">
-            Loading...
+            {t("common.loading")}
           </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
) : isMetricsLoading ? (
<div className="dc__metrics__chart-container__placeholder">
Loading...
</div>
) : isMetricsLoading ? (
<div className="dc__metrics__chart-container__placeholder">
{t("common.loading")}
</div>
🤖 Prompt for AI Agents
In `@src/frontend/src/pages/operators/`[operator_id]/metrics/index.tsx around
lines 267 - 270, The "Loading..." literal should be replaced with the i18n
translation function: import and call useTranslation in the component (const { t
} = useTranslation()) if not already present, then change the placeholder
content from "Loading..." to {t('common.loading')} (or your project's preferred
key like 'loading'), keeping the surrounding JSX and the isMetricsLoading
conditional intact; ensure the useTranslation import comes from your i18n
library (e.g., react-i18next) and that the translation key exists in your locale
files.

@NathanVss
NathanVss force-pushed the feat/metrics-dashboard branch from ca00df0 to 1d7c7a9 Compare May 12, 2026 15:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/api/viewsets/metrics.py`:
- Around line 137-145: After fetching the Service with
models.Service.objects.get(id=service_id) you must verify it belongs to the
current operator scope (e.g., compare service.operator or service.operator_id
against the request's operator/context operator) and return a 404/403 if it does
not; update the validation in the same block that defines service so that after
retrieving the object you check membership (e.g., service.operator !=
request.operator or service.operator_id not in operator scope) and respond with
an appropriate error response to prevent accepting services outside the operator
perimeter.
- Around line 179-183: The code currently passes potentially malformed IDs from
accounts_param into queryset.filter(account_id__in=account_ids) which can raise
DB errors; update the handling around
self._parse_comma_separated_ids(accounts_param) to validate each ID as a UUID
and return a controlled 400 on invalid input: call _parse_comma_separated_ids
inside a try/except that catches invalid UUID/ValueError (or change
_parse_comma_separated_ids to raise a ValueError on invalid tokens), and on
exception raise rest_framework.exceptions.ParseError (or
rest_framework.exceptions.ValidationError) with a clear message like "Invalid
account id(s)" so malformed IDs never reach queryset.filter; ensure you import
the chosen exception and keep the variables accounts_param, account_ids, and the
queryset.filter(account_id__in=account_ids) usage intact.

In `@src/backend/core/management/commands/create_demo_operator.py`:
- Around line 294-368: The handle method performs multiple DB writes and must be
wrapped in a single atomic transaction to avoid partial demo data on failure;
import transaction from django.db and wrap the main seeding flow (from creating
the operator in create_operator(...) through creating accounts/metrics and
linking organizations via link_organizations(...) and
create_accounts_and_metrics(...)) in a with transaction.atomic(): block so any
exception rolls back all inserts, re-raise exceptions as currently done to
preserve error handling and logging.

In `@src/frontend/package.json`:
- Line 29: The package.json currently lists "recharts": "2.15.3" but is missing
the peer dependency "react-is" required for React 19 compatibility; update
src/frontend/package.json to add "react-is" with a version matching your React
19 line (e.g., "react-is": "^19.0.0") alongside the existing "recharts":
"2.15.3" entry so recharts resolves correctly.

In `@src/frontend/src/hooks/useQueries.tsx`:
- Around line 366-367: The query key currently uses JSON.stringify(params) which
can fragment cache entries; update the queryKey in the useQueries hook to use a
structured key like ["operators", operatorId, "metrics", params] (passing the
params object directly) and keep queryFn as () => getOperatorMetrics(operatorId,
params!) — ensure params is stable/serializable (or normalized) before adding it
to the key so equivalent param objects produce the same cache key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 46440241-ab33-42a0-81e7-087cf686d0cd

📥 Commits

Reviewing files that changed from the base of the PR and between ca00df0 and 1d7c7a9.

⛔ Files ignored due to path filters (1)
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (11)
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/metrics.py
  • src/backend/core/management/commands/create_demo_operator.py
  • src/backend/core/urls.py
  • src/frontend/package.json
  • src/frontend/src/features/api/Repository.ts
  • src/frontend/src/features/i18n/translations.json
  • src/frontend/src/hooks/useQueries.tsx
  • src/frontend/src/pages/operators/[operator_id]/metrics/index.scss
  • src/frontend/src/pages/operators/[operator_id]/metrics/index.tsx
  • src/frontend/src/styles/globals.scss

Comment on lines +137 to +145
# 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,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce that service belongs to the operator scope.

Current validation only checks existence. At Line 139, a service outside the operator perimeter is accepted, which weakens endpoint scoping.

Proposed fix
-        try:
-            service = models.Service.objects.get(id=service_id)
-        except models.Service.DoesNotExist:
+        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,
             )
+        if not models.OperatorServiceConfig.objects.filter(
+            operator_id=operator_id,
+            service_id=service_id,
+        ).exists():
+            return Response(
+                {"error": "Service is not accessible for this operator."},
+                status=status.HTTP_403_FORBIDDEN,
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/viewsets/metrics.py` around lines 137 - 145, After
fetching the Service with models.Service.objects.get(id=service_id) you must
verify it belongs to the current operator scope (e.g., compare service.operator
or service.operator_id against the request's operator/context operator) and
return a 404/403 if it does not; update the validation in the same block that
defines service so that after retrieving the object you check membership (e.g.,
service.operator != request.operator or service.operator_id not in operator
scope) and respond with an appropriate error response to prevent accepting
services outside the operator perimeter.

Comment on lines +179 to +183
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate accounts IDs before applying UUID filter.

At Line 182, malformed IDs can bubble into DB errors instead of returning a controlled 400 response.

Proposed fix
+import uuid
...
         accounts_param = request.query_params.get("accounts")
         if accounts_param:
-            account_ids = self._parse_comma_separated_ids(accounts_param)
+            account_ids = self._parse_comma_separated_ids(accounts_param)
+            try:
+                account_ids = [str(uuid.UUID(v)) for v in account_ids]
+            except (TypeError, ValueError):
+                return Response(
+                    {"error": "Query parameter 'accounts' must contain valid UUIDs."},
+                    status=status.HTTP_400_BAD_REQUEST,
+                )
             queryset = queryset.filter(account_id__in=account_ids)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/viewsets/metrics.py` around lines 179 - 183, The code
currently passes potentially malformed IDs from accounts_param into
queryset.filter(account_id__in=account_ids) which can raise DB errors; update
the handling around self._parse_comma_separated_ids(accounts_param) to validate
each ID as a UUID and return a controlled 400 on invalid input: call
_parse_comma_separated_ids inside a try/except that catches invalid
UUID/ValueError (or change _parse_comma_separated_ids to raise a ValueError on
invalid tokens), and on exception raise rest_framework.exceptions.ParseError (or
rest_framework.exceptions.ValidationError) with a clear message like "Invalid
account id(s)" so malformed IDs never reach queryset.filter; ensure you import
the chosen exception and keep the variables accounts_param, account_ids, and the
queryset.filter(account_id__in=account_ids) usage intact.

Comment on lines +294 to +368
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}"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wrap the end-to-end seeding flow in a DB transaction.

A failure after partial inserts leaves inconsistent demo data (operator/services/roles/metrics partially created). Add an atomic boundary in handle (Line 294 onward).

Proposed fix
+from django.db import transaction
 ...
     def handle(self, *args, **options):
         """Execute the command."""
-        email = options["email"]
-        operator_name = options["operator_name"] or f"Demo Operator {uuid.uuid4().hex[:6]}"
+        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("")
+        with transaction.atomic():
+            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 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}"))
 ...
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}"))
def handle(self, *args, **options):
"""Execute the command."""
from django.db import transaction
email = options["email"]
operator_name = options["operator_name"] or f"Demo Operator {uuid.uuid4().hex[:6]}"
with transaction.atomic():
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}"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/management/commands/create_demo_operator.py` around lines
294 - 368, The handle method performs multiple DB writes and must be wrapped in
a single atomic transaction to avoid partial demo data on failure; import
transaction from django.db and wrap the main seeding flow (from creating the
operator in create_operator(...) through creating accounts/metrics and linking
organizations via link_organizations(...) and create_accounts_and_metrics(...))
in a with transaction.atomic(): block so any exception rolls back all inserts,
re-raise exceptions as currently done to preserve error handling and logging.

Comment thread src/frontend/package.json
"react-hook-form": "7.67.0",
"react-i18next": "15.4.1",
"react-toastify": "11.0.2",
"recharts": "2.15.3",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

What are the official peerDependencies and React version compatibility for recharts 2.15.3, and does it explicitly support React 19.x?

💡 Result:

recharts 2.15.3 official peerDependencies (as published around the 2.15.x “Final 2.x React 19 support” timeframe): - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 [1] - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 [1] - (Additionally required in this period) react-is must be version-matched/overridden for React 19 to work with recharts (this is explicitly called out by the project’s React 19 support notes for the 2.x line). [2][3] Does it explicitly support React 19.x? - Yes. React 19 support is explicitly added to the peerDependencies (React and ReactDOM include “|| ^19.0.0”). [1] - However, the project also explicitly warns that for React 19 to work correctly in the 2.x line, you must override/ensure a matching react-is version (a limitation noted in the 2.x React 19 support release notes and the related support issue). [2][3] Note: The peerDependency range above is evidenced in the recharts v2.15.0 package.json diff that introduced React 19 into peerDeps, and the 2.15.x patch releases (including 2.15.3) are part of that same React-19-supported 2.x line. [1][4]

Citations:


🏁 Script executed:

cat -n src/frontend/package.json | grep -E "(react-is|recharts|react[^a-z-]|react-dom)"

Repository: suitenumerique/st-deploycenter

Length of output: 292


Add react-is dependency to package.json—required by recharts for React 19 compatibility.

While recharts@2.15.3 officially supports React 19, the recharts project explicitly requires a matching react-is version for React 19 to work correctly with the 2.x line (per v2.15.0 release notes and issue #4558). This dependency is currently missing. Add react-is with a version that matches your React version (typically ^19.0.0 for React 19.1.0).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/package.json` at line 29, The package.json currently lists
"recharts": "2.15.3" but is missing the peer dependency "react-is" required for
React 19 compatibility; update src/frontend/package.json to add "react-is" with
a version matching your React 19 line (e.g., "react-is": "^19.0.0") alongside
the existing "recharts": "2.15.3" entry so recharts resolves correctly.

Comment on lines +366 to +367
queryKey: ["operators", operatorId, "metrics", JSON.stringify(params)],
queryFn: () => getOperatorMetrics(operatorId, params!),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use structured query keys instead of JSON.stringify(params).

At Line 366, serializing params can fragment cache entries for equivalent objects with different key order. Prefer passing the params object directly in queryKey.

Proposed fix
 export const useOperatorMetrics = (
   operatorId: string,
   params: MetricsParams | null,
 ) => {
   return useQuery({
-    queryKey: ["operators", operatorId, "metrics", JSON.stringify(params)],
+    queryKey: ["operators", operatorId, "metrics", params],
     queryFn: () => getOperatorMetrics(operatorId, params!),
     enabled: !!operatorId && !!params?.key && !!params?.service,
   });
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
queryKey: ["operators", operatorId, "metrics", JSON.stringify(params)],
queryFn: () => getOperatorMetrics(operatorId, params!),
export const useOperatorMetrics = (
operatorId: string,
params: MetricsParams | null,
) => {
return useQuery({
queryKey: ["operators", operatorId, "metrics", params],
queryFn: () => getOperatorMetrics(operatorId, params!),
enabled: !!operatorId && !!params?.key && !!params?.service,
});
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/hooks/useQueries.tsx` around lines 366 - 367, The query key
currently uses JSON.stringify(params) which can fragment cache entries; update
the queryKey in the useQueries hook to use a structured key like ["operators",
operatorId, "metrics", params] (passing the params object directly) and keep
queryFn as () => getOperatorMetrics(operatorId, params!) — ensure params is
stable/serializable (or normalized) before adding it to the key so equivalent
param objects produce the same cache key.

NathanVss added 4 commits May 12, 2026 17:36
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.
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.
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.
Surface "Adhérents" and "Métriques" entries on every
operator-scoped page so users can move between sections
without going through the breadcrumb.
@NathanVss
NathanVss force-pushed the feat/metrics-dashboard branch from 74ddcaa to a87de80 Compare May 12, 2026 15:38
@NathanVss NathanVss changed the title [WIP] Provide a metric dashboard [POC] Provide a metric dashboard May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant