From fb56c465995c1eeb56db12fc821f78b89f01f037 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:49:46 +0000 Subject: [PATCH 1/5] feat(dashboards): add live activity dashboard widget Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../src/scenes/dashboard/tileLayouts.test.ts | 6 + posthog/settings/web.py | 1 + .../api/test/test_dashboard_widgets.py | 55 +++ .../backend/api/test/test_run_widgets.py | 93 +++++ .../test/test_widget_config_schema_parity.py | 5 +- products/dashboards/backend/constants.py | 4 + .../backend/test/test_widget_layouts.py | 14 +- products/dashboards/backend/widget_catalog.py | 4 +- .../dashboards/backend/widget_registry.py | 2 + .../dashboards/backend/widget_specs/common.py | 2 + .../backend/widget_specs/configs.py | 36 +- .../backend/widget_specs/registry.py | 21 +- .../backend/widgets/live_activity.py | 296 +++++++++++++++ .../widgets/test/test_live_activity_widget.py | 188 +++++++++ .../bin/generate-widget-config-zod.mjs | 16 +- .../WidgetCard/widgetOverviewStoryFixtures.ts | 10 + .../frontend/generated/api.schemas.ts | 79 +++- .../dashboards/frontend/generated/api.zod.ts | 179 ++++++++- .../widget-config-property-keys.json | 12 + .../liveActivityWidgetConfig.zod.ts | 36 ++ .../frontend/generated/widget-configs.zod.ts | 9 + .../generated/widget-form-fields.json | 6 + .../frontend/widget_types/catalog.ts | 17 + .../EditLiveActivityWidgetModal.test.tsx | 84 +++++ .../activity/EditLiveActivityWidgetModal.tsx | 148 ++++++++ .../activity/LiveActivityWidget.stories.tsx | 145 +++++++ .../activity/LiveActivityWidget.test.tsx | 148 ++++++++ .../widgets/activity/LiveActivityWidget.tsx | 356 ++++++++++++++++++ .../editLiveActivityWidgetModalLogic.ts | 201 ++++++++++ .../activity/liveActivitySampleData.ts | 90 +++++ ...liveActivityWidgetConfigValidation.test.ts | 82 ++++ .../liveActivityWidgetConfigValidation.ts | 97 +++++ .../activity/liveActivityWidgetLogic.ts | 101 +++++ .../previews/LiveActivityWidgetPreview.tsx | 22 ++ .../frontend/widgets/registry.test.tsx | 29 +- .../dashboards/frontend/widgets/registry.tsx | 8 + .../widgets/widgetConfigSchemaParity.test.ts | 2 + services/mcp/src/api/generated.ts | 82 +++- services/mcp/src/generated/dashboards/api.ts | 179 ++++++++- .../tool-schemas/dashboard-update.json | 39 +- .../dashboard-widgets-batch-add.json | 113 +++++- 41 files changed, 2960 insertions(+), 57 deletions(-) create mode 100644 products/dashboards/backend/widgets/live_activity.py create mode 100644 products/dashboards/backend/widgets/test/test_live_activity_widget.py create mode 100644 products/dashboards/frontend/generated/widget-config-schemas/liveActivityWidgetConfig.zod.ts create mode 100644 products/dashboards/frontend/widgets/activity/EditLiveActivityWidgetModal.test.tsx create mode 100644 products/dashboards/frontend/widgets/activity/EditLiveActivityWidgetModal.tsx create mode 100644 products/dashboards/frontend/widgets/activity/LiveActivityWidget.stories.tsx create mode 100644 products/dashboards/frontend/widgets/activity/LiveActivityWidget.test.tsx create mode 100644 products/dashboards/frontend/widgets/activity/LiveActivityWidget.tsx create mode 100644 products/dashboards/frontend/widgets/activity/editLiveActivityWidgetModalLogic.ts create mode 100644 products/dashboards/frontend/widgets/activity/liveActivitySampleData.ts create mode 100644 products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.test.ts create mode 100644 products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.ts create mode 100644 products/dashboards/frontend/widgets/activity/liveActivityWidgetLogic.ts create mode 100644 products/dashboards/frontend/widgets/previews/LiveActivityWidgetPreview.tsx diff --git a/frontend/src/scenes/dashboard/tileLayouts.test.ts b/frontend/src/scenes/dashboard/tileLayouts.test.ts index e340fcdb8107..8dee4e25b4d7 100644 --- a/frontend/src/scenes/dashboard/tileLayouts.test.ts +++ b/frontend/src/scenes/dashboard/tileLayouts.test.ts @@ -134,6 +134,12 @@ describe('calculating tile layouts', () => { expectedMinH: 3, expectedMinW: 3, }, + { + name: 'uses catalog minW for live activity widgets', + widgetType: 'live_activity', + expectedMinH: 3, + expectedMinW: 3, + }, ])('$name', ({ widgetType, expectedMinH, expectedMinW }) => { const tiles: DashboardTile[] = [ { diff --git a/posthog/settings/web.py b/posthog/settings/web.py index bfa81d07a4e5..b810e97983cd 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -548,6 +548,7 @@ def static_varies_origin(headers, path, url): # stable component name; value is the singleton list even though length is 1. "ActivityEventsListWidgetTypeEnum": ["activity_events_list"], "ErrorTrackingListWidgetTypeEnum": ["error_tracking_list"], + "LiveActivityWidgetTypeEnum": ["live_activity"], "SessionReplayListWidgetTypeEnum": ["session_replay_list"], "OrderByEnum": ["latest", "earliest"], "PropertyGroupTypeEnum": ["cohort", "person", "group"], diff --git a/products/dashboards/backend/api/test/test_dashboard_widgets.py b/products/dashboards/backend/api/test/test_dashboard_widgets.py index 0a35c7207141..9451d74b9ac4 100644 --- a/products/dashboards/backend/api/test/test_dashboard_widgets.py +++ b/products/dashboards/backend/api/test/test_dashboard_widgets.py @@ -570,9 +570,14 @@ def test_widget_catalog_lists_registered_types(self) -> None: results = response.json()["results"] assert any(entry["widget_type"] == "error_tracking_list" for entry in results) + assert any(entry["widget_type"] == "live_activity" for entry in results) error_tracking_list = next(entry for entry in results if entry["widget_type"] == "error_tracking_list") assert error_tracking_list["config_schema"]["properties"]["limit"]["default"] == DEFAULT_WIDGET_LIST_LIMIT assert error_tracking_list["availability_requirements"] == ["exception_autocapture"] + live_activity = next(entry for entry in results if entry["widget_type"] == "live_activity") + assert live_activity["group_id"] == "activity" + assert live_activity["config_schema"]["properties"]["refreshIntervalSeconds"]["minimum"] == 15 + assert live_activity["config_schema"]["properties"]["refreshIntervalSeconds"]["maximum"] == 60 @override_settings(IN_UNIT_TESTING=True) def test_add_widget_via_batch_endpoint(self) -> None: @@ -594,6 +599,56 @@ def test_add_widget_via_batch_endpoint(self) -> None: assert tile["widget"]["name"] == "Errors" assert tile["layouts"]["sm"]["w"] == 6 + @override_settings(IN_UNIT_TESTING=True) + def test_add_live_activity_widget_via_batch_endpoint_uses_compact_layout(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) + + response = self.client.post( + f"/api/projects/{self.team.id}/dashboards/{dashboard_id}/widgets/batch/", + { + "widgets": [ + { + "widget_type": "live_activity", + "config": {"limit": 5, "refreshIntervalSeconds": 15}, + "name": "Live activity", + }, + ] + }, + ) + assert response.status_code == status.HTTP_201_CREATED + + tile = response.json()["tiles"][0] + assert tile["widget"]["widget_type"] == "live_activity" + assert tile["widget"]["config"] == {"limit": 5, "refreshIntervalSeconds": 15} + assert tile["widget"]["name"] == "Live activity" + assert tile["layouts"]["sm"]["w"] == 4 + assert tile["layouts"]["sm"]["h"] == 4 + + @override_settings(IN_UNIT_TESTING=True) + def test_live_activity_widget_create_and_update_validate_refresh_interval(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) + + response = self.client.post( + f"/api/projects/{self.team.id}/dashboards/{dashboard_id}/widgets/batch/", + {"widgets": [{"widget_type": "live_activity", "config": {"refreshIntervalSeconds": 5}}]}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + _, dashboard_json = self.dashboard_api.create_widget_tile( + dashboard_id, + widget_type="live_activity", + config={"limit": 5, "refreshIntervalSeconds": 15}, + ) + tile = dashboard_json["tiles"][0] + tile["widget"]["config"] = {"limit": 5, "refreshIntervalSeconds": 61} + + response = self.client.patch( + f"/api/projects/{self.team.id}/dashboards/{dashboard_id}", + {"tiles": [tile]}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["attr"] == "config" + @override_settings(IN_UNIT_TESTING=True) def test_widgets_batch_endpoint_rejects_legacy_error_tracking_widget_type(self) -> None: dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) diff --git a/products/dashboards/backend/api/test/test_run_widgets.py b/products/dashboards/backend/api/test/test_run_widgets.py index 26b1b3d61e1d..bc00ab15cf7d 100644 --- a/products/dashboards/backend/api/test/test_run_widgets.py +++ b/products/dashboards/backend/api/test/test_run_widgets.py @@ -1,6 +1,7 @@ import typing from typing import Any, cast +from freezegun import freeze_time from posthog.test.base import APIBaseTest from unittest.mock import MagicMock, patch @@ -28,6 +29,8 @@ ACTIVITY_EVENTS_DEFAULT_LIMIT, ACTIVITY_EVENTS_MAX_LIMIT, DEFAULT_WIDGET_LIST_LIMIT, + LIVE_ACTIVITY_DEFAULT_LIMIT, + LIVE_ACTIVITY_MAX_LIMIT, MAX_WIDGET_RESULT_LIMIT, MAX_WIDGETS_BATCH_SIZE, ) @@ -36,6 +39,7 @@ from products.dashboards.backend.widget_specs.configs import ( ACTIVITY_EVENTS_LIST_WIDGET_TYPE, ERROR_TRACKING_LIST_WIDGET_TYPE, + LIVE_ACTIVITY_WIDGET_TYPE, SESSION_REPLAY_LIST_WIDGET_TYPE, ErrorTrackingListWidgetConfig, SessionReplayOrderBy, @@ -46,6 +50,11 @@ run_activity_events_list_widget, ) from products.dashboards.backend.widgets.error_tracking_list import run_error_tracking_list_widget +from products.dashboards.backend.widgets.live_activity import ( + LIVE_ACTIVITY_BUCKET_COUNT, + LIVE_ACTIVITY_BUCKET_SECONDS, + run_live_activity_widget, +) from products.dashboards.backend.widgets.session_replay_list import run_session_replay_list_widget from products.error_tracking.backend.api.query_utils import ERROR_TRACKING_LISTING_VOLUME_RESOLUTION @@ -103,10 +112,26 @@ def test_validate_activity_events_list_config_rejects_limit_above_max(self) -> N with self.assertRaises(Exception): validate_widget_config(ACTIVITY_EVENTS_LIST_WIDGET_TYPE, {"limit": ACTIVITY_EVENTS_MAX_LIMIT + 1}) + def test_validate_live_activity_config_defaults(self) -> None: + validated = validate_widget_config(LIVE_ACTIVITY_WIDGET_TYPE, {}) + assert validated["limit"] == LIVE_ACTIVITY_DEFAULT_LIMIT + assert validated["refreshIntervalSeconds"] == 15 + assert "filterTestAccounts" not in validated + + def test_validate_live_activity_config_accepts_limit_up_to_max(self) -> None: + validated = validate_widget_config(LIVE_ACTIVITY_WIDGET_TYPE, {"limit": LIVE_ACTIVITY_MAX_LIMIT}) + assert validated["limit"] == LIVE_ACTIVITY_MAX_LIMIT + + @parameterized.expand([0, 14, 61]) + def test_validate_live_activity_config_rejects_unsupported_refresh_interval(self, refresh_interval: int) -> None: + with self.assertRaises(Exception): + validate_widget_config(LIVE_ACTIVITY_WIDGET_TYPE, {"refreshIntervalSeconds": refresh_interval}) + @parameterized.expand( [ ("activity_events", ACTIVITY_EVENTS_LIST_WIDGET_TYPE), ("error_tracking", ERROR_TRACKING_LIST_WIDGET_TYPE), + ("live_activity", LIVE_ACTIVITY_WIDGET_TYPE), ("session_replay", SESSION_REPLAY_LIST_WIDGET_TYPE), ] ) @@ -121,6 +146,7 @@ def test_validate_list_config_rejects_invalid_filter_test_accounts(self, _label: [ ("activity_events", ACTIVITY_EVENTS_LIST_WIDGET_TYPE), ("error_tracking", ERROR_TRACKING_LIST_WIDGET_TYPE), + ("live_activity", LIVE_ACTIVITY_WIDGET_TYPE), ("session_replay", SESSION_REPLAY_LIST_WIDGET_TYPE), ] ) @@ -346,6 +372,73 @@ def test_runs_activity_events_widget_for_requested_tile(self, mock_runner_cls: M self.assertEqual(query.after, "-24h") self.assertEqual(query.limit, 10) + @freeze_time("2026-06-17T12:00:00Z") + @patch("products.dashboards.backend.widgets.live_activity.HogQLQueryRunner") + def test_runs_live_activity_widget_for_requested_tile(self, mock_runner_cls: MagicMock) -> None: + def runner_side_effect(*_args: object, **kwargs: object) -> MagicMock: + query = kwargs["query"] + if "events_in_window" in query.query: + results = [[9, 3, "2026-06-17T11:59:50Z"]] + elif "bucket_index" in query.query: + results = [[19, 9]] + else: + results = [ + [ + "0196e9ab-0000-0000-0000-000000000000", + "$pageview", + "Alex Chen", + "https://app.example.test/dashboard", + "web", + "2026-06-17T11:59:50Z", + "user-1", + ] + ] + return MagicMock( + calculate=MagicMock(return_value=MagicMock(model_dump=lambda mode="json": {"results": results})) + ) + + mock_runner_cls.side_effect = runner_side_effect + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dash"}) + _, dashboard_json = self.dashboard_api.create_widget_tile( + dashboard_id, + widget_type=LIVE_ACTIVITY_WIDGET_TYPE, + config={"limit": 5, "refreshIntervalSeconds": 15}, + ) + tile_id = dashboard_json["tiles"][0]["id"] + + body = self._run(dashboard_id, [tile_id]) + + self.assertEqual(len(body["results"]), 1) + self.assertEqual(body["results"][0]["tile_id"], tile_id) + self.assertEqual(body["results"][0]["widget_type"], LIVE_ACTIVITY_WIDGET_TYPE) + self.assertIsNone(body["results"][0]["error"]) + result = body["results"][0]["result"] + self.assertEqual(result["activeUsers"], 3) + self.assertEqual(result["eventsInWindow"], 9) + self.assertEqual(result["events"][0]["surface"], "web") + self.assertEqual(result["limit"], 5) + self.assertEqual(result["bucketSeconds"], LIVE_ACTIVITY_BUCKET_SECONDS) + self.assertEqual(len(result["pulse"]), LIVE_ACTIVITY_BUCKET_COUNT) + self.assertEqual(mock_runner_cls.call_count, 3) + for call in mock_runner_cls.call_args_list: + self.assertEqual(call.kwargs["user"], self.user) + + @patch("products.dashboards.backend.widgets.live_activity.HogQLQueryRunner") + def test_live_activity_widget_tags_queries_in_debug_mode(self, mock_runner_cls: MagicMock) -> None: + def calculate_with_tag_assertion() -> MagicMock: + tags = get_query_tags() + if tags.product != Product.PRODUCT_ANALYTICS or tags.feature != Feature.QUERY: + raise UntaggedQueryError("live activity widget must tag ClickHouse queries") + return MagicMock(model_dump=lambda mode="json": {"results": []}) + + mock_runner_cls.return_value.calculate.side_effect = calculate_with_tag_assertion + + with override_settings(DEBUG=True, TEST=False): + result = run_live_activity_widget(self.team, {"limit": 5}, user=self.user) + + self.assertEqual(result["limit"], 5) + self.assertEqual(mock_runner_cls.call_count, 3) + @patch("products.dashboards.backend.widgets.activity_events_list.EventsQueryRunner") def test_activity_events_widget_applies_date_range_and_widget_filters(self, mock_runner_cls: MagicMock) -> None: mock_runner_cls.return_value.calculate.return_value = MagicMock( diff --git a/products/dashboards/backend/api/test/test_widget_config_schema_parity.py b/products/dashboards/backend/api/test/test_widget_config_schema_parity.py index b0b21428129d..1279b4d6b663 100644 --- a/products/dashboards/backend/api/test/test_widget_config_schema_parity.py +++ b/products/dashboards/backend/api/test/test_widget_config_schema_parity.py @@ -4,15 +4,18 @@ ACTIVITY_EVENTS_DEFAULT_LIMIT, ACTIVITY_EVENTS_MAX_LIMIT, DEFAULT_WIDGET_LIST_LIMIT, + LIVE_ACTIVITY_DEFAULT_LIMIT, + LIVE_ACTIVITY_MAX_LIMIT, MAX_WIDGET_RESULT_LIMIT, ) from products.dashboards.backend.widget_catalog import WIDGET_CATALOG -from products.dashboards.backend.widget_specs.configs import ACTIVITY_EVENTS_LIST_WIDGET_TYPE +from products.dashboards.backend.widget_specs.configs import ACTIVITY_EVENTS_LIST_WIDGET_TYPE, LIVE_ACTIVITY_WIDGET_TYPE from products.dashboards.backend.widget_specs.registry import WIDGET_SPECS # Activity events allows a larger page than other list widgets; other types share the default bounds. WIDGET_LIMIT_BOUNDS = { ACTIVITY_EVENTS_LIST_WIDGET_TYPE: (ACTIVITY_EVENTS_MAX_LIMIT, ACTIVITY_EVENTS_DEFAULT_LIMIT), + LIVE_ACTIVITY_WIDGET_TYPE: (LIVE_ACTIVITY_MAX_LIMIT, LIVE_ACTIVITY_DEFAULT_LIMIT), } diff --git a/products/dashboards/backend/constants.py b/products/dashboards/backend/constants.py index 8a5b51706475..a29bad812974 100644 --- a/products/dashboards/backend/constants.py +++ b/products/dashboards/backend/constants.py @@ -11,6 +11,10 @@ ACTIVITY_EVENTS_MAX_LIMIT = 50 ACTIVITY_EVENTS_DEFAULT_LIMIT = 25 +# Live activity is intentionally compact: active users + pulse + a short recent-events feed. +LIVE_ACTIVITY_MAX_LIMIT = 10 +LIVE_ACTIVITY_DEFAULT_LIMIT = 5 + # Cap widgets per batch create / run-widgets request. MAX_WIDGETS_BATCH_SIZE = 10 diff --git a/products/dashboards/backend/test/test_widget_layouts.py b/products/dashboards/backend/test/test_widget_layouts.py index 32abd492506a..b2e7c15da364 100644 --- a/products/dashboards/backend/test/test_widget_layouts.py +++ b/products/dashboards/backend/test/test_widget_layouts.py @@ -1,6 +1,7 @@ from parameterized import parameterized from products.dashboards.backend.widget_layouts import stack_widget_layout_at_bottom +from products.dashboards.backend.widget_specs.configs import LIVE_ACTIVITY_WIDGET_TYPE class TestWidgetLayouts: @@ -75,12 +76,14 @@ def test_stack_widget_layout_at_bottom_narrow_tile_anchors_to_tallest_column(sel ] layouts = stack_widget_layout_at_bottom( - widget_type="error_tracking_list", + widget_type=LIVE_ACTIVITY_WIDGET_TYPE, existing_sm_layouts=existing, ) placement = layouts["sm"] assert placement["y"] == 14 + assert placement["w"] == 4 + assert placement["h"] == 4 # column span must include the tallest column (8) assert placement["x"] <= 8 < placement["x"] + placement["w"] @@ -97,3 +100,12 @@ def test_stack_widget_layout_at_bottom_stacks_batch_on_empty_dashboard(self) -> assert first["sm"] == {"x": 0, "y": 0, "w": 6, "h": 5} assert second["sm"] == {"x": 0, "y": 5, "w": 6, "h": 5} + + def test_live_activity_uses_compact_default_layout(self) -> None: + layouts = stack_widget_layout_at_bottom( + widget_type=LIVE_ACTIVITY_WIDGET_TYPE, + existing_sm_layouts=[], + ) + + assert layouts["sm"] == {"x": 0, "y": 0, "w": 4, "h": 4} + assert layouts["xs"] == layouts["sm"] diff --git a/products/dashboards/backend/widget_catalog.py b/products/dashboards/backend/widget_catalog.py index a6e7d07c0c14..27afd536a45d 100644 --- a/products/dashboards/backend/widget_catalog.py +++ b/products/dashboards/backend/widget_catalog.py @@ -51,8 +51,8 @@ def get_widget_product_access_denied_message(required_product_access: str) -> st def get_default_widget_layouts(widget_type: str) -> dict[str, dict[str, int]]: - width = 6 - height = 5 + spec = WIDGET_SPECS.get(widget_type) + width, height = spec.default_layout if spec else (6, 5) return { "sm": {"x": 0, "y": 0, "w": width, "h": height}, "xs": {"x": 0, "y": 0, "w": width, "h": height}, diff --git a/products/dashboards/backend/widget_registry.py b/products/dashboards/backend/widget_registry.py index c1c61689f3ac..dfd90616f115 100644 --- a/products/dashboards/backend/widget_registry.py +++ b/products/dashboards/backend/widget_registry.py @@ -9,6 +9,7 @@ from products.dashboards.backend.widget_specs.configs import ( ACTIVITY_EVENTS_LIST_WIDGET_TYPE, ERROR_TRACKING_LIST_WIDGET_TYPE, + LIVE_ACTIVITY_WIDGET_TYPE, SESSION_REPLAY_LIST_WIDGET_TYPE, ) from products.dashboards.backend.widget_specs.registry import ( @@ -29,6 +30,7 @@ __all__ = [ "ACTIVITY_EVENTS_LIST_WIDGET_TYPE", "ERROR_TRACKING_LIST_WIDGET_TYPE", + "LIVE_ACTIVITY_WIDGET_TYPE", "SESSION_REPLAY_LIST_WIDGET_TYPE", "EXPECTED_WIDGET_TYPES", "DashboardWidgetType", diff --git a/products/dashboards/backend/widget_specs/common.py b/products/dashboards/backend/widget_specs/common.py index ca034e2977ca..d7ec362509aa 100644 --- a/products/dashboards/backend/widget_specs/common.py +++ b/products/dashboards/backend/widget_specs/common.py @@ -8,6 +8,7 @@ from products.dashboards.backend.constants import ( ACTIVITY_EVENTS_MAX_LIMIT, + LIVE_ACTIVITY_MAX_LIMIT, MAX_WIDGET_RESULT_LIMIT, WIDGET_DATE_FROM_VALUES_ORDERED, ) @@ -91,3 +92,4 @@ def validate_date_range(cls, value: object) -> WidgetDateRange | None: WidgetLimit = Annotated[int, Field(ge=1, le=MAX_WIDGET_RESULT_LIMIT)] ActivityWidgetLimit = Annotated[int, Field(ge=1, le=ACTIVITY_EVENTS_MAX_LIMIT)] +LiveActivityWidgetLimit = Annotated[int, Field(ge=1, le=LIVE_ACTIVITY_MAX_LIMIT)] diff --git a/products/dashboards/backend/widget_specs/configs.py b/products/dashboards/backend/widget_specs/configs.py index 30af693651ac..9d51a8227f55 100644 --- a/products/dashboards/backend/widget_specs/configs.py +++ b/products/dashboards/backend/widget_specs/configs.py @@ -4,9 +4,14 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -from products.dashboards.backend.constants import ACTIVITY_EVENTS_DEFAULT_LIMIT, DEFAULT_WIDGET_LIST_LIMIT +from products.dashboards.backend.constants import ( + ACTIVITY_EVENTS_DEFAULT_LIMIT, + DEFAULT_WIDGET_LIST_LIMIT, + LIVE_ACTIVITY_DEFAULT_LIMIT, +) from products.dashboards.backend.widget_specs.common import ( ActivityWidgetLimit, + LiveActivityWidgetLimit, WidgetLimit, WidgetListConfigBase, WidgetOrderDirection, @@ -14,6 +19,7 @@ ACTIVITY_EVENTS_LIST_WIDGET_TYPE = "activity_events_list" ERROR_TRACKING_LIST_WIDGET_TYPE = "error_tracking_list" +LIVE_ACTIVITY_WIDGET_TYPE = "live_activity" SESSION_REPLAY_LIST_WIDGET_TYPE = "session_replay_list" ErrorTrackingOrderBy = Literal["last_seen", "first_seen", "occurrences", "users", "sessions"] @@ -68,3 +74,31 @@ class ActivityEventsListWidgetConfig(WidgetListConfigBase): limit: ActivityWidgetLimit = Field( default=ACTIVITY_EVENTS_DEFAULT_LIMIT, description="Maximum number of events to return." ) + + +class LiveActivityWidgetConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + limit: LiveActivityWidgetLimit = Field( + default=LIVE_ACTIVITY_DEFAULT_LIMIT, + description="Maximum number of recent events to return.", + ) + filterTestAccounts: bool | None = Field( + default=None, + description="Whether to exclude test accounts. When omitted, the project default is used.", + ) + refreshIntervalSeconds: int = Field( + default=15, + ge=15, + le=60, + description="Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.", + ) + + @field_validator("filterTestAccounts", mode="before") + @classmethod + def validate_filter_test_accounts(cls, value: object) -> bool | None: + if value is None: + return None + if not isinstance(value, bool): + raise ValueError("filterTestAccounts must be a boolean.") + return value diff --git a/products/dashboards/backend/widget_specs/registry.py b/products/dashboards/backend/widget_specs/registry.py index 857e7c0c6c68..313ffb363738 100644 --- a/products/dashboards/backend/widget_specs/registry.py +++ b/products/dashboards/backend/widget_specs/registry.py @@ -10,13 +10,15 @@ from products.dashboards.backend.widget_specs.configs import ( ACTIVITY_EVENTS_LIST_WIDGET_TYPE, ERROR_TRACKING_LIST_WIDGET_TYPE, + LIVE_ACTIVITY_WIDGET_TYPE, SESSION_REPLAY_LIST_WIDGET_TYPE, ActivityEventsListWidgetConfig, ErrorTrackingListWidgetConfig, + LiveActivityWidgetConfig, SessionReplayListWidgetConfig, ) -DashboardWidgetType = Literal["activity_events_list", "error_tracking_list", "session_replay_list"] +DashboardWidgetType = Literal["activity_events_list", "error_tracking_list", "live_activity", "session_replay_list"] __all__ = [ "DashboardWidgetType", @@ -44,6 +46,7 @@ class WidgetSpec: product_access_denied_message: str | None availability_requirements: tuple[str, ...] form_fields: tuple[str, ...] + default_layout: tuple[int, int] = (6, 5) def validate_widget_config(widget_type: str, config: dict[str, Any]) -> dict[str, Any]: @@ -65,6 +68,7 @@ def _load_widget_specs() -> dict[str, WidgetSpec]: run_activity_events_list_widget, ) from products.dashboards.backend.widgets.error_tracking_list import run_error_tracking_list_widget # noqa: PLC0415 + from products.dashboards.backend.widgets.live_activity import run_live_activity_widget # noqa: PLC0415 from products.dashboards.backend.widgets.session_replay_list import run_session_replay_list_widget # noqa: PLC0415 return { @@ -96,6 +100,21 @@ def _load_widget_specs() -> dict[str, WidgetSpec]: availability_requirements=("exception_autocapture",), form_fields=("limit", "orderBy", "orderDirection", "dateRange", "filterTestAccounts", "status"), ), + LIVE_ACTIVITY_WIDGET_TYPE: WidgetSpec( + widget_type=LIVE_ACTIVITY_WIDGET_TYPE, + config_model=LiveActivityWidgetConfig, + query_fn=run_live_activity_widget, + required_scopes=("query:read",), + group_id="activity", + group_label="Activity", + label="Live activity", + description="Active users, activity pulse, and recent events for the last 5 minutes.", + required_product_access=None, + product_access_denied_message=None, + availability_requirements=(), + form_fields=("limit", "refreshIntervalSeconds", "filterTestAccounts"), + default_layout=(4, 4), + ), SESSION_REPLAY_LIST_WIDGET_TYPE: WidgetSpec( widget_type=SESSION_REPLAY_LIST_WIDGET_TYPE, config_model=SessionReplayListWidgetConfig, diff --git a/products/dashboards/backend/widgets/live_activity.py b/products/dashboards/backend/widgets/live_activity.py new file mode 100644 index 000000000000..87329b72ba87 --- /dev/null +++ b/products/dashboards/backend/widgets/live_activity.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from posthog.schema import DateRange, HogQLFilters, HogQLQuery + +from posthog.clickhouse.query_tagging import Feature, Product, tags_context +from posthog.hogql_queries.hogql_query_runner import HogQLQueryRunner +from posthog.models.team import Team +from posthog.models.user import User + +from products.dashboards.backend.widget_specs.configs import LIVE_ACTIVITY_WIDGET_TYPE +from products.dashboards.backend.widgets.config import resolve_filter_test_accounts + +ValidatedLiveActivityWidgetConfig = dict[str, Any] + +LIVE_ACTIVITY_WINDOW_SECONDS = 5 * 60 +LIVE_ACTIVITY_BUCKET_SECONDS = 15 +LIVE_ACTIVITY_BUCKET_COUNT = LIVE_ACTIVITY_WINDOW_SECONDS // LIVE_ACTIVITY_BUCKET_SECONDS +LIVE_ACTIVITY_DELAYED_AFTER_SECONDS = 60 + +LIVE_ACTIVITY_SUMMARY_QUERY = """ +SELECT + count() AS events_in_window, + count(DISTINCT distinct_id) AS active_users, + max(timestamp) AS latest_event_timestamp +FROM events +WHERE {filters} +""" + +LIVE_ACTIVITY_PULSE_QUERY = """ +SELECT + intDiv(toUnixTimestamp(timestamp) - %(window_start_timestamp)d, %(bucket_seconds)d) AS bucket_index, + count() AS events +FROM events +WHERE {filters} +GROUP BY bucket_index +HAVING bucket_index >= 0 AND bucket_index < %(bucket_count)d +ORDER BY bucket_index ASC +""" + +LIVE_ACTIVITY_FEED_QUERY = """ +SELECT + uuid, + event, + distinct_id AS person_display_name, + coalesce(properties.$current_url, properties.$screen_name, properties.$pathname) AS target, + properties.$lib, + timestamp, + distinct_id +FROM events +WHERE {filters} +ORDER BY timestamp DESC +LIMIT %(limit)d +""" + + +def _query_values(generated_at: datetime, config: ValidatedLiveActivityWidgetConfig) -> dict[str, Any]: + window_start = generated_at - timedelta(seconds=LIVE_ACTIVITY_WINDOW_SECONDS) + return { + "window_start_timestamp": int(window_start.timestamp()), + "bucket_seconds": LIVE_ACTIVITY_BUCKET_SECONDS, + "bucket_count": LIVE_ACTIVITY_BUCKET_COUNT, + "limit": config["limit"], + } + + +def _query_filters(team: Team, config: ValidatedLiveActivityWidgetConfig, generated_at: datetime) -> HogQLFilters: + window_start = generated_at - timedelta(seconds=LIVE_ACTIVITY_WINDOW_SECONDS) + return HogQLFilters( + dateRange=DateRange(date_from=window_start.isoformat(), date_to=generated_at.isoformat()), + filterTestAccounts=resolve_filter_test_accounts(config, team), + ) + + +def _run_hogql_query( + team: Team, + user: User | None, + query: str, + *, + filters: HogQLFilters, +) -> list[list[Any]]: + response = HogQLQueryRunner( + team=team, + query=HogQLQuery(query=query, filters=filters), + user=user, + ).calculate() + raw_results = response.model_dump(mode="json").get("results") + return raw_results if isinstance(raw_results, list) else [] + + +def _format_live_activity_query(query: str, values: dict[str, Any]) -> str: + return query % values + + +def _surface_for_event(event_name: str, lib: str | None, target: str | None) -> str: + normalized_lib = (lib or "").lower() + if event_name == "$screen" or any( + token in normalized_lib for token in ("android", "ios", "react-native", "mobile") + ): + return "mobile" + if ( + event_name == "$pageview" + or normalized_lib in {"web", "js", "javascript"} + or "web" in normalized_lib + or bool(target and (target.startswith("http://") or target.startswith("https://") or target.startswith("/"))) + ): + return "web" + return "backend" + + +def _serialize_timestamp(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=UTC).isoformat() + return value.isoformat() + if isinstance(value, str): + if value.endswith("Z") or "+" in value[10:] or "-" in value[10:]: + return value + if "T" in value: + return f"{value}+00:00" + return value + return str(value) + + +def _parse_serialized_timestamp(value: str | None) -> datetime | None: + if value is None: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _feed_row_to_event(row: list[Any]) -> dict[str, Any]: + uuid, event_name, display_name, target, lib, timestamp, distinct_id = (row + [None] * 7)[:7] + event_name_str = str(event_name or "") + lib_str = str(lib) if lib is not None else None + target_str = str(target) if target is not None else None + distinct_id_str = str(distinct_id) if distinct_id is not None else None + display_name_str = str(display_name) if display_name is not None else None + + return { + "uuid": str(uuid), + "event": event_name_str, + "person": ( + { + "display_name": display_name_str or distinct_id_str, + "distinct_id": distinct_id_str, + } + if display_name_str or distinct_id_str + else None + ), + "target": target_str, + "lib": lib_str, + "surface": _surface_for_event(event_name_str, lib_str, target_str), + "timestamp": _serialize_timestamp(timestamp), + } + + +def _build_pulse(rows: list[list[Any]], window_start: datetime) -> list[dict[str, Any]]: + counts_by_bucket: dict[int, int] = {} + for row in rows: + if len(row) < 2: + continue + try: + bucket_index = int(row[0]) + count = int(row[1]) + except (TypeError, ValueError): + continue + if 0 <= bucket_index < LIVE_ACTIVITY_BUCKET_COUNT: + counts_by_bucket[bucket_index] = count + + return [ + { + "bucketStart": (window_start + timedelta(seconds=index * LIVE_ACTIVITY_BUCKET_SECONDS)).isoformat(), + "count": counts_by_bucket.get(index, 0), + } + for index in range(LIVE_ACTIVITY_BUCKET_COUNT) + ] + + +def _apply_feed_lower_bounds_to_pulse( + pulse: list[dict[str, Any]], events: list[dict[str, Any]], window_start: datetime +) -> None: + counts_by_bucket: dict[int, int] = {} + window_end = window_start + timedelta(seconds=LIVE_ACTIVITY_WINDOW_SECONDS) + for event in events: + timestamp = _parse_serialized_timestamp(event.get("timestamp")) + if timestamp is None or timestamp < window_start or timestamp >= window_end: + continue + bucket_index = int((timestamp - window_start).total_seconds() // LIVE_ACTIVITY_BUCKET_SECONDS) + counts_by_bucket[bucket_index] = counts_by_bucket.get(bucket_index, 0) + 1 + + for bucket_index, count in counts_by_bucket.items(): + if 0 <= bucket_index < len(pulse): + pulse[bucket_index]["count"] = max(int(pulse[bucket_index]["count"]), count) + + +def _active_users_lower_bound(events: list[dict[str, Any]]) -> int: + distinct_ids: set[str] = set() + for event in events: + person = event.get("person") + if not isinstance(person, dict): + continue + distinct_id = person.get("distinct_id") + if distinct_id: + distinct_ids.add(str(distinct_id)) + return len(distinct_ids) + + +def _peak_events_per_minute(pulse: list[dict[str, Any]]) -> int: + buckets_per_minute = 60 // LIVE_ACTIVITY_BUCKET_SECONDS + counts = [int(bucket["count"]) for bucket in pulse] + return max( + ( + sum(counts[index : index + buckets_per_minute]) + for index in range(0, max(len(counts) - buckets_per_minute + 1, 1)) + ), + default=0, + ) + + +def _summary_from_rows(rows: list[list[Any]]) -> tuple[int, int, str | None]: + if not rows: + return 0, 0, None + row = rows[0] + events_in_window = int(row[0] or 0) if len(row) > 0 else 0 + active_users = int(row[1] or 0) if len(row) > 1 else 0 + if events_in_window == 0: + return 0, 0, None + latest_event_timestamp = _serialize_timestamp(row[2]) if len(row) > 2 else None + return events_in_window, active_users, latest_event_timestamp + + +def run_live_activity_widget( + team: Team, + config: dict[str, Any], + user: User | None = None, + *, + include_total_count: bool = False, +) -> dict[str, Any]: + # Registry imports this runner while building WIDGET_SPECS. + from products.dashboards.backend.widget_specs.registry import validate_widget_config # noqa: PLC0415 + + typed_config = validate_widget_config(LIVE_ACTIVITY_WIDGET_TYPE, config) + generated_at = datetime.now(UTC).replace(microsecond=0) + window_start = generated_at - timedelta(seconds=LIVE_ACTIVITY_WINDOW_SECONDS) + values = _query_values(generated_at, typed_config) + filters = _query_filters(team, typed_config, generated_at) + + with tags_context(product=Product.PRODUCT_ANALYTICS, feature=Feature.QUERY, team_id=team.pk): + summary_rows = _run_hogql_query(team, user, LIVE_ACTIVITY_SUMMARY_QUERY, filters=filters) + pulse_rows = _run_hogql_query( + team, + user, + _format_live_activity_query(LIVE_ACTIVITY_PULSE_QUERY, values), + filters=filters, + ) + feed_rows = _run_hogql_query( + team, + user, + _format_live_activity_query(LIVE_ACTIVITY_FEED_QUERY, values), + filters=filters, + ) + + events_in_window, active_users, latest_event_timestamp = _summary_from_rows(summary_rows) + events = [_feed_row_to_event(row) for row in feed_rows] + events_in_window = max(events_in_window, len(events)) + active_users = max(active_users, _active_users_lower_bound(events)) + if latest_event_timestamp is None and events: + latest_event_timestamp = events[0]["timestamp"] + pulse = _build_pulse(pulse_rows, window_start) + _apply_feed_lower_bounds_to_pulse(pulse, events, window_start) + + return { + "activeUsers": active_users, + "eventsInWindow": events_in_window, + "peakEventsPerMinute": _peak_events_per_minute(pulse), + "pulse": pulse, + "events": events, + "limit": typed_config["limit"], + "rollingWindowSeconds": LIVE_ACTIVITY_WINDOW_SECONDS, + "bucketSeconds": LIVE_ACTIVITY_BUCKET_SECONDS, + "refreshIntervalSeconds": typed_config["refreshIntervalSeconds"], + "generatedAt": generated_at.isoformat(), + "windowStart": window_start.isoformat(), + "latestEventTimestamp": latest_event_timestamp, + "delayedAfterSeconds": LIVE_ACTIVITY_DELAYED_AFTER_SECONDS, + } diff --git a/products/dashboards/backend/widgets/test/test_live_activity_widget.py b/products/dashboards/backend/widgets/test/test_live_activity_widget.py new file mode 100644 index 000000000000..2fd21e2b4159 --- /dev/null +++ b/products/dashboards/backend/widgets/test/test_live_activity_widget.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from freezegun import freeze_time +from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event +from unittest.mock import MagicMock, patch + +from products.dashboards.backend.widgets.live_activity import ( + LIVE_ACTIVITY_BUCKET_COUNT, + LIVE_ACTIVITY_BUCKET_SECONDS, + LIVE_ACTIVITY_DELAYED_AFTER_SECONDS, + LIVE_ACTIVITY_WINDOW_SECONDS, + _feed_row_to_event, + _summary_from_rows, + _surface_for_event, + run_live_activity_widget, +) + + +class TestLiveActivityWidget(ClickhouseTestMixin, APIBaseTest): + def _create_live_event( + self, + event: str, + distinct_id: str, + timestamp: datetime, + properties: dict[str, Any] | None = None, + ) -> str: + return _create_event( + team=self.team, + event=event, + distinct_id=distinct_id, + timestamp=timestamp, + properties=properties or {}, + ) + + @freeze_time("2026-06-17T12:00:00Z") + def test_returns_active_users_pulse_and_bounded_feed_for_recent_events(self) -> None: + generated_at = datetime(2026, 6, 17, 12, 0, tzinfo=UTC) + web_uuid = self._create_live_event( + "$pageview", + "user-a", + generated_at - timedelta(seconds=10), + {"$current_url": "https://example.test/pricing", "$lib": "web"}, + ) + mobile_uuid = self._create_live_event( + "$screen", + "user-b", + generated_at - timedelta(seconds=20), + {"$screen_name": "Checkout", "$lib": "posthog-ios"}, + ) + self._create_live_event( + "Job completed", + "user-a", + generated_at - timedelta(seconds=35), + {"$lib": "python"}, + ) + self._create_live_event( + "$pageview", + "outside-window", + generated_at - timedelta(seconds=LIVE_ACTIVITY_WINDOW_SECONDS + 1), + {"$current_url": "https://example.test/old", "$lib": "web"}, + ) + + result = run_live_activity_widget(self.team, {"limit": 2, "refreshIntervalSeconds": 15}, self.user) + + assert result["activeUsers"] == 2 + assert result["eventsInWindow"] == 3 + assert result["limit"] == 2 + assert result["rollingWindowSeconds"] == LIVE_ACTIVITY_WINDOW_SECONDS + assert result["bucketSeconds"] == LIVE_ACTIVITY_BUCKET_SECONDS + assert result["delayedAfterSeconds"] == LIVE_ACTIVITY_DELAYED_AFTER_SECONDS + assert result["generatedAt"] == "2026-06-17T12:00:00+00:00" + assert result["windowStart"] == "2026-06-17T11:55:00+00:00" + assert len(result["pulse"]) == LIVE_ACTIVITY_BUCKET_COUNT + assert sum(bucket["count"] for bucket in result["pulse"]) == 3 + assert result["peakEventsPerMinute"] == 3 + + assert [event["uuid"] for event in result["events"]] == [web_uuid, mobile_uuid] + assert result["events"][0]["surface"] == "web" + assert result["events"][0]["target"] == "https://example.test/pricing" + assert result["events"][1]["surface"] == "mobile" + assert result["events"][1]["target"] == "Checkout" + assert result["latestEventTimestamp"] == "2026-06-17T11:59:50Z" + + @freeze_time("2026-06-17T12:00:00Z") + def test_filter_test_accounts_follows_team_default_and_config_override(self) -> None: + generated_at = datetime(2026, 6, 17, 12, 0, tzinfo=UTC) + self.team.test_account_filters = [{"key": "$lib", "type": "event", "value": "web", "operator": "is_not"}] + self.team.test_account_filters_default_checked = True + self.team.save(update_fields=["test_account_filters", "test_account_filters_default_checked"]) + + self._create_live_event( + "$pageview", + "test-account", + generated_at - timedelta(seconds=10), + {"$current_url": "https://example.test/internal", "$lib": "web"}, + ) + self._create_live_event( + "Webhook processed", + "real-account", + generated_at - timedelta(seconds=20), + {"$lib": "python"}, + ) + + default_filtered = run_live_activity_widget(self.team, {"limit": 5}, self.user) + unfiltered = run_live_activity_widget(self.team, {"limit": 5, "filterTestAccounts": False}, self.user) + + assert default_filtered["eventsInWindow"] == 1 + assert default_filtered["activeUsers"] == 1 + assert [event["event"] for event in default_filtered["events"]] == ["Webhook processed"] + assert unfiltered["eventsInWindow"] == 2 + assert unfiltered["activeUsers"] == 2 + + def test_surface_detection_covers_web_mobile_and_backend_events(self) -> None: + assert _surface_for_event("$pageview", "web", "https://example.test") == "web" + assert _surface_for_event("Screen viewed", "posthog-react-native", "Checkout") == "mobile" + assert _surface_for_event("Webhook processed", "python", None) == "backend" + + def test_feed_row_serializes_person_and_surface_without_per_row_lookup(self) -> None: + event = _feed_row_to_event( + [ + "event-uuid", + "$screen", + None, + "Checkout", + "posthog-android", + datetime(2026, 6, 17, 11, 59, 40, tzinfo=UTC), + "user-b", + ] + ) + + assert event == { + "uuid": "event-uuid", + "event": "$screen", + "person": {"display_name": "user-b", "distinct_id": "user-b"}, + "target": "Checkout", + "lib": "posthog-android", + "surface": "mobile", + "timestamp": "2026-06-17T11:59:40+00:00", + } + + def test_summary_omits_latest_event_timestamp_when_window_is_empty(self) -> None: + assert _summary_from_rows([[0, 0, datetime(1970, 1, 1, tzinfo=UTC)]]) == (0, 0, None) + + @freeze_time("2026-06-17T12:00:00Z") + @patch("products.dashboards.backend.widgets.live_activity.HogQLQueryRunner") + def test_feed_rows_provide_lower_bounds_when_aggregate_queries_lag(self, mock_runner_cls: MagicMock) -> None: + def runner_side_effect(*_args: object, **kwargs: object) -> MagicMock: + query = kwargs["query"] + if "events_in_window" in query.query: + results = [[0, 0, datetime(1970, 1, 1, tzinfo=UTC)]] + elif "bucket_index" in query.query: + results = [] + else: + results = [ + [ + "event-1", + "$pageview", + "user-a", + "https://example.test", + "web", + datetime(2026, 6, 17, 11, 59, 50, tzinfo=UTC), + "user-a", + ], + [ + "event-2", + "Job completed", + "user-b", + None, + "python", + datetime(2026, 6, 17, 11, 59, 40, tzinfo=UTC), + "user-b", + ], + ] + return MagicMock( + calculate=MagicMock(return_value=MagicMock(model_dump=lambda mode="json": {"results": results})) + ) + + mock_runner_cls.side_effect = runner_side_effect + + result = run_live_activity_widget(self.team, {"limit": 5, "refreshIntervalSeconds": 15}, self.user) + + assert result["eventsInWindow"] == 2 + assert result["activeUsers"] == 2 + assert result["latestEventTimestamp"] == "2026-06-17T11:59:50+00:00" + assert sum(bucket["count"] for bucket in result["pulse"]) == 2 diff --git a/products/dashboards/frontend/bin/generate-widget-config-zod.mjs b/products/dashboards/frontend/bin/generate-widget-config-zod.mjs index 342e1af99888..55501197758d 100644 --- a/products/dashboards/frontend/bin/generate-widget-config-zod.mjs +++ b/products/dashboards/frontend/bin/generate-widget-config-zod.mjs @@ -148,18 +148,30 @@ function buildBarrelImports(copiedSchemas, configModelNames) { const aliasLines = [] const exportedComponentNames = new Set([...configModelNames, WIDGET_FILTER_ENTRY_MODEL]) + const configImportAliases = new Map() for (const { componentName, fileName } of copiedSchemas) { if (!exportedComponentNames.has(componentName)) { continue } const importPath = `./${SCHEMAS_SUBDIR_NAME}/${fileName.replace(/\.ts$/, '')}` - importLines.push(`import { ${componentName} } from '${importPath}'`) + const friendlyTypeName = configModelNames.includes(componentName) + ? configModelNameToFriendlyNames(componentName).type + : null + const importName = friendlyTypeName === componentName ? `${componentName}SchemaSource` : componentName + configImportAliases.set(componentName, importName) + importLines.push( + importName === componentName + ? `import { ${componentName} } from '${importPath}'` + : `import { ${componentName} as ${importName} } from '${importPath}'` + ) } for (const configModelName of configModelNames) { const { schema, orvalExport } = configModelNameToFriendlyNames(configModelName) - aliasLines.push(`export const ${schema} = /* @__PURE__ */ ${orvalExport}`) + aliasLines.push( + `export const ${schema} = /* @__PURE__ */ ${configImportAliases.get(orvalExport) ?? orvalExport}` + ) } const filterEntryImport = copiedSchemas.find((entry) => entry.componentName === WIDGET_FILTER_ENTRY_MODEL) diff --git a/products/dashboards/frontend/components/WidgetCard/widgetOverviewStoryFixtures.ts b/products/dashboards/frontend/components/WidgetCard/widgetOverviewStoryFixtures.ts index c47086a6248b..d0318021c5db 100644 --- a/products/dashboards/frontend/components/WidgetCard/widgetOverviewStoryFixtures.ts +++ b/products/dashboards/frontend/components/WidgetCard/widgetOverviewStoryFixtures.ts @@ -1,6 +1,7 @@ import type { DashboardWidgetCatalogKey } from '../../widget_types/catalog' import { getDashboardWidgetCatalogEntry } from '../../widget_types/catalog' import { activityEventsSampleEvents } from '../../widgets/activity/activityEventsSampleData' +import { liveActivitySampleResult } from '../../widgets/activity/liveActivitySampleData' export type WidgetOverviewDemoState = { title?: string @@ -201,6 +202,15 @@ export function getWidgetOverviewDemoState(catalogKey: DashboardWidgetCatalogKey totalCountCapped: true, }, } + case 'live_activity': + return { + title: defaultTitle, + description: catalogEntry.description, + showDescription: true, + config: { ...defaultConfig }, + loading: false, + result: liveActivitySampleResult, + } default: { const exhaustiveCheck: never = catalogKey return exhaustiveCheck diff --git a/products/dashboards/frontend/generated/api.schemas.ts b/products/dashboards/frontend/generated/api.schemas.ts index 39f07be15af3..aed9f225cc26 100644 --- a/products/dashboards/frontend/generated/api.schemas.ts +++ b/products/dashboards/frontend/generated/api.schemas.ts @@ -367,6 +367,7 @@ export interface DashboardCollaboratorApi { /** * * `activity_events_list` - activity_events_list * * `error_tracking_list` - error_tracking_list + * * `live_activity` - live_activity * * `session_replay_list` - session_replay_list */ export type DashboardPatchWidgetOpenApiWidgetTypeEnumApi = @@ -375,6 +376,7 @@ export type DashboardPatchWidgetOpenApiWidgetTypeEnumApi = export const DashboardPatchWidgetOpenApiWidgetTypeEnumApi = { ActivityEventsList: 'activity_events_list', ErrorTrackingList: 'error_tracking_list', + LiveActivity: 'live_activity', SessionReplayList: 'session_replay_list', } as const @@ -534,6 +536,23 @@ export interface ErrorTrackingListWidgetConfigApi { assignee?: WidgetAssigneeFilterApi | null } +export interface LiveActivityWidgetConfigApi { + /** + * Maximum number of recent events to return. + * @minimum 1 + * @maximum 10 + */ + limit?: number + /** Whether to exclude test accounts. When omitted, the project default is used. */ + filterTestAccounts?: boolean | null + /** + * Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden. + * @minimum 15 + * @maximum 60 + */ + refreshIntervalSeconds?: number +} + /** * Recording ranking column. */ @@ -583,6 +602,7 @@ export interface SessionReplayListWidgetConfigApi { export type DashboardWidgetConfigApi = | ActivityEventsListWidgetConfigApi | ErrorTrackingListWidgetConfigApi + | LiveActivityWidgetConfigApi | SessionReplayListWidgetConfigApi export interface DashboardPatchWidgetOpenApiApi { @@ -592,6 +612,7 @@ export interface DashboardPatchWidgetOpenApiApi { * * * `activity_events_list` - activity_events_list * * `error_tracking_list` - error_tracking_list + * * `live_activity` - live_activity * * `session_replay_list` - session_replay_list */ widget_type?: DashboardPatchWidgetOpenApiWidgetTypeEnumApi /** Widget-specific configuration. Shape depends on the tile's widget_type. */ @@ -7976,6 +7997,31 @@ export interface ErrorTrackingListWidgetAddRequestOpenApiApi { config: ErrorTrackingListWidgetConfigApi } +export type LiveActivityWidgetAddRequestOpenApiApiWidgetType = + (typeof LiveActivityWidgetAddRequestOpenApiApiWidgetType)[keyof typeof LiveActivityWidgetAddRequestOpenApiApiWidgetType] + +export const LiveActivityWidgetAddRequestOpenApiApiWidgetType = { + LiveActivity: 'live_activity', +} as const + +export interface LiveActivityWidgetAddRequestOpenApiApi { + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null + /** Optional markdown description shown when show_description is enabled. */ + description?: string + /** Optional react-grid-layout positions keyed by breakpoint (sm, xs). */ + layouts?: _WidgetTileLayoutsOpenApiApi + /** Whether to show the description on the dashboard tile. */ + show_description?: boolean + widget_type: LiveActivityWidgetAddRequestOpenApiApiWidgetType + /** Configuration for the live activity widget. */ + config: LiveActivityWidgetConfigApi +} + export type SessionReplayListWidgetAddRequestOpenApiApiWidgetType = (typeof SessionReplayListWidgetAddRequestOpenApiApiWidgetType)[keyof typeof SessionReplayListWidgetAddRequestOpenApiApiWidgetType] @@ -8004,6 +8050,7 @@ export interface SessionReplayListWidgetAddRequestOpenApiApi { export type AddDashboardWidgetRequestApi = | ActivityEventsListWidgetAddRequestOpenApiApi | ErrorTrackingListWidgetAddRequestOpenApiApi + | LiveActivityWidgetAddRequestOpenApiApi | SessionReplayListWidgetAddRequestOpenApiApi /** @@ -8011,7 +8058,7 @@ export type AddDashboardWidgetRequestApi = */ export interface AddDashboardWidgetsBatchRequestOpenApiApi { /** - * Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request). + * Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, live_activity, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request). * @minItems 1 * @maxItems 10 */ @@ -8105,6 +8152,25 @@ export interface ErrorTrackingListWidgetCatalogEntryOpenApiApi { required_product_access?: string | null } +export type LiveActivityWidgetCatalogEntryOpenApiApiWidgetType = + (typeof LiveActivityWidgetCatalogEntryOpenApiApiWidgetType)[keyof typeof LiveActivityWidgetCatalogEntryOpenApiApiWidgetType] + +export const LiveActivityWidgetCatalogEntryOpenApiApiWidgetType = { + LiveActivity: 'live_activity', +} as const + +export interface LiveActivityWidgetCatalogEntryOpenApiApi { + widget_type: LiveActivityWidgetCatalogEntryOpenApiApiWidgetType + group_id: string + group_label: string + label: string + description: string + /** OpenAPI config shape for this widget type (documentation; matches batch-add/PATCH schemas). */ + readonly config_schema: LiveActivityWidgetConfigApi + /** @nullable */ + required_product_access?: string | null +} + export type SessionReplayListWidgetCatalogEntryOpenApiApiWidgetType = (typeof SessionReplayListWidgetCatalogEntryOpenApiApiWidgetType)[keyof typeof SessionReplayListWidgetCatalogEntryOpenApiApiWidgetType] @@ -8127,6 +8193,7 @@ export interface SessionReplayListWidgetCatalogEntryOpenApiApi { export type WidgetCatalogEntryApi = | ActivityEventsListWidgetCatalogEntryOpenApiApi | ErrorTrackingListWidgetCatalogEntryOpenApiApi + | LiveActivityWidgetCatalogEntryOpenApiApi | SessionReplayListWidgetCatalogEntryOpenApiApi export interface WidgetCatalogResponseApi { @@ -8185,6 +8252,16 @@ export const ErrorTrackingListWidgetTypeEnumApi = { ErrorTrackingList: 'error_tracking_list', } as const +/** + * * `live_activity` - live_activity + */ +export type LiveActivityWidgetTypeEnumApi = + (typeof LiveActivityWidgetTypeEnumApi)[keyof typeof LiveActivityWidgetTypeEnumApi] + +export const LiveActivityWidgetTypeEnumApi = { + LiveActivity: 'live_activity', +} as const + /** * * `session_replay_list` - session_replay_list */ diff --git a/products/dashboards/frontend/generated/api.zod.ts b/products/dashboards/frontend/generated/api.zod.ts index b1804266e1d6..622fb69da330 100644 --- a/products/dashboards/frontend/generated/api.zod.ts +++ b/products/dashboards/frontend/generated/api.zod.ts @@ -219,12 +219,18 @@ export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitMax = export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderByDefault = `occurrences` export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoStatusDefault = `active` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault = 5 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax = 25 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsDefault = 15 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMin = 15 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMax = 60 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeOrderByDefault = `start_time` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeOrderDirectionDefault = `DESC` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitDefault = 10 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitMax = 25 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderByDefault = `start_time` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneNameMax = 400 export const dashboardsPartialUpdateBodyDeleteInsightsDefault = false @@ -253,13 +259,18 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .optional() .describe('Existing widget row ID when updating a widget tile via dashboard PATCH.'), widget_type: zod - .enum(['activity_events_list', 'error_tracking_list', 'session_replay_list']) + .enum([ + 'activity_events_list', + 'error_tracking_list', + 'live_activity', + 'session_replay_list', + ]) .describe( - '\* `activity_events_list` - activity_events_list\n\* `error_tracking_list` - error_tracking_list\n\* `session_replay_list` - session_replay_list' + '\* `activity_events_list` - activity_events_list\n\* `error_tracking_list` - error_tracking_list\n\* `live_activity` - live_activity\n\* `session_replay_list` - session_replay_list' ) .optional() .describe( - 'Widget type identifier (cannot be changed on update).\n\n\* `activity_events_list` - activity_events_list\n\* `error_tracking_list` - error_tracking_list\n\* `session_replay_list` - session_replay_list' + 'Widget type identifier (cannot be changed on update).\n\n\* `activity_events_list` - activity_events_list\n\* `error_tracking_list` - error_tracking_list\n\* `live_activity` - live_activity\n\* `session_replay_list` - session_replay_list' ), config: zod .union([ @@ -468,6 +479,36 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'Filter by assignee ({type: user|role, id}). Omit for any assignee.' ), }), + zod.object({ + limit: zod + .number() + .min(1) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax) + .default( + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault + ) + .describe('Maximum number of recent events to return.'), + filterTestAccounts: zod + .union([zod.boolean(), zod.null()]) + .optional() + .describe( + 'Whether to exclude test accounts. When omitted, the project default is used.' + ), + refreshIntervalSeconds: zod + .number() + .min( + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMin + ) + .max( + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMax + ) + .default( + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsDefault + ) + .describe( + 'Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.' + ), + }), zod.object({ dateRange: zod .union([ @@ -546,9 +587,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitDefault ) .describe('Maximum number of recordings to return.'), orderBy: zod @@ -561,13 +602,13 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'console_error_count', ]) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeOrderByDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderByDefault ) .describe('Recording ranking column.'), orderDirection: zod .enum(['ASC', 'DESC']) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeOrderDirectionDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderDirectionDefault ) .describe('Sort direction for orderBy.'), savedFilterId: zod @@ -777,11 +818,20 @@ export const dashboardsWidgetsBatchCreateBodyWidgetsItemTwoConfigOneOrderDirecti export const dashboardsWidgetsBatchCreateBodyWidgetsItemTwoConfigOneStatusDefault = `active` export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeNameMax = 400 -export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitDefault = 10 -export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitMax = 25 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitDefault = 5 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitMax = 10 + +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsDefault = 15 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsMin = 15 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsMax = 60 + +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourNameMax = 400 + +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneLimitDefault = 10 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneLimitMax = 25 -export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneOrderByDefault = `start_time` -export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneOrderDirectionDefault = `DESC` +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneOrderByDefault = `start_time` +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneOrderDirectionDefault = `DESC` export const dashboardsWidgetsBatchCreateBodyWidgetsMax = 10 export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod @@ -1140,6 +1190,95 @@ export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod .boolean() .optional() .describe('Whether to show the description on the dashboard tile.'), + widget_type: zod.enum(['live_activity']), + config: zod + .object({ + limit: zod + .number() + .min(1) + .max(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitMax) + .default(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitDefault) + .describe('Maximum number of recent events to return.'), + filterTestAccounts: zod + .union([zod.boolean(), zod.null()]) + .optional() + .describe( + 'Whether to exclude test accounts. When omitted, the project default is used.' + ), + refreshIntervalSeconds: zod + .number() + .min( + dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsMin + ) + .max( + dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsMax + ) + .default( + dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsDefault + ) + .describe( + 'Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.' + ), + }) + .describe('Configuration for the live activity widget.'), + }), + zod.object({ + name: zod + .string() + .max(dashboardsWidgetsBatchCreateBodyWidgetsItemFourNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + layouts: zod + .object({ + sm: zod + .object({ + x: zod + .number() + .optional() + .describe('Column position in the dashboard grid (0-indexed).'), + y: zod + .number() + .optional() + .describe('Row position in the dashboard grid (0-indexed).'), + w: zod + .number() + .optional() + .describe('Width in grid columns. The desktop grid is 12 columns wide.'), + h: zod.number().optional().describe('Height in grid rows.'), + }) + .optional() + .describe( + 'Layout for the standard (desktop) breakpoint. The grid is 12 columns wide.' + ), + xs: zod + .object({ + x: zod + .number() + .optional() + .describe('Column position in the dashboard grid (0-indexed).'), + y: zod + .number() + .optional() + .describe('Row position in the dashboard grid (0-indexed).'), + w: zod + .number() + .optional() + .describe('Width in grid columns. The desktop grid is 12 columns wide.'), + h: zod.number().optional().describe('Height in grid rows.'), + }) + .optional() + .describe('Layout for the small (mobile) breakpoint. The grid is 1 column wide.'), + }) + .optional() + .describe('Optional react-grid-layout positions keyed by breakpoint (sm, xs).'), + show_description: zod + .boolean() + .optional() + .describe('Whether to show the description on the dashboard tile.'), widget_type: zod.enum(['session_replay_list']), config: zod .object({ @@ -1212,8 +1351,8 @@ export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitMax) - .default(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitDefault) + .max(dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneLimitMax) + .default(dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneLimitDefault) .describe('Maximum number of recordings to return.'), orderBy: zod .enum([ @@ -1224,12 +1363,12 @@ export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod 'click_count', 'console_error_count', ]) - .default(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneOrderByDefault) + .default(dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneOrderByDefault) .describe('Recording ranking column.'), orderDirection: zod .enum(['ASC', 'DESC']) .default( - dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneOrderDirectionDefault + dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneOrderDirectionDefault ) .describe('Sort direction for orderBy.'), savedFilterId: zod @@ -1246,7 +1385,7 @@ export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod .min(1) .max(dashboardsWidgetsBatchCreateBodyWidgetsMax) .describe( - 'Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request).' + 'Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, live_activity, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request).' ), }) .describe('OpenAPI-only batch-add schema with widget_type-discriminated config shapes for agents.') diff --git a/products/dashboards/frontend/generated/widget-config-property-keys.json b/products/dashboards/frontend/generated/widget-config-property-keys.json index 93db9f77f813..f7a6b8a73204 100644 --- a/products/dashboards/frontend/generated/widget-config-property-keys.json +++ b/products/dashboards/frontend/generated/widget-config-property-keys.json @@ -11,6 +11,7 @@ "status", "widgetFilters" ], + "live_activity": ["filterTestAccounts", "limit", "refreshIntervalSeconds"], "session_replay_list": [ "dateRange", "filterTestAccounts", @@ -173,6 +174,17 @@ } } }, + "live_activity": { + "limit": { + "$type": "integer" + }, + "filterTestAccounts": { + "$type": "boolean" + }, + "refreshIntervalSeconds": { + "$type": "integer" + } + }, "session_replay_list": { "dateRange": { "date_from": { diff --git a/products/dashboards/frontend/generated/widget-config-schemas/liveActivityWidgetConfig.zod.ts b/products/dashboards/frontend/generated/widget-config-schemas/liveActivityWidgetConfig.zod.ts new file mode 100644 index 000000000000..3830e5265a8d --- /dev/null +++ b/products/dashboards/frontend/generated/widget-config-schemas/liveActivityWidgetConfig.zod.ts @@ -0,0 +1,36 @@ +/** + * Generated by orval v8.14.0 🍺 + * Do not edit manually. + * PostHog API - 1 ops + * OpenAPI spec version: 1.0.0 + */ +import { z as zod } from 'zod' + +export const liveActivityWidgetConfigLimitDefault = 5 +export const liveActivityWidgetConfigLimitMax = 10 + +export const liveActivityWidgetConfigRefreshIntervalSecondsDefault = 15 +export const liveActivityWidgetConfigRefreshIntervalSecondsMin = 15 +export const liveActivityWidgetConfigRefreshIntervalSecondsMax = 60 + +export const LiveActivityWidgetConfig = /* @__PURE__ */ zod.object({ + limit: zod + .number() + .min(1) + .max(liveActivityWidgetConfigLimitMax) + .default(liveActivityWidgetConfigLimitDefault) + .describe('Maximum number of recent events to return.'), + filterTestAccounts: zod + .union([zod.boolean(), zod.null()]) + .optional() + .describe('Whether to exclude test accounts. When omitted, the project default is used.'), + refreshIntervalSeconds: zod + .number() + .min(liveActivityWidgetConfigRefreshIntervalSecondsMin) + .max(liveActivityWidgetConfigRefreshIntervalSecondsMax) + .default(liveActivityWidgetConfigRefreshIntervalSecondsDefault) + .describe('Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.'), +}) + +export type LiveActivityWidgetConfig = zod.input +export type LiveActivityWidgetConfigOutput = zod.output diff --git a/products/dashboards/frontend/generated/widget-configs.zod.ts b/products/dashboards/frontend/generated/widget-configs.zod.ts index 589f22bc186f..02f3a6e8d880 100644 --- a/products/dashboards/frontend/generated/widget-configs.zod.ts +++ b/products/dashboards/frontend/generated/widget-configs.zod.ts @@ -5,16 +5,19 @@ import { z as zod } from 'zod' import { ActivityEventsListWidgetConfig } from './widget-config-schemas/activityEventsListWidgetConfig.zod' import { ErrorTrackingListWidgetConfig } from './widget-config-schemas/errorTrackingListWidgetConfig.zod' +import { LiveActivityWidgetConfig as LiveActivityWidgetConfigSchemaSource } from './widget-config-schemas/liveActivityWidgetConfig.zod' import { SessionReplayListWidgetConfig } from './widget-config-schemas/sessionReplayListWidgetConfig.zod' import { WidgetFilterEntry } from './widget-config-schemas/widgetFilterEntry.zod' export const activityEventsWidgetConfigSchema = /* @__PURE__ */ ActivityEventsListWidgetConfig export const errorTrackingWidgetConfigSchema = /* @__PURE__ */ ErrorTrackingListWidgetConfig +export const liveActivityWidgetConfigSchema = /* @__PURE__ */ LiveActivityWidgetConfigSchemaSource export const sessionReplayWidgetConfigSchema = /* @__PURE__ */ SessionReplayListWidgetConfig export const widgetFilterEntrySchema = /* @__PURE__ */ WidgetFilterEntry export type ActivityEventsWidgetConfig = zod.infer export type ErrorTrackingWidgetConfig = zod.infer +export type LiveActivityWidgetConfig = zod.infer export type SessionReplayWidgetConfig = zod.infer type WidgetFiltersRecord = NonNullable @@ -37,6 +40,12 @@ export const errorTrackingWidgetFormSchema = errorTrackingWidgetConfigSchema.pic status: true, }) +export const liveActivityWidgetFormSchema = liveActivityWidgetConfigSchema.pick({ + limit: true, + refreshIntervalSeconds: true, + filterTestAccounts: true, +}) + export const sessionReplayWidgetFormSchema = sessionReplayWidgetConfigSchema.pick({ limit: true, orderBy: true, diff --git a/products/dashboards/frontend/generated/widget-form-fields.json b/products/dashboards/frontend/generated/widget-form-fields.json index 91d3ce45c55b..748eed418dda 100644 --- a/products/dashboards/frontend/generated/widget-form-fields.json +++ b/products/dashboards/frontend/generated/widget-form-fields.json @@ -12,6 +12,12 @@ "formSchemaExport": "errorTrackingWidgetFormSchema", "formFields": ["limit", "orderBy", "orderDirection", "dateRange", "filterTestAccounts", "status"] }, + "live_activity": { + "configSchemaExport": "liveActivityWidgetConfigSchema", + "configTypeExport": "LiveActivityWidgetConfig", + "formSchemaExport": "liveActivityWidgetFormSchema", + "formFields": ["limit", "refreshIntervalSeconds", "filterTestAccounts"] + }, "session_replay_list": { "configSchemaExport": "sessionReplayWidgetConfigSchema", "configTypeExport": "SessionReplayWidgetConfig", diff --git a/products/dashboards/frontend/widget_types/catalog.ts b/products/dashboards/frontend/widget_types/catalog.ts index a1bc6b8ee44a..51b1c5c85de5 100644 --- a/products/dashboards/frontend/widget_types/catalog.ts +++ b/products/dashboards/frontend/widget_types/catalog.ts @@ -6,11 +6,13 @@ import { ActivityTab } from '~/types' import { activityEventsWidgetConfigSchema, errorTrackingWidgetConfigSchema, + liveActivityWidgetConfigSchema, sessionReplayWidgetConfigSchema, } from '../generated/widget-configs.zod' import type { DashboardWidgetProductAccess } from '../types' import { ActivityEventsWidgetPreview } from '../widgets/previews/ActivityEventsWidgetPreview' import { ErrorTrackingWidgetPreview } from '../widgets/previews/ErrorTrackingWidgetPreview' +import { LiveActivityWidgetPreview } from '../widgets/previews/LiveActivityWidgetPreview' import { SessionReplayWidgetPreview } from '../widgets/previews/SessionReplayWidgetPreview' import type { WidgetAvailabilityConfig } from './widgetAvailability' @@ -172,6 +174,20 @@ export const DASHBOARD_WIDGET_CATALOG = { message: 'Log in to PostHog to explore the latest events from this dashboard.', }, }, + live_activity: { + groupId: 'activity', + label: 'Live activity', + description: 'Active users, activity pulse, and recent events for the last 5 minutes.', + headerTitle: 'Live activity', + defaultConfig: liveActivityWidgetConfigSchema.parse({}), + defaultLayout: { w: 4, h: 4, minW: 3, minH: 3 }, + headerMeta: { showDateRange: false }, + titleHref: urls.activity(ActivityTab.LiveEvents), + sharedPlaceholder: { + title: 'Live activity', + message: 'Log in to PostHog to see live activity from this dashboard.', + }, + }, } as const satisfies Record export type DashboardWidgetCatalogKey = keyof typeof DASHBOARD_WIDGET_CATALOG @@ -180,6 +196,7 @@ export type DashboardWidgetCatalogKey = keyof typeof DASHBOARD_WIDGET_CATALOG export const DASHBOARD_WIDGET_PREVIEWS: Record JSX.Element> = { activity_events_list: ActivityEventsWidgetPreview, error_tracking_list: ErrorTrackingWidgetPreview, + live_activity: LiveActivityWidgetPreview, session_replay_list: SessionReplayWidgetPreview, } diff --git a/products/dashboards/frontend/widgets/activity/EditLiveActivityWidgetModal.test.tsx b/products/dashboards/frontend/widgets/activity/EditLiveActivityWidgetModal.test.tsx new file mode 100644 index 000000000000..3deeb951eca2 --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/EditLiveActivityWidgetModal.test.tsx @@ -0,0 +1,84 @@ +import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' + +import '@testing-library/jest-dom' + +import { cleanup, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { filterTestAccountsDefaultsLogic } from 'scenes/settings/environment/filterTestAccountDefaultsLogic' + +import { initKeaTests } from '~/test/init' +import { PropertyFilterType, PropertyOperator } from '~/types' + +import { EditLiveActivityWidgetModal } from './EditLiveActivityWidgetModal' + +describe('EditLiveActivityWidgetModal', () => { + afterEach(() => { + cleanup() + }) + + beforeEach(() => { + initKeaTests(true, { + ...MOCK_DEFAULT_TEAM, + test_account_filters: [ + { + key: 'email', + value: '@posthog.com', + operator: PropertyOperator.NotIContains, + type: PropertyFilterType.Person, + }, + ], + }) + filterTestAccountsDefaultsLogic.mount() + }) + + it('saves widget config from default live activity settings', async () => { + const onSave = jest.fn().mockResolvedValue(undefined) + + render( + + ) + + const dialog = screen.getByRole('dialog') + await userEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 5, + refreshIntervalSeconds: 15, + filterTestAccounts: true, + }), + {} + ) + }) + + it('shows inline errors for too-fast refresh intervals instead of saving', async () => { + const onSave = jest.fn() + + render( + + ) + + const refreshIntervalInput = screen.getAllByRole('spinbutton')[1] + await userEvent.clear(refreshIntervalInput) + await userEvent.type(refreshIntervalInput, '5') + + expect(screen.getByText('Too small: expected number to be >=15')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-disabled', 'true') + expect(onSave).not.toHaveBeenCalled() + }) +}) diff --git a/products/dashboards/frontend/widgets/activity/EditLiveActivityWidgetModal.tsx b/products/dashboards/frontend/widgets/activity/EditLiveActivityWidgetModal.tsx new file mode 100644 index 000000000000..901e4afa7a6e --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/EditLiveActivityWidgetModal.tsx @@ -0,0 +1,148 @@ +import { BindLogic, useActions, useValues } from 'kea' + +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { LemonDivider } from 'lib/lemon-ui/LemonDivider' +import { LemonField } from 'lib/lemon-ui/LemonField/LemonField' +import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput' +import { LemonModal } from 'lib/lemon-ui/LemonModal' + +import { getDashboardWidgetGroupLabel } from '../../widget_types/catalog' +import { EditWidgetModalFiltersSubsection } from '../EditWidgetModalFiltersSection' +import { EditWidgetModalTileDetailsSection } from '../EditWidgetModalTileDetailsSection' +import type { DashboardWidgetEditModalProps } from '../registry' +import { editLiveActivityWidgetModalLogic } from './editLiveActivityWidgetModalLogic' + +function EditLiveActivityWidgetModalContents(): JSX.Element { + const { + limit, + refreshIntervalSeconds, + tileName, + tileDescription, + filterTestAccounts, + activeFieldErrors, + saving, + saveDisabledReason, + onClose, + defaultTitle, + } = useValues(editLiveActivityWidgetModalLogic) + const { + setLimit, + setRefreshIntervalSeconds, + setTileName, + setTileDescription, + setFilterTestAccounts, + clearFieldError, + submit, + } = useActions(editLiveActivityWidgetModalLogic) + + return ( + +
+ + Cancel + + submit()} + > + Save + + + } + > +
+ + +
+
{getDashboardWidgetGroupLabel('activity')}
+
+ +

+ Live activity always uses a fixed 5-minute rolling window. Auto-refresh pauses while the + browser tab is hidden. +

+ + { + setLimit(Number(value)) + clearFieldError('limit') + }} + /> + + + { + setRefreshIntervalSeconds(Number(value)) + clearFieldError('refreshIntervalSeconds') + }} + /> + +
+
+
+
+ + ) +} + +export function EditLiveActivityWidgetModal({ + isOpen, + onClose, + config, + onSave, + name, + defaultTitle, + description, +}: DashboardWidgetEditModalProps): JSX.Element | null { + if (!isOpen) { + return null + } + + return ( + + + + ) +} diff --git a/products/dashboards/frontend/widgets/activity/LiveActivityWidget.stories.tsx b/products/dashboards/frontend/widgets/activity/LiveActivityWidget.stories.tsx new file mode 100644 index 000000000000..5b8e3bc1e3c9 --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/LiveActivityWidget.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { DashboardPlacement } from '~/types' + +import { WidgetCard } from '../../components/WidgetCard/WidgetCard' +import { WidgetCardBody } from '../../components/WidgetCard/WidgetCardBody' +import { WidgetCardHeader, widgetCardShouldHideMoreButton } from '../../components/WidgetCard/WidgetCardHeader' +import { + mockMoreOverlay, + widgetStorybookParameters, + widgetTileFrameDecorator, +} from '../../components/WidgetCard/widgetCardStoryFixtures' +import { getDashboardWidgetCatalogEntry, getDashboardWidgetGroupLabel } from '../../widget_types/catalog' +import type { DashboardWidgetComponentProps } from '../registry' +import { liveActivityDelayedResult, liveActivityEmptyResult, liveActivitySampleResult } from './liveActivitySampleData' +import { LiveActivityWidget } from './LiveActivityWidget' + +const LIVE_ACTIVITY_CATALOG = getDashboardWidgetCatalogEntry('live_activity') +const DEFAULT_CONFIG = LIVE_ACTIVITY_CATALOG.defaultConfig as Record + +function LiveActivityWidgetTileStory({ + title = '', + description = 'Active users, activity pulse, and recent events for the last 5 minutes.', + showDescription = true, + cardError, + ...widgetProps +}: DashboardWidgetComponentProps & { + title?: string + description?: string + showDescription?: boolean + cardError?: string | null +}): JSX.Element { + const widgetTypeLabel = getDashboardWidgetGroupLabel(LIVE_ACTIVITY_CATALOG.groupId) + const defaultTitle = LIVE_ACTIVITY_CATALOG.headerTitle ?? LIVE_ACTIVITY_CATALOG.label + + return ( + + + + + + + ) +} + +const meta: Meta = { + title: 'Dashboards/Dashboard Widgets/Widget types/Activity/Live activity', + component: LiveActivityWidgetTileStory, + parameters: { + layout: 'padded', + ...widgetStorybookParameters, + }, + decorators: [...widgetTileFrameDecorator], + args: { + tileId: 1, + config: DEFAULT_CONFIG, + loading: false, + result: liveActivitySampleResult, + onUpdateConfig: () => undefined, + onRefresh: () => undefined, + onRefreshData: () => undefined, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + title: 'Live activity', + config: DEFAULT_CONFIG, + loading: false, + result: liveActivitySampleResult, + }, +} + +export const Loading: Story = { + args: { + title: 'Live activity', + config: DEFAULT_CONFIG, + loading: true, + result: null, + }, + parameters: { + testOptions: { waitForLoadersToDisappear: false }, + }, +} + +export const Empty: Story = { + args: { + title: 'Live activity', + config: DEFAULT_CONFIG, + loading: false, + result: liveActivityEmptyResult, + }, +} + +export const Delayed: Story = { + args: { + title: 'Live activity', + config: DEFAULT_CONFIG, + loading: false, + result: liveActivityDelayedResult, + }, +} + +export const Narrow: Story = { + args: { + title: 'Live activity', + config: DEFAULT_CONFIG, + loading: false, + result: liveActivitySampleResult, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const Error: Story = { + args: { + title: 'Live activity', + config: DEFAULT_CONFIG, + loading: false, + result: liveActivitySampleResult, + cardError: 'Live activity could not be refreshed.', + }, +} diff --git a/products/dashboards/frontend/widgets/activity/LiveActivityWidget.test.tsx b/products/dashboards/frontend/widgets/activity/LiveActivityWidget.test.tsx new file mode 100644 index 000000000000..d75fcb29baa3 --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/LiveActivityWidget.test.tsx @@ -0,0 +1,148 @@ +import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' + +import '@testing-library/jest-dom' + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' + +import { teamLogic } from 'scenes/teamLogic' + +import { initKeaTests } from '~/test/init' + +import { liveActivityDelayedResult, liveActivityEmptyResult, liveActivitySampleResult } from './liveActivitySampleData' +import { LiveActivityWidget } from './LiveActivityWidget' + +const setHidden = (hidden: boolean): void => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }) + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => (hidden ? 'hidden' : 'visible'), + }) + document.dispatchEvent(new Event('visibilitychange')) +} + +describe('LiveActivityWidget', () => { + afterEach(() => { + cleanup() + jest.useRealTimers() + setHidden(false) + }) + + beforeEach(() => { + initKeaTests(true, { ...MOCK_DEFAULT_TEAM }) + teamLogic.mount() + setHidden(false) + }) + + it('renders the active user metric, pulse, and bounded recent-events feed', () => { + const { container } = render( + + ) + + expect(screen.getByText('42')).toBeInTheDocument() + expect(screen.getByText('active users')).toBeInTheDocument() + expect(container.querySelectorAll('[data-attr="live-activity-widget-event-row"]')).toHaveLength(5) + expect(screen.getByText('Signup completed')).toBeInTheDocument() + expect(screen.getByText('Backend')).toBeInTheDocument() + expect(screen.getByText('5 of 186 recent events')).toBeInTheDocument() + expect(screen.getByLabelText('Activity pulse over the last 5 minutes')).toBeInTheDocument() + }) + + it('renders loading and narrow-layout affordances', () => { + const { container, rerender } = render( +
+ +
+ ) + + expect(container.querySelectorAll('.LemonSkeleton')).toHaveLength(7) + + rerender( +
+ +
+ ) + + expect(screen.getByLabelText('Pause live activity refresh')).toBeInTheDocument() + expect(container.querySelector('.\\@max-\\[360px\\]\\/widget-card\\:flex-col')).toBeInTheDocument() + }) + + it('renders empty and delayed states', () => { + const { rerender, container } = render( + + ) + + expect(container.querySelector('[data-attr="live-activity-widget-empty-state"]')).toBeInTheDocument() + expect(screen.getByText('No live activity yet')).toBeInTheDocument() + + rerender( + + ) + + expect(screen.getByText('Delayed')).toBeInTheDocument() + }) + + it('auto-refreshes on the configured interval, supports pause/resume, and pauses while hidden', () => { + jest.useFakeTimers() + const onRefresh = jest.fn() + const onRefreshData = jest.fn() + + render( + + ) + + act(() => jest.advanceTimersByTime(15000)) + expect(onRefreshData).toHaveBeenCalledTimes(1) + expect(onRefresh).not.toHaveBeenCalled() + + fireEvent.click(screen.getByLabelText('Pause live activity refresh')) + act(() => jest.advanceTimersByTime(30000)) + expect(onRefreshData).toHaveBeenCalledTimes(1) + expect(screen.getByText('Paused')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Resume live activity refresh')) + expect(onRefreshData).toHaveBeenCalledTimes(2) + + act(() => setHidden(true)) + act(() => jest.advanceTimersByTime(30000)) + expect(onRefreshData).toHaveBeenCalledTimes(2) + + act(() => setHidden(false)) + act(() => jest.advanceTimersByTime(15000)) + expect(onRefreshData).toHaveBeenCalledTimes(3) + + fireEvent.click(screen.getByLabelText('Refresh live activity now')) + expect(onRefresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/products/dashboards/frontend/widgets/activity/LiveActivityWidget.tsx b/products/dashboards/frontend/widgets/activity/LiveActivityWidget.tsx new file mode 100644 index 000000000000..482f2f8d77c2 --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/LiveActivityWidget.tsx @@ -0,0 +1,356 @@ +import clsx from 'clsx' +import { BindLogic, useActions, useValues } from 'kea' + +import { IconPauseFilled, IconPlayFilled, IconRefresh } from '@posthog/icons' + +import { DetectiveHog } from 'lib/components/hedgehogs' +import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { TZLabel } from 'lib/components/TZLabel' +import { dayjs } from 'lib/dayjs' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' +import { Link } from 'lib/lemon-ui/Link' +import { PersonDisplay } from 'scenes/persons/PersonDisplay' +import { urls } from 'scenes/urls' + +import { + WidgetCardBodyMessage, + WidgetCardContent, + WidgetContentFooter, + WidgetListCount, +} from '../../components/WidgetCard' +import type { DashboardWidgetComponentProps } from '../registry' +import { parseLiveActivityWidgetConfig } from './liveActivityWidgetConfigValidation' +import { liveActivityWidgetLogic } from './liveActivityWidgetLogic' + +export type LiveActivitySurface = 'web' | 'mobile' | 'backend' + +export type LiveActivityWidgetEvent = { + uuid: string + event: string + person: { display_name?: string | null; distinct_id?: string | null } | null + target: string | null + lib: string | null + surface: LiveActivitySurface + timestamp: string | null +} + +export type LiveActivityPulseBucket = { + bucketStart: string + count: number +} + +export type LiveActivityWidgetResult = { + activeUsers?: number + eventsInWindow?: number + peakEventsPerMinute?: number + pulse?: LiveActivityPulseBucket[] + events?: LiveActivityWidgetEvent[] + limit?: number + rollingWindowSeconds?: number + bucketSeconds?: number + refreshIntervalSeconds?: number + generatedAt?: string + windowStart?: string + latestEventTimestamp?: string | null + delayedAfterSeconds?: number +} + +const LIVE_ACTIVITY_SURFACE_LABELS: Record = { + web: 'Web', + mobile: 'Mobile', + backend: 'Backend', +} + +const LIVE_ACTIVITY_SURFACE_CLASSES: Record = { + web: 'bg-accent-highlight-secondary text-accent', + mobile: 'bg-warning-highlight text-warning', + backend: 'bg-surface-secondary text-secondary', +} + +function formatCompactNumber(value: number | null | undefined): string { + return Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(value ?? 0) +} + +function formatWindow(seconds: number | null | undefined): string { + const safeSeconds = seconds ?? 300 + if (safeSeconds % 60 === 0) { + return `${safeSeconds / 60} min` + } + return `${safeSeconds}s` +} + +function buildPulsePath(pulse: LiveActivityPulseBucket[], width: number, height: number): string { + if (pulse.length === 0) { + return '' + } + + const maxCount = Math.max(...pulse.map((bucket) => bucket.count), 1) + return pulse + .map((bucket, index) => { + const x = pulse.length === 1 ? width : (index / (pulse.length - 1)) * width + const y = height - (bucket.count / maxCount) * height + return `${index === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}` + }) + .join(' ') +} + +function buildPulseAreaPath(pulse: LiveActivityPulseBucket[], width: number, height: number): string { + const linePath = buildPulsePath(pulse, width, height) + if (!linePath) { + return '' + } + return `${linePath} L ${width} ${height} L 0 ${height} Z` +} + +function isResultDelayed(payload: LiveActivityWidgetResult): boolean { + if (!payload.generatedAt || !payload.latestEventTimestamp) { + return false + } + + return ( + dayjs(payload.generatedAt).diff(dayjs(payload.latestEventTimestamp), 'second') >= + (payload.delayedAfterSeconds ?? 60) + ) +} + +export function LiveActivityPulse({ pulse }: { pulse: LiveActivityPulseBucket[] }): JSX.Element { + const width = 240 + const height = 48 + const linePath = buildPulsePath(pulse, width, height) + const areaPath = buildPulseAreaPath(pulse, width, height) + + return ( +
+ + {areaPath ? : null} + {linePath ? : null} + +
+ ) +} + +function LiveActivityWidgetSkeleton(): JSX.Element { + return ( + +
+
+
+ + +
+ +
+ +
+ {Array.from({ length: 3 }, (_, index) => ( + + ))} +
+
+
+ ) +} + +function LiveActivityEventRow({ event }: { event: LiveActivityWidgetEvent }): JSX.Element { + const content = ( +
+
+ + {LIVE_ACTIVITY_SURFACE_LABELS[event.surface]} + + + + + {event.timestamp ? ( + + + + ) : null} +
+
+ {event.person ? ( + + ) : null} + {event.target ? {event.target} : null} +
+
+ ) + + if (!event.timestamp) { + return content + } + + return ( + + {content} + + ) +} + +function LiveActivityWidgetContents({ result, loading }: DashboardWidgetComponentProps): JSX.Element { + const { isPaused } = useValues(liveActivityWidgetLogic) + const { pauseAutoRefresh, resumeAutoRefresh, manualRefresh } = useActions(liveActivityWidgetLogic) + const payload = result as LiveActivityWidgetResult | null | undefined + const events = payload?.events ?? [] + const pulse = payload?.pulse ?? [] + const activeUsers = payload?.activeUsers ?? 0 + const eventsInWindow = payload?.eventsInWindow ?? 0 + const rollingWindowSeconds = payload?.rollingWindowSeconds ?? 300 + const isDelayed = payload ? isResultDelayed(payload) : false + + if (loading && !payload) { + return + } + + if (!payload || (events.length === 0 && eventsInWindow === 0)) { + return ( + + +
+ +

No live activity yet

+

+ No events were captured in the last {formatWindow(rollingWindowSeconds)}. +

+
+
+
+ ) + } + + return ( + <> + +
+
+
+
+ + {formatCompactNumber(activeUsers)} + + active users +
+
+ Last {formatWindow(rollingWindowSeconds)} · {formatCompactNumber(eventsInWindow)} events +
+
+
+ + {isPaused ? 'Paused' : isDelayed ? 'Delayed' : 'Live'} + + : } + onClick={isPaused ? resumeAutoRefresh : pauseAutoRefresh} + size="xsmall" + type="secondary" + /> + } + onClick={manualRefresh} + size="xsmall" + type="secondary" + /> +
+
+ + + +
+ {events.map((event) => ( + + ))} +
+
+
+ +
+ events.length} + dataAttr="live-activity-widget-count" + /> + + Refreshes every {payload.refreshIntervalSeconds ?? 15}s · hidden tabs pause + +
+
+ + ) +} + +export function LiveActivityWidget({ + config, + tileId, + loading, + onRefresh, + onRefreshData, + ...props +}: DashboardWidgetComponentProps): JSX.Element { + const parsedConfig = parseLiveActivityWidgetConfig(config) + + return ( + + + + ) +} diff --git a/products/dashboards/frontend/widgets/activity/editLiveActivityWidgetModalLogic.ts b/products/dashboards/frontend/widgets/activity/editLiveActivityWidgetModalLogic.ts new file mode 100644 index 000000000000..797b6056e652 --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/editLiveActivityWidgetModalLogic.ts @@ -0,0 +1,201 @@ +import { actions, connect, defaults, kea, listeners, path, props, reducers, selectors } from 'kea' + +import { filterTestAccountsDefaultsLogic } from 'scenes/settings/environment/filterTestAccountDefaultsLogic' + +import type { LiveActivityWidgetConfig } from '../../generated/widget-configs.zod' +import { isWidgetConfigValidationError } from '../../utils' +import { resolveWidgetFilterTestAccounts } from '../../widget_types/widgetConfigShared' +import { + buildWidgetTileMetadataPatch, + getWidgetEditModalTileDefaults, + widgetEditModalFilterTestAccountsActions, + widgetEditModalListFieldActions, + widgetEditModalPropSelectors, + widgetEditModalTileActions, +} from '../editWidgetModalBuilders' +import type { DashboardWidgetEditModalProps } from '../registry' +import type { editLiveActivityWidgetModalLogicType } from './editLiveActivityWidgetModalLogicType' +import { + parseLiveActivityWidgetConfig, + validateLiveActivityWidgetConfigInput, + type LiveActivityWidgetFieldErrors, +} from './liveActivityWidgetConfigValidation' + +export type EditLiveActivityWidgetModalLogicProps = Omit + +export const editLiveActivityWidgetModalLogic = kea([ + path(['products', 'dashboards', 'widgets', 'activity', 'editLiveActivityWidgetModalLogic']), + + props({ + config: {}, + onSave: async () => {}, + onClose: () => {}, + name: '', + defaultTitle: 'Untitled', + description: '', + } as EditLiveActivityWidgetModalLogicProps), + + connect(() => ({ + values: [filterTestAccountsDefaultsLogic, ['filterTestAccountsDefault']], + })), + + actions({ + ...widgetEditModalListFieldActions, + ...widgetEditModalTileActions, + ...widgetEditModalFilterTestAccountsActions, + setRefreshIntervalSeconds: (refreshIntervalSeconds: number) => ({ refreshIntervalSeconds }), + setFieldErrors: (fieldErrors: LiveActivityWidgetFieldErrors) => ({ fieldErrors }), + clearFieldError: (field: keyof LiveActivityWidgetFieldErrors) => ({ field }), + submit: true, + submitSuccess: true, + submitFailure: true, + }), + + reducers({ + limit: [ + 5, + { + setLimit: (_: number, { limit }: { limit: number }) => limit, + }, + ], + refreshIntervalSeconds: [ + 15, + { + setRefreshIntervalSeconds: ( + _: number, + { refreshIntervalSeconds }: { refreshIntervalSeconds: number } + ) => refreshIntervalSeconds, + }, + ], + tileName: [ + '', + { + setTileName: (_: string, { tileName }: { tileName: string }) => tileName, + }, + ], + tileDescription: [ + '', + { + setTileDescription: (_: string, { tileDescription }: { tileDescription: string }) => tileDescription, + }, + ], + filterTestAccounts: [ + false, + { + setFilterTestAccounts: (_: boolean, { filterTestAccounts }: { filterTestAccounts: boolean }) => + filterTestAccounts, + }, + ], + fieldErrors: [ + {} as LiveActivityWidgetFieldErrors, + { + setFieldErrors: ( + _: LiveActivityWidgetFieldErrors, + { fieldErrors }: { fieldErrors: LiveActivityWidgetFieldErrors } + ) => fieldErrors, + clearFieldError: ( + state: LiveActivityWidgetFieldErrors, + { field }: { field: keyof LiveActivityWidgetFieldErrors } + ) => { + if (!state[field]) { + return state + } + const next = { ...state } + delete next[field] + return next + }, + }, + ], + saving: [ + false, + { + submit: (_state: boolean, _payload: { value: true }) => true, + submitSuccess: (_state: boolean, _payload: { value: true }) => false, + submitFailure: (_state: boolean, _payload: { value: true }) => false, + }, + ], + }), + + selectors({ + widgetConfig: [ + (_, p) => [p.config], + (config): LiveActivityWidgetConfig => parseLiveActivityWidgetConfig(config), + ], + ...widgetEditModalPropSelectors, + validation: [ + (s) => [s.limit, s.refreshIntervalSeconds, s.filterTestAccounts, s.widgetConfig], + (limit, refreshIntervalSeconds, filterTestAccounts, widgetConfig) => + validateLiveActivityWidgetConfigInput({ + limit, + refreshIntervalSeconds, + filterTestAccounts, + baseConfig: widgetConfig, + }), + ], + activeFieldErrors: [ + (s) => [s.validation, s.fieldErrors], + (validation, fieldErrors): LiveActivityWidgetFieldErrors => { + if (!validation.success) { + return { ...validation.fieldErrors, ...fieldErrors } + } + return fieldErrors + }, + ], + saveDisabledReason: [ + (s) => [s.saving, s.validation], + (saving, validation): string | undefined => { + if (saving) { + return 'Saving…' + } + if (!validation.success) { + return 'Fix validation errors to save' + } + return undefined + }, + ], + }), + + defaults(({ props, values }) => { + const baseConfig = parseLiveActivityWidgetConfig(props.config) + + return { + limit: baseConfig.limit, + refreshIntervalSeconds: baseConfig.refreshIntervalSeconds, + ...getWidgetEditModalTileDefaults(props), + filterTestAccounts: resolveWidgetFilterTestAccounts( + baseConfig.filterTestAccounts, + values.filterTestAccountsDefault + ), + fieldErrors: {}, + saving: false, + } + }), + + listeners(({ actions, props, values }) => ({ + submit: async () => { + const result = values.validation + + if (!result.success) { + actions.setFieldErrors(result.fieldErrors) + return + } + + try { + await props.onSave( + result.config, + buildWidgetTileMetadataPatch(props, values.tileName, values.tileDescription) + ) + actions.setFieldErrors({}) + props.onClose() + actions.submitSuccess() + } catch (error) { + actions.submitFailure() + if (isWidgetConfigValidationError(error)) { + actions.setFieldErrors(error.fieldErrors as LiveActivityWidgetFieldErrors) + return + } + throw error + } + }, + })), +]) diff --git a/products/dashboards/frontend/widgets/activity/liveActivitySampleData.ts b/products/dashboards/frontend/widgets/activity/liveActivitySampleData.ts new file mode 100644 index 000000000000..fa6b73e0262c --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/liveActivitySampleData.ts @@ -0,0 +1,90 @@ +import type { LiveActivityWidgetResult } from './LiveActivityWidget' + +export const liveActivitySamplePulse = [3, 5, 8, 6, 10, 11, 14, 12, 15, 18, 19, 17, 21, 24, 22, 29, 25, 31, 28, 34].map( + (count, index) => ({ + bucketStart: `2026-06-17T11:${String(55 + Math.floor(index / 4)).padStart(2, '0')}:${String( + (index % 4) * 15 + ).padStart(2, '0')}.000Z`, + count, + }) +) + +export const liveActivitySampleResult: LiveActivityWidgetResult = { + activeUsers: 42, + eventsInWindow: 186, + peakEventsPerMinute: 118, + pulse: liveActivitySamplePulse, + events: [ + { + uuid: 'event-live-1', + event: '$pageview', + person: { display_name: 'Alex Chen', distinct_id: 'alex@example.test' }, + surface: 'web', + target: '/pricing', + lib: 'web', + timestamp: '2026-06-17T11:59:50.000Z', + }, + { + uuid: 'event-live-2', + event: 'Signup completed', + person: { display_name: 'Dana Okafor', distinct_id: 'dana@example.test' }, + surface: 'mobile', + target: 'SignupView', + lib: 'ios', + timestamp: '2026-06-17T11:59:43.000Z', + }, + { + uuid: 'event-live-3', + event: 'File uploaded', + person: { display_name: 'Sam Rivera', distinct_id: 'sam@example.test' }, + surface: 'backend', + target: 'api.uploads.create', + lib: 'python', + timestamp: '2026-06-17T11:59:29.000Z', + }, + { + uuid: 'event-live-4', + event: '$screen', + person: { display_name: 'Jordan Lee', distinct_id: 'jordan@example.test' }, + surface: 'mobile', + target: 'Dashboard', + lib: 'android', + timestamp: '2026-06-17T11:59:12.000Z', + }, + { + uuid: 'event-live-5', + event: 'Checkout started', + person: { display_name: 'Priya Shah', distinct_id: 'priya@example.test' }, + surface: 'web', + target: '/checkout', + lib: 'web', + timestamp: '2026-06-17T11:58:58.000Z', + }, + ], + limit: 5, + rollingWindowSeconds: 300, + bucketSeconds: 15, + refreshIntervalSeconds: 15, + generatedAt: '2026-06-17T12:00:00.000Z', + windowStart: '2026-06-17T11:55:00.000Z', + latestEventTimestamp: '2026-06-17T11:59:50.000Z', + delayedAfterSeconds: 60, +} + +export const liveActivityDelayedResult: LiveActivityWidgetResult = { + ...liveActivitySampleResult, + activeUsers: 3, + eventsInWindow: 9, + latestEventTimestamp: '2026-06-17T11:57:45.000Z', + events: (liveActivitySampleResult.events ?? []).slice(0, 2), +} + +export const liveActivityEmptyResult: LiveActivityWidgetResult = { + ...liveActivitySampleResult, + activeUsers: 0, + eventsInWindow: 0, + peakEventsPerMinute: 0, + pulse: liveActivitySamplePulse.map((bucket) => ({ ...bucket, count: 0 })), + events: [], + latestEventTimestamp: null, +} diff --git a/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.test.ts b/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.test.ts new file mode 100644 index 000000000000..376579d8b34d --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.test.ts @@ -0,0 +1,82 @@ +import { ApiError } from 'lib/api-error' + +import { liveActivityWidgetConfigSchema } from '../../generated/widget-configs.zod' +import { + LIVE_ACTIVITY_WIDGET_FORM_FIELD_NAMES, + parseLiveActivityWidgetConfigApiError, + validateLiveActivityWidgetConfigInput, +} from './liveActivityWidgetConfigValidation' + +describe('liveActivityWidgetConfigValidation', () => { + it('form picked fields exist on the generated config schema', () => { + const shape = liveActivityWidgetConfigSchema.shape + for (const field of LIVE_ACTIVITY_WIDGET_FORM_FIELD_NAMES) { + expect(shape).toHaveProperty(field) + } + }) + + describe('validateLiveActivityWidgetConfigInput', () => { + it('rejects limit above 10 with inline-friendly message', () => { + const result = validateLiveActivityWidgetConfigInput({ + limit: 11, + refreshIntervalSeconds: 15, + filterTestAccounts: true, + baseConfig: liveActivityWidgetConfigSchema.parse({}), + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.fieldErrors.limit).toBe('Too big: expected number to be <=10') + } + }) + + it('rejects refresh intervals below 15 seconds', () => { + const result = validateLiveActivityWidgetConfigInput({ + limit: 5, + refreshIntervalSeconds: 5, + filterTestAccounts: true, + baseConfig: liveActivityWidgetConfigSchema.parse({}), + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.fieldErrors.refreshIntervalSeconds).toBe('Too small: expected number to be >=15') + } + }) + + it('accepts valid config', () => { + const result = validateLiveActivityWidgetConfigInput({ + limit: 8, + refreshIntervalSeconds: 30, + filterTestAccounts: false, + baseConfig: liveActivityWidgetConfigSchema.parse({}), + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.config).toEqual({ + limit: 8, + refreshIntervalSeconds: 30, + filterTestAccounts: false, + }) + } + }) + }) + + describe('parseLiveActivityWidgetConfigApiError', () => { + it('maps invalid config to zod field errors', () => { + const error = new ApiError('limit must be an integer between 1 and 10.', 400, undefined, { + config: 'limit must be an integer between 1 and 10.', + }) + + expect( + parseLiveActivityWidgetConfigApiError(error, { + limit: 11, + refreshIntervalSeconds: 15, + }) + ).toEqual({ + limit: 'Too big: expected number to be <=10', + }) + }) + }) +}) diff --git a/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.ts b/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.ts new file mode 100644 index 000000000000..820a46f34291 --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' + +import { ApiError } from 'lib/api-error' + +import { + liveActivityWidgetConfigSchema, + liveActivityWidgetFormSchema, + type LiveActivityWidgetConfig, +} from '../../generated/widget-configs.zod' +import { fieldErrorsFromZodError, parseWidgetConfig } from '../widgetConfigValidation' + +export const LIVE_ACTIVITY_WIDGET_FORM_FIELD_NAMES = Object.keys( + liveActivityWidgetFormSchema.shape +) as (keyof typeof liveActivityWidgetFormSchema.shape)[] + +type LiveActivityWidgetFormField = keyof z.infer + +export type LiveActivityWidgetFieldErrors = Partial> + +export type LiveActivityWidgetFormInput = { + limit: number + refreshIntervalSeconds: number + filterTestAccounts: boolean | null +} + +const liveActivityConfigDefaults = liveActivityWidgetConfigSchema.parse({}) + +export function parseLiveActivityWidgetConfig(config: Record): LiveActivityWidgetConfig { + return parseWidgetConfig(liveActivityWidgetConfigSchema, config) +} + +export function buildLiveActivityWidgetConfig( + formInput: LiveActivityWidgetFormInput, + baseConfig: LiveActivityWidgetConfig +): LiveActivityWidgetConfig { + return liveActivityWidgetConfigSchema.parse({ + ...baseConfig, + ...formInput, + }) +} + +export function validateLiveActivityWidgetConfigInput(input: { + limit: number + refreshIntervalSeconds: number + filterTestAccounts: boolean + baseConfig: LiveActivityWidgetConfig +}): + | { success: true; config: LiveActivityWidgetConfig } + | { success: false; fieldErrors: LiveActivityWidgetFieldErrors } { + const parsed = liveActivityWidgetFormSchema.safeParse({ + limit: input.limit, + refreshIntervalSeconds: input.refreshIntervalSeconds, + filterTestAccounts: input.filterTestAccounts, + }) + + if (!parsed.success) { + return { success: false, fieldErrors: fieldErrorsFromZodError(parsed.error) } + } + + return { + success: true, + config: buildLiveActivityWidgetConfig( + { + limit: parsed.data.limit, + refreshIntervalSeconds: parsed.data.refreshIntervalSeconds, + filterTestAccounts: parsed.data.filterTestAccounts ?? null, + }, + input.baseConfig + ), + } +} + +export function parseLiveActivityWidgetConfigApiError( + error: unknown, + config: Record +): LiveActivityWidgetFieldErrors | null { + if (!(error instanceof ApiError)) { + return null + } + + const parsedConfig = liveActivityWidgetConfigSchema.safeParse(config) + if (parsedConfig.success) { + return null + } + + const parsedForm = liveActivityWidgetFormSchema.safeParse({ + limit: (config.limit as number) ?? liveActivityConfigDefaults.limit ?? 0, + refreshIntervalSeconds: + (config.refreshIntervalSeconds as number) ?? liveActivityConfigDefaults.refreshIntervalSeconds ?? 0, + filterTestAccounts: (config.filterTestAccounts as boolean) ?? false, + }) + if (!parsedForm.success) { + return fieldErrorsFromZodError(parsedForm.error) + } + + return fieldErrorsFromZodError(parsedConfig.error) +} diff --git a/products/dashboards/frontend/widgets/activity/liveActivityWidgetLogic.ts b/products/dashboards/frontend/widgets/activity/liveActivityWidgetLogic.ts new file mode 100644 index 000000000000..859ec6b31b03 --- /dev/null +++ b/products/dashboards/frontend/widgets/activity/liveActivityWidgetLogic.ts @@ -0,0 +1,101 @@ +import { actions, afterMount, kea, key, listeners, path, props, propsChanged, reducers } from 'kea' + +import type { liveActivityWidgetLogicType } from './liveActivityWidgetLogicType' + +export type LiveActivityWidgetLogicProps = { + tileId: number + refreshIntervalSeconds: number + loading?: boolean + onRefresh?: () => void + onRefreshData?: () => void +} + +const LIVE_ACTIVITY_AUTO_REFRESH_KEY = 'liveActivityAutoRefresh' +const MIN_LIVE_ACTIVITY_REFRESH_INTERVAL_SECONDS = 15 + +function triggerScheduledRefresh(cache: Record): void { + const refresh = (cache.onRefreshData ?? cache.onRefresh) as (() => void) | undefined + refresh?.() +} + +function triggerManualRefresh(cache: Record): void { + const refresh = (cache.onRefresh ?? cache.onRefreshData) as (() => void) | undefined + refresh?.() +} + +export const liveActivityWidgetLogic = kea([ + props({} as LiveActivityWidgetLogicProps), + key((props) => props.tileId), + path((key) => ['products', 'dashboards', 'widgets', 'activity', 'liveActivityWidgetLogic', key]), + + actions({ + startAutoRefresh: true, + pauseAutoRefresh: true, + resumeAutoRefresh: true, + refreshFromTimer: true, + manualRefresh: true, + }), + + reducers({ + isPaused: [ + false, + { + pauseAutoRefresh: () => true, + resumeAutoRefresh: () => false, + }, + ], + }), + + listeners(({ actions, values, props, cache }) => ({ + startAutoRefresh: () => { + cache.disposables.dispose(LIVE_ACTIVITY_AUTO_REFRESH_KEY) + cache.onRefresh = props.onRefresh + cache.onRefreshData = props.onRefreshData + + if (values.isPaused || (!props.onRefresh && !props.onRefreshData)) { + return + } + + const intervalMs = Math.max(props.refreshIntervalSeconds, MIN_LIVE_ACTIVITY_REFRESH_INTERVAL_SECONDS) * 1000 + + cache.disposables.add(() => { + const intervalId = setInterval(() => actions.refreshFromTimer(), intervalMs) + return () => clearInterval(intervalId) + }, LIVE_ACTIVITY_AUTO_REFRESH_KEY) + }, + pauseAutoRefresh: () => { + cache.disposables.dispose(LIVE_ACTIVITY_AUTO_REFRESH_KEY) + }, + resumeAutoRefresh: () => { + actions.startAutoRefresh() + if (!props.loading) { + triggerScheduledRefresh(cache) + } + }, + refreshFromTimer: () => { + if (!values.isPaused && !props.loading) { + triggerScheduledRefresh(cache) + } + }, + manualRefresh: () => { + if (!props.loading) { + triggerManualRefresh(cache) + } + }, + })), + + afterMount(({ actions, props, cache }) => { + cache.onRefresh = props.onRefresh + cache.onRefreshData = props.onRefreshData + actions.startAutoRefresh() + }), + + propsChanged(({ actions, props, cache }, oldProps) => { + cache.onRefresh = props.onRefresh + cache.onRefreshData = props.onRefreshData + + if (props.refreshIntervalSeconds !== oldProps.refreshIntervalSeconds) { + actions.startAutoRefresh() + } + }), +]) diff --git a/products/dashboards/frontend/widgets/previews/LiveActivityWidgetPreview.tsx b/products/dashboards/frontend/widgets/previews/LiveActivityWidgetPreview.tsx new file mode 100644 index 000000000000..1b525e0cf2f5 --- /dev/null +++ b/products/dashboards/frontend/widgets/previews/LiveActivityWidgetPreview.tsx @@ -0,0 +1,22 @@ +import { liveActivitySampleResult } from '../activity/liveActivitySampleData' +import { LiveActivityPulse } from '../activity/LiveActivityWidget' + +export function LiveActivityWidgetPreview(): JSX.Element { + return ( +
+
+ {liveActivitySampleResult.activeUsers} + active users +
+ +
+ {liveActivitySampleResult.events?.slice(0, 3).map((event) => ( +
+ {event.surface} + {event.event} +
+ ))} +
+
+ ) +} diff --git a/products/dashboards/frontend/widgets/registry.test.tsx b/products/dashboards/frontend/widgets/registry.test.tsx index 88268477255a..ec6b09f3cd8f 100644 --- a/products/dashboards/frontend/widgets/registry.test.tsx +++ b/products/dashboards/frontend/widgets/registry.test.tsx @@ -2,7 +2,12 @@ import posthog from 'posthog-js' import { ApiError } from 'lib/api-error' -import { DASHBOARD_WIDGET_CATALOG, type DashboardWidgetCatalogEntry } from '../widget_types/catalog' +import { + DASHBOARD_WIDGET_CATALOG, + DASHBOARD_WIDGET_PREVIEWS, + getDashboardWidgetCatalogEntry, + type DashboardWidgetCatalogEntry, +} from '../widget_types/catalog' import { getDashboardWidgetDefinition, parseDashboardWidgetConfigApiError, @@ -34,9 +39,31 @@ describe('dashboard widget registry', () => { expect(definition?.TileFilters).toBeTruthy() expect(catalogEntry.tileFilters.allowedPropertyNames.length).toBeGreaterThan(0) } + expect(definition?.parseConfigApiError).toBeTruthy() expect(posthog.captureException).not.toHaveBeenCalled() }) + it('registers every catalog preview', () => { + expect(Object.keys(DASHBOARD_WIDGET_PREVIEWS).sort()).toEqual(Object.keys(DASHBOARD_WIDGET_CATALOG).sort()) + expect(DASHBOARD_WIDGET_PREVIEWS.live_activity()).toBeTruthy() + }) + + it('documents live activity catalog defaults and shared placeholder copy', () => { + const entry = getDashboardWidgetCatalogEntry('live_activity') + + expect(entry.groupId).toBe('activity') + expect(entry.defaultLayout).toEqual({ w: 4, h: 4, minW: 3, minH: 3 }) + expect(entry.headerMeta.showDateRange).toBe(false) + expect(entry.defaultConfig).toEqual({ + limit: 5, + refreshIntervalSeconds: 15, + }) + expect(entry.sharedPlaceholder).toEqual({ + title: 'Live activity', + message: 'Log in to PostHog to see live activity from this dashboard.', + }) + }) + it('delegates config api error parsing to the widget registry entry', () => { const error = new ApiError('limit must be an integer between 1 and 25.', 400, undefined, { config: 'limit must be an integer between 1 and 25.', diff --git a/products/dashboards/frontend/widgets/registry.tsx b/products/dashboards/frontend/widgets/registry.tsx index 594729444fac..6d18e3ce0524 100644 --- a/products/dashboards/frontend/widgets/registry.tsx +++ b/products/dashboards/frontend/widgets/registry.tsx @@ -16,6 +16,9 @@ import { ActivityEventsWidget } from './activity/ActivityEventsWidget' import { parseActivityEventsWidgetConfigApiError } from './activity/activityEventsWidgetConfigValidation' import { ActivityEventsWidgetTileFilters } from './activity/ActivityEventsWidgetTileFilters' import { EditActivityEventsWidgetModal } from './activity/EditActivityEventsWidgetModal' +import { EditLiveActivityWidgetModal } from './activity/EditLiveActivityWidgetModal' +import { LiveActivityWidget } from './activity/LiveActivityWidget' +import { parseLiveActivityWidgetConfigApiError } from './activity/liveActivityWidgetConfigValidation' import type { WidgetIssueMetadataContext, WidgetIssueMetadataDelta, @@ -135,6 +138,11 @@ export const DASHBOARD_WIDGET_REGISTRY = { EditModal: EditActivityEventsWidgetModal, parseConfigApiError: parseActivityEventsWidgetConfigApiError, }, + live_activity: { + Component: LiveActivityWidget, + EditModal: EditLiveActivityWidgetModal, + parseConfigApiError: parseLiveActivityWidgetConfigApiError, + }, error_tracking_list: { Component: ErrorTrackingWidget, TileFilters: ErrorTrackingWidgetTileFilters, diff --git a/products/dashboards/frontend/widgets/widgetConfigSchemaParity.test.ts b/products/dashboards/frontend/widgets/widgetConfigSchemaParity.test.ts index 0a7991b3d559..64a484b8616e 100644 --- a/products/dashboards/frontend/widgets/widgetConfigSchemaParity.test.ts +++ b/products/dashboards/frontend/widgets/widgetConfigSchemaParity.test.ts @@ -3,6 +3,7 @@ import configPropertyMetadata from '../generated/widget-config-property-keys.jso import { activityEventsWidgetConfigSchema, errorTrackingWidgetConfigSchema, + liveActivityWidgetConfigSchema, sessionReplayWidgetConfigSchema, widgetFilterEntrySchema, } from '../generated/widget-configs.zod' @@ -10,6 +11,7 @@ import { const WIDGET_CONFIG_SCHEMAS = { activity_events_list: activityEventsWidgetConfigSchema, error_tracking_list: errorTrackingWidgetConfigSchema, + live_activity: liveActivityWidgetConfigSchema, session_replay_list: sessionReplayWidgetConfigSchema, } as const diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index d47789fe0433..2a216f567cee 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -4022,6 +4022,48 @@ export namespace Schemas { config: ErrorTrackingListWidgetConfig; } + export type LiveActivityWidgetAddRequestOpenApiWidgetType = typeof LiveActivityWidgetAddRequestOpenApiWidgetType[keyof typeof LiveActivityWidgetAddRequestOpenApiWidgetType]; + + + export const LiveActivityWidgetAddRequestOpenApiWidgetType = { + LiveActivity: 'live_activity', + } as const; + + export interface LiveActivityWidgetConfig { + /** + * Maximum number of recent events to return. + * @minimum 1 + * @maximum 10 + */ + limit?: number; + /** Whether to exclude test accounts. When omitted, the project default is used. */ + filterTestAccounts?: boolean | null; + /** + * Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden. + * @minimum 15 + * @maximum 60 + */ + refreshIntervalSeconds?: number; + } + + export interface LiveActivityWidgetAddRequestOpenApi { + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null; + /** Optional markdown description shown when show_description is enabled. */ + description?: string; + /** Optional react-grid-layout positions keyed by breakpoint (sm, xs). */ + layouts?: _WidgetTileLayoutsOpenApi; + /** Whether to show the description on the dashboard tile. */ + show_description?: boolean; + widget_type: LiveActivityWidgetAddRequestOpenApiWidgetType; + /** Configuration for the live activity widget. */ + config: LiveActivityWidgetConfig; + } + export type SessionReplayListWidgetAddRequestOpenApiWidgetType = typeof SessionReplayListWidgetAddRequestOpenApiWidgetType[keyof typeof SessionReplayListWidgetAddRequestOpenApiWidgetType]; @@ -4093,14 +4135,14 @@ export namespace Schemas { config: SessionReplayListWidgetConfig; } - export type AddDashboardWidgetRequest = ActivityEventsListWidgetAddRequestOpenApi | ErrorTrackingListWidgetAddRequestOpenApi | SessionReplayListWidgetAddRequestOpenApi; + export type AddDashboardWidgetRequest = ActivityEventsListWidgetAddRequestOpenApi | ErrorTrackingListWidgetAddRequestOpenApi | LiveActivityWidgetAddRequestOpenApi | SessionReplayListWidgetAddRequestOpenApi; /** * OpenAPI-only batch-add schema with widget_type-discriminated config shapes for agents. */ export interface AddDashboardWidgetsBatchRequestOpenApi { /** - * Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request). + * Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, live_activity, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request). * @minItems 1 * @maxItems 10 */ @@ -7083,7 +7125,7 @@ export namespace Schemas { team: number; } - export type DashboardWidgetConfig = ActivityEventsListWidgetConfig | ErrorTrackingListWidgetConfig | SessionReplayListWidgetConfig; + export type DashboardWidgetConfig = ActivityEventsListWidgetConfig | ErrorTrackingListWidgetConfig | LiveActivityWidgetConfig | SessionReplayListWidgetConfig; export interface DashboardWidget { readonly id: string; @@ -13739,6 +13781,7 @@ export namespace Schemas { /** * * `activity_events_list` - activity_events_list * * `error_tracking_list` - error_tracking_list + * * `live_activity` - live_activity * * `session_replay_list` - session_replay_list */ export type DashboardPatchWidgetOpenApiWidgetTypeEnum = typeof DashboardPatchWidgetOpenApiWidgetTypeEnum[keyof typeof DashboardPatchWidgetOpenApiWidgetTypeEnum]; @@ -13747,6 +13790,7 @@ export namespace Schemas { export const DashboardPatchWidgetOpenApiWidgetTypeEnum = { ActivityEventsList: 'activity_events_list', ErrorTrackingList: 'error_tracking_list', + LiveActivity: 'live_activity', SessionReplayList: 'session_replay_list', } as const; @@ -13757,6 +13801,7 @@ export namespace Schemas { * * * `activity_events_list` - activity_events_list * * `error_tracking_list` - error_tracking_list + * * `live_activity` - live_activity * * `session_replay_list` - session_replay_list */ widget_type?: DashboardPatchWidgetOpenApiWidgetTypeEnum; /** Widget-specific configuration. Shape depends on the tile's widget_type. */ @@ -25723,6 +25768,35 @@ export namespace Schemas { status: BatchExportRunStatusEnum; } + export type LiveActivityWidgetCatalogEntryOpenApiWidgetType = typeof LiveActivityWidgetCatalogEntryOpenApiWidgetType[keyof typeof LiveActivityWidgetCatalogEntryOpenApiWidgetType]; + + + export const LiveActivityWidgetCatalogEntryOpenApiWidgetType = { + LiveActivity: 'live_activity', + } as const; + + export interface LiveActivityWidgetCatalogEntryOpenApi { + widget_type: LiveActivityWidgetCatalogEntryOpenApiWidgetType; + group_id: string; + group_label: string; + label: string; + description: string; + /** OpenAPI config shape for this widget type (documentation; matches batch-add/PATCH schemas). */ + readonly config_schema: LiveActivityWidgetConfig; + /** @nullable */ + required_product_access?: string | null; + } + + /** + * * `live_activity` - live_activity + */ + export type LiveActivityWidgetTypeEnum = typeof LiveActivityWidgetTypeEnum[keyof typeof LiveActivityWidgetTypeEnum]; + + + export const LiveActivityWidgetTypeEnum = { + LiveActivity: 'live_activity', + } as const; + export interface LiveDebuggerBreakpoint { readonly id: string; /** @nullable */ @@ -47717,7 +47791,7 @@ export namespace Schemas { is_organization_first_user: boolean; } - export type WidgetCatalogEntry = ActivityEventsListWidgetCatalogEntryOpenApi | ErrorTrackingListWidgetCatalogEntryOpenApi | SessionReplayListWidgetCatalogEntryOpenApi; + export type WidgetCatalogEntry = ActivityEventsListWidgetCatalogEntryOpenApi | ErrorTrackingListWidgetCatalogEntryOpenApi | LiveActivityWidgetCatalogEntryOpenApi | SessionReplayListWidgetCatalogEntryOpenApi; export interface WidgetCatalogResponse { /** Registered dashboard widget types available when dashboard-widgets is enabled. */ diff --git a/services/mcp/src/generated/dashboards/api.ts b/services/mcp/src/generated/dashboards/api.ts index 6b7179b9ca50..9b266c828c93 100644 --- a/services/mcp/src/generated/dashboards/api.ts +++ b/services/mcp/src/generated/dashboards/api.ts @@ -123,12 +123,18 @@ export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitMax = export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderByDefault = `occurrences` export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoStatusDefault = `active` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault = 5 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax = 25 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsDefault = 15 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMin = 15 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMax = 60 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeOrderByDefault = `start_time` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeOrderDirectionDefault = `DESC` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitDefault = 10 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitMax = 25 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderByDefault = `start_time` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneNameMax = 400 export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod @@ -155,13 +161,18 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .optional() .describe('Existing widget row ID when updating a widget tile via dashboard PATCH.'), widget_type: zod - .enum(['activity_events_list', 'error_tracking_list', 'session_replay_list']) + .enum([ + 'activity_events_list', + 'error_tracking_list', + 'live_activity', + 'session_replay_list', + ]) .describe( - '* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `session_replay_list` - session_replay_list' + '* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `live_activity` - live_activity\n* `session_replay_list` - session_replay_list' ) .optional() .describe( - 'Widget type identifier (cannot be changed on update).\n\n* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `session_replay_list` - session_replay_list' + 'Widget type identifier (cannot be changed on update).\n\n* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `live_activity` - live_activity\n* `session_replay_list` - session_replay_list' ), config: zod .union([ @@ -370,6 +381,36 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'Filter by assignee ({type: user|role, id}). Omit for any assignee.' ), }), + zod.object({ + limit: zod + .number() + .min(1) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax) + .default( + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault + ) + .describe('Maximum number of recent events to return.'), + filterTestAccounts: zod + .union([zod.boolean(), zod.null()]) + .optional() + .describe( + 'Whether to exclude test accounts. When omitted, the project default is used.' + ), + refreshIntervalSeconds: zod + .number() + .min( + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMin + ) + .max( + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMax + ) + .default( + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsDefault + ) + .describe( + 'Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.' + ), + }), zod.object({ dateRange: zod .union([ @@ -448,9 +489,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitDefault ) .describe('Maximum number of recordings to return.'), orderBy: zod @@ -463,13 +504,13 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'console_error_count', ]) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeOrderByDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderByDefault ) .describe('Recording ranking column.'), orderDirection: zod .enum(['ASC', 'DESC']) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeOrderDirectionDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderDirectionDefault ) .describe('Sort direction for orderBy.'), savedFilterId: zod @@ -831,11 +872,20 @@ export const dashboardsWidgetsBatchCreateBodyWidgetsItemTwoConfigOneOrderDirecti export const dashboardsWidgetsBatchCreateBodyWidgetsItemTwoConfigOneStatusDefault = `active` export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeNameMax = 400 -export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitDefault = 10 -export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitMax = 25 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitDefault = 5 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitMax = 10 + +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsDefault = 15 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsMin = 15 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsMax = 60 + +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourNameMax = 400 + +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneLimitDefault = 10 +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneLimitMax = 25 -export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneOrderByDefault = `start_time` -export const dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneOrderDirectionDefault = `DESC` +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneOrderByDefault = `start_time` +export const dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneOrderDirectionDefault = `DESC` export const dashboardsWidgetsBatchCreateBodyWidgetsMax = 10 export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod @@ -1194,6 +1244,95 @@ export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod .boolean() .optional() .describe('Whether to show the description on the dashboard tile.'), + widget_type: zod.enum(['live_activity']), + config: zod + .object({ + limit: zod + .number() + .min(1) + .max(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitMax) + .default(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitDefault) + .describe('Maximum number of recent events to return.'), + filterTestAccounts: zod + .union([zod.boolean(), zod.null()]) + .optional() + .describe( + 'Whether to exclude test accounts. When omitted, the project default is used.' + ), + refreshIntervalSeconds: zod + .number() + .min( + dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsMin + ) + .max( + dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsMax + ) + .default( + dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneRefreshIntervalSecondsDefault + ) + .describe( + 'Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.' + ), + }) + .describe('Configuration for the live activity widget.'), + }), + zod.object({ + name: zod + .string() + .max(dashboardsWidgetsBatchCreateBodyWidgetsItemFourNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + layouts: zod + .object({ + sm: zod + .object({ + x: zod + .number() + .optional() + .describe('Column position in the dashboard grid (0-indexed).'), + y: zod + .number() + .optional() + .describe('Row position in the dashboard grid (0-indexed).'), + w: zod + .number() + .optional() + .describe('Width in grid columns. The desktop grid is 12 columns wide.'), + h: zod.number().optional().describe('Height in grid rows.'), + }) + .optional() + .describe( + 'Layout for the standard (desktop) breakpoint. The grid is 12 columns wide.' + ), + xs: zod + .object({ + x: zod + .number() + .optional() + .describe('Column position in the dashboard grid (0-indexed).'), + y: zod + .number() + .optional() + .describe('Row position in the dashboard grid (0-indexed).'), + w: zod + .number() + .optional() + .describe('Width in grid columns. The desktop grid is 12 columns wide.'), + h: zod.number().optional().describe('Height in grid rows.'), + }) + .optional() + .describe('Layout for the small (mobile) breakpoint. The grid is 1 column wide.'), + }) + .optional() + .describe('Optional react-grid-layout positions keyed by breakpoint (sm, xs).'), + show_description: zod + .boolean() + .optional() + .describe('Whether to show the description on the dashboard tile.'), widget_type: zod.enum(['session_replay_list']), config: zod .object({ @@ -1266,8 +1405,8 @@ export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitMax) - .default(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneLimitDefault) + .max(dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneLimitMax) + .default(dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneLimitDefault) .describe('Maximum number of recordings to return.'), orderBy: zod .enum([ @@ -1278,12 +1417,12 @@ export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod 'click_count', 'console_error_count', ]) - .default(dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneOrderByDefault) + .default(dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneOrderByDefault) .describe('Recording ranking column.'), orderDirection: zod .enum(['ASC', 'DESC']) .default( - dashboardsWidgetsBatchCreateBodyWidgetsItemThreeConfigOneOrderDirectionDefault + dashboardsWidgetsBatchCreateBodyWidgetsItemFourConfigOneOrderDirectionDefault ) .describe('Sort direction for orderBy.'), savedFilterId: zod @@ -1300,7 +1439,7 @@ export const DashboardsWidgetsBatchCreateBody = /* @__PURE__ */ zod .min(1) .max(dashboardsWidgetsBatchCreateBodyWidgetsMax) .describe( - 'Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request).' + 'Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, live_activity, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request).' ), }) .describe('OpenAPI-only batch-add schema with widget_type-discriminated config shapes for agents.') diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json index f595e4792f12..f28abb06ebe6 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json @@ -426,6 +426,36 @@ }, "type": "object" }, + { + "properties": { + "filterTestAccounts": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to exclude test accounts. When omitted, the project default is used." + }, + "limit": { + "default": 5, + "description": "Maximum number of recent events to return.", + "maximum": 10, + "minimum": 1, + "type": "number" + }, + "refreshIntervalSeconds": { + "default": 15, + "description": "Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.", + "maximum": 60, + "minimum": 15, + "type": "number" + } + }, + "type": "object" + }, { "properties": { "dateRange": { @@ -624,8 +654,13 @@ "description": "Optional custom display name for the widget tile." }, "widget_type": { - "description": "Widget type identifier (cannot be changed on update).\n\n* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `session_replay_list` - session_replay_list", - "enum": ["activity_events_list", "error_tracking_list", "session_replay_list"], + "description": "Widget type identifier (cannot be changed on update).\n\n* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `live_activity` - live_activity\n* `session_replay_list` - session_replay_list", + "enum": [ + "activity_events_list", + "error_tracking_list", + "live_activity", + "session_replay_list" + ], "type": "string" } }, diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-widgets-batch-add.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-widgets-batch-add.json index e228d4987b6b..b717e16ba05b 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-widgets-batch-add.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-widgets-batch-add.json @@ -6,7 +6,7 @@ "type": "number" }, "widgets": { - "description": "Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request).", + "description": "Widget tiles to add atomically. Supported widget_type values: activity_events_list, error_tracking_list, live_activity, session_replay_list. Use dashboard-widget-catalog-list for per-type config_schema documentation. (1–10 per request).", "items": { "anyOf": [ { @@ -502,6 +502,117 @@ "required": ["widget_type", "config"], "type": "object" }, + { + "properties": { + "config": { + "description": "Configuration for the live activity widget.", + "properties": { + "filterTestAccounts": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to exclude test accounts. When omitted, the project default is used." + }, + "limit": { + "default": 5, + "description": "Maximum number of recent events to return.", + "maximum": 10, + "minimum": 1, + "type": "number" + }, + "refreshIntervalSeconds": { + "default": 15, + "description": "Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.", + "maximum": 60, + "minimum": 15, + "type": "number" + } + }, + "type": "object" + }, + "description": { + "description": "Optional markdown description shown when show_description is enabled.", + "type": "string" + }, + "layouts": { + "description": "Optional react-grid-layout positions keyed by breakpoint (sm, xs).", + "properties": { + "sm": { + "description": "Layout for the standard (desktop) breakpoint. The grid is 12 columns wide.", + "properties": { + "h": { + "description": "Height in grid rows.", + "type": "number" + }, + "w": { + "description": "Width in grid columns. The desktop grid is 12 columns wide.", + "type": "number" + }, + "x": { + "description": "Column position in the dashboard grid (0-indexed).", + "type": "number" + }, + "y": { + "description": "Row position in the dashboard grid (0-indexed).", + "type": "number" + } + }, + "type": "object" + }, + "xs": { + "description": "Layout for the small (mobile) breakpoint. The grid is 1 column wide.", + "properties": { + "h": { + "description": "Height in grid rows.", + "type": "number" + }, + "w": { + "description": "Width in grid columns. The desktop grid is 12 columns wide.", + "type": "number" + }, + "x": { + "description": "Column position in the dashboard grid (0-indexed).", + "type": "number" + }, + "y": { + "description": "Row position in the dashboard grid (0-indexed).", + "type": "number" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional custom display name for the widget tile." + }, + "show_description": { + "description": "Whether to show the description on the dashboard tile.", + "type": "boolean" + }, + "widget_type": { + "enum": ["live_activity"], + "type": "string" + } + }, + "required": ["widget_type", "config"], + "type": "object" + }, { "properties": { "config": { From b54901747c3054e89e236e2f2f24a4794ac0dc42 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:25:36 +0000 Subject: [PATCH 2/5] fix(dashboards): address live activity widget review findings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../backend/widget_specs/openapi.py | 53 ++++- .../backend/widgets/live_activity.py | 19 +- .../widgets/test/test_live_activity_widget.py | 2 +- .../frontend/generated/api.schemas.ts | 120 +++++++--- .../dashboards/frontend/generated/api.zod.ts | 210 +++++++++++------- ...liveActivityWidgetConfigValidation.test.ts | 31 +++ .../liveActivityWidgetConfigValidation.ts | 23 +- services/mcp/src/api/generated.ts | 98 ++++++-- services/mcp/src/generated/dashboards/api.ts | 210 +++++++++++------- .../tool-schemas/dashboard-update.json | 168 ++++++++++---- .../unit/dashboard-update-schema.test.ts | 44 +++- 11 files changed, 722 insertions(+), 256 deletions(-) diff --git a/products/dashboards/backend/widget_specs/openapi.py b/products/dashboards/backend/widget_specs/openapi.py index 7c6c41aefe19..5bae951d7f94 100644 --- a/products/dashboards/backend/widget_specs/openapi.py +++ b/products/dashboards/backend/widget_specs/openapi.py @@ -79,9 +79,11 @@ def _build_openapi_serializers() -> tuple[ dict[str, type[serializers.Serializer]], dict[str, type[serializers.Serializer]], dict[str, type[serializers.Serializer]], + dict[str, type[serializers.Serializer]], ]: config_serializers: dict[str, type[serializers.Serializer]] = {} add_request_serializers: dict[str, type[serializers.Serializer]] = {} + patch_request_serializers: dict[str, type[serializers.Serializer]] = {} catalog_entry_serializers: dict[str, type[serializers.Serializer]] = {} for widget_type, spec in WIDGET_SPECS.items(): @@ -100,6 +102,36 @@ def _build_openapi_serializers() -> tuple[ ), }, ) + patch_request_serializers[widget_type] = type( + f"{prefix}PatchRequestOpenApiSerializer", + (serializers.Serializer,), + { + "id": serializers.UUIDField( + required=False, + help_text="Existing widget row ID when updating a widget tile via dashboard PATCH.", + ), + "widget_type": serializers.ChoiceField( + choices=[widget_type], + ), + "config": pydantic_config_field( + spec.config_model, + required=False, + help_text=f"Configuration patch for the {spec.label.lower()} widget.", + ), + "name": serializers.CharField( + max_length=400, + required=False, + allow_null=True, + allow_blank=True, + help_text="Optional custom display name for the widget tile.", + ), + "description": serializers.CharField( + required=False, + allow_blank=True, + help_text="Optional markdown description shown when show_description is enabled.", + ), + }, + ) catalog_entry_serializers[widget_type] = type( f"{prefix}CatalogEntryOpenApiSerializer", (serializers.Serializer,), @@ -120,12 +152,15 @@ def _build_openapi_serializers() -> tuple[ }, ) - return config_serializers, add_request_serializers, catalog_entry_serializers + return config_serializers, add_request_serializers, patch_request_serializers, catalog_entry_serializers -WIDGET_CONFIG_SERIALIZERS, _WIDGET_ADD_REQUEST_SERIALIZERS, _WIDGET_CATALOG_ENTRY_SERIALIZERS = ( - _build_openapi_serializers() -) +( + WIDGET_CONFIG_SERIALIZERS, + _WIDGET_ADD_REQUEST_SERIALIZERS, + _WIDGET_PATCH_REQUEST_SERIALIZERS, + _WIDGET_CATALOG_ENTRY_SERIALIZERS, +) = _build_openapi_serializers() DashboardWidgetConfigOpenApi = PolymorphicProxySerializer( component_name="DashboardWidgetConfig", @@ -147,6 +182,14 @@ class DashboardWidgetConfigField(serializers.JSONField): resource_type_field_name="widget_type", ) +DashboardPatchWidgetRequestOpenApi = PolymorphicProxySerializer( + component_name="DashboardPatchWidgetRequest", + serializers=cast("dict[str, Any]", _WIDGET_PATCH_REQUEST_SERIALIZERS), + resource_type_field_name="widget_type", + required=False, + help_text="Nested widget row updates.", +) + WidgetCatalogEntryOpenApi = PolymorphicProxySerializer( component_name="WidgetCatalogEntry", serializers=cast("dict[str, Any]", _WIDGET_CATALOG_ENTRY_SERIALIZERS), @@ -191,7 +234,7 @@ class DashboardPatchWidgetOpenApiSerializer(serializers.Serializer): class DashboardPatchTileOpenApiSerializer(serializers.Serializer): id = serializers.IntegerField(required=False, help_text="Dashboard tile ID to update.") - widget = DashboardPatchWidgetOpenApiSerializer(required=False, help_text="Nested widget row updates.") + widget = DashboardPatchWidgetRequestOpenApi class PatchedDashboardOpenApiSerializer(serializers.Serializer): diff --git a/products/dashboards/backend/widgets/live_activity.py b/products/dashboards/backend/widgets/live_activity.py index 87329b72ba87..ce3298f2a8b8 100644 --- a/products/dashboards/backend/widgets/live_activity.py +++ b/products/dashboards/backend/widgets/live_activity.py @@ -215,6 +215,22 @@ def _active_users_lower_bound(events: list[dict[str, Any]]) -> int: return len(distinct_ids) +def _latest_event_timestamp_lower_bound(current: str | None, events: list[dict[str, Any]]) -> str | None: + if not events: + return current + + feed_timestamp = events[0]["timestamp"] + parsed_feed_timestamp = _parse_serialized_timestamp(feed_timestamp) + if parsed_feed_timestamp is None: + return current + + parsed_current = _parse_serialized_timestamp(current) + if parsed_current is None or parsed_feed_timestamp > parsed_current: + return feed_timestamp + + return current + + def _peak_events_per_minute(pulse: list[dict[str, Any]]) -> int: buckets_per_minute = 60 // LIVE_ACTIVITY_BUCKET_SECONDS counts = [int(bucket["count"]) for bucket in pulse] @@ -274,8 +290,7 @@ def run_live_activity_widget( events = [_feed_row_to_event(row) for row in feed_rows] events_in_window = max(events_in_window, len(events)) active_users = max(active_users, _active_users_lower_bound(events)) - if latest_event_timestamp is None and events: - latest_event_timestamp = events[0]["timestamp"] + latest_event_timestamp = _latest_event_timestamp_lower_bound(latest_event_timestamp, events) pulse = _build_pulse(pulse_rows, window_start) _apply_feed_lower_bounds_to_pulse(pulse, events, window_start) diff --git a/products/dashboards/backend/widgets/test/test_live_activity_widget.py b/products/dashboards/backend/widgets/test/test_live_activity_widget.py index 2fd21e2b4159..cc0940cf6e5b 100644 --- a/products/dashboards/backend/widgets/test/test_live_activity_widget.py +++ b/products/dashboards/backend/widgets/test/test_live_activity_widget.py @@ -150,7 +150,7 @@ def test_feed_rows_provide_lower_bounds_when_aggregate_queries_lag(self, mock_ru def runner_side_effect(*_args: object, **kwargs: object) -> MagicMock: query = kwargs["query"] if "events_in_window" in query.query: - results = [[0, 0, datetime(1970, 1, 1, tzinfo=UTC)]] + results = [[1, 1, datetime(2026, 6, 17, 11, 59, 30, tzinfo=UTC)]] elif "bucket_index" in query.query: results = [] else: diff --git a/products/dashboards/frontend/generated/api.schemas.ts b/products/dashboards/frontend/generated/api.schemas.ts index aed9f225cc26..ea022978ccba 100644 --- a/products/dashboards/frontend/generated/api.schemas.ts +++ b/products/dashboards/frontend/generated/api.schemas.ts @@ -364,20 +364,11 @@ export interface DashboardCollaboratorApi { user_uuid: string } -/** - * * `activity_events_list` - activity_events_list - * * `error_tracking_list` - error_tracking_list - * * `live_activity` - live_activity - * * `session_replay_list` - session_replay_list - */ -export type DashboardPatchWidgetOpenApiWidgetTypeEnumApi = - (typeof DashboardPatchWidgetOpenApiWidgetTypeEnumApi)[keyof typeof DashboardPatchWidgetOpenApiWidgetTypeEnumApi] +export type ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType = + (typeof ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType)[keyof typeof ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType] -export const DashboardPatchWidgetOpenApiWidgetTypeEnumApi = { +export const ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType = { ActivityEventsList: 'activity_events_list', - ErrorTrackingList: 'error_tracking_list', - LiveActivity: 'live_activity', - SessionReplayList: 'session_replay_list', } as const export type WidgetDateRangeApiDateFrom = @@ -462,6 +453,29 @@ export interface ActivityEventsListWidgetConfigApi { limit?: number } +export interface ActivityEventsListWidgetPatchRequestOpenApiApi { + /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ + id?: string + widget_type: ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType + /** Configuration patch for the recent events widget. */ + config?: ActivityEventsListWidgetConfigApi + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null + /** Optional markdown description shown when show_description is enabled. */ + description?: string +} + +export type ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType = + (typeof ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType)[keyof typeof ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType] + +export const ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType = { + ErrorTrackingList: 'error_tracking_list', +} as const + /** * Issue ranking column. */ @@ -536,6 +550,29 @@ export interface ErrorTrackingListWidgetConfigApi { assignee?: WidgetAssigneeFilterApi | null } +export interface ErrorTrackingListWidgetPatchRequestOpenApiApi { + /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ + id?: string + widget_type: ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType + /** Configuration patch for the top issues widget. */ + config?: ErrorTrackingListWidgetConfigApi + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null + /** Optional markdown description shown when show_description is enabled. */ + description?: string +} + +export type LiveActivityWidgetPatchRequestOpenApiApiWidgetType = + (typeof LiveActivityWidgetPatchRequestOpenApiApiWidgetType)[keyof typeof LiveActivityWidgetPatchRequestOpenApiApiWidgetType] + +export const LiveActivityWidgetPatchRequestOpenApiApiWidgetType = { + LiveActivity: 'live_activity', +} as const + export interface LiveActivityWidgetConfigApi { /** * Maximum number of recent events to return. @@ -553,6 +590,29 @@ export interface LiveActivityWidgetConfigApi { refreshIntervalSeconds?: number } +export interface LiveActivityWidgetPatchRequestOpenApiApi { + /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ + id?: string + widget_type: LiveActivityWidgetPatchRequestOpenApiApiWidgetType + /** Configuration patch for the live activity widget. */ + config?: LiveActivityWidgetConfigApi + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null + /** Optional markdown description shown when show_description is enabled. */ + description?: string +} + +export type SessionReplayListWidgetPatchRequestOpenApiApiWidgetType = + (typeof SessionReplayListWidgetPatchRequestOpenApiApiWidgetType)[keyof typeof SessionReplayListWidgetPatchRequestOpenApiApiWidgetType] + +export const SessionReplayListWidgetPatchRequestOpenApiApiWidgetType = { + SessionReplayList: 'session_replay_list', +} as const + /** * Recording ranking column. */ @@ -599,24 +659,12 @@ export interface SessionReplayListWidgetConfigApi { savedFilterId?: string | null } -export type DashboardWidgetConfigApi = - | ActivityEventsListWidgetConfigApi - | ErrorTrackingListWidgetConfigApi - | LiveActivityWidgetConfigApi - | SessionReplayListWidgetConfigApi - -export interface DashboardPatchWidgetOpenApiApi { +export interface SessionReplayListWidgetPatchRequestOpenApiApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string - /** Widget type identifier (cannot be changed on update). - * - * * `activity_events_list` - activity_events_list - * * `error_tracking_list` - error_tracking_list - * * `live_activity` - live_activity - * * `session_replay_list` - session_replay_list */ - widget_type?: DashboardPatchWidgetOpenApiWidgetTypeEnumApi - /** Widget-specific configuration. Shape depends on the tile's widget_type. */ - config?: DashboardWidgetConfigApi + widget_type: SessionReplayListWidgetPatchRequestOpenApiApiWidgetType + /** Configuration patch for the recent recordings widget. */ + config?: SessionReplayListWidgetConfigApi /** * Optional custom display name for the widget tile. * @maxLength 400 @@ -627,11 +675,17 @@ export interface DashboardPatchWidgetOpenApiApi { description?: string } +export type DashboardPatchWidgetRequestApi = + | ActivityEventsListWidgetPatchRequestOpenApiApi + | ErrorTrackingListWidgetPatchRequestOpenApiApi + | LiveActivityWidgetPatchRequestOpenApiApi + | SessionReplayListWidgetPatchRequestOpenApiApi + export interface DashboardPatchTileOpenApiApi { + /** Nested widget row updates. */ + widget?: DashboardPatchWidgetRequestApi /** Dashboard tile ID to update. */ id?: number - /** Nested widget row updates. */ - widget?: DashboardPatchWidgetOpenApiApi } /** @@ -7768,6 +7822,12 @@ export interface ButtonTileApi { team: number } +export type DashboardWidgetConfigApi = + | ActivityEventsListWidgetConfigApi + | ErrorTrackingListWidgetConfigApi + | LiveActivityWidgetConfigApi + | SessionReplayListWidgetConfigApi + export interface DashboardWidgetApi { readonly id: string readonly created_by: UserBasicApi diff --git a/products/dashboards/frontend/generated/api.zod.ts b/products/dashboards/frontend/generated/api.zod.ts index 622fb69da330..088bc34c600d 100644 --- a/products/dashboards/frontend/generated/api.zod.ts +++ b/products/dashboards/frontend/generated/api.zod.ts @@ -210,28 +210,34 @@ export const DashboardsUpdateBody = /* @__PURE__ */ zod export const dashboardsPartialUpdateBodyNameMax = 400 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneLimitDefault = 25 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneLimitMax = 50 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneOneConfigOneLimitDefault = 25 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneOneConfigOneLimitMax = 50 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitDefault = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitMax = 25 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneOneNameMax = 400 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderByDefault = `occurrences` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderDirectionDefault = `DESC` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoStatusDefault = `active` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault = 5 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax = 10 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneLimitDefault = 10 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneLimitMax = 25 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsDefault = 15 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMin = 15 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMax = 60 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneOrderByDefault = `occurrences` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneOrderDirectionDefault = `DESC` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneStatusDefault = `active` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoNameMax = 400 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitDefault = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitMax = 25 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneLimitDefault = 5 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneLimitMax = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderByDefault = `start_time` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderDirectionDefault = `DESC` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneNameMax = 400 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsDefault = 15 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsMin = 15 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsMax = 60 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeNameMax = 400 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneLimitDefault = 10 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneLimitMax = 25 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDefault = `start_time` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault = `DESC` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax = 400 export const dashboardsPartialUpdateBodyDeleteInsightsDefault = false @@ -251,30 +257,18 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod tiles: zod .array( zod.object({ - id: zod.number().optional().describe('Dashboard tile ID to update.'), widget: zod - .object({ - id: zod - .uuid() - .optional() - .describe('Existing widget row ID when updating a widget tile via dashboard PATCH.'), - widget_type: zod - .enum([ - 'activity_events_list', - 'error_tracking_list', - 'live_activity', - 'session_replay_list', - ]) - .describe( - '\* `activity_events_list` - activity_events_list\n\* `error_tracking_list` - error_tracking_list\n\* `live_activity` - live_activity\n\* `session_replay_list` - session_replay_list' - ) - .optional() - .describe( - 'Widget type identifier (cannot be changed on update).\n\n\* `activity_events_list` - activity_events_list\n\* `error_tracking_list` - error_tracking_list\n\* `live_activity` - live_activity\n\* `session_replay_list` - session_replay_list' - ), - config: zod - .union([ - zod.object({ + .union([ + zod.object({ + id: zod + .uuid() + .optional() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + widget_type: zod.enum(['activity_events_list']), + config: zod + .object({ dateRange: zod .union([ zod.object({ @@ -352,13 +346,34 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneOneConfigOneLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneOneConfigOneLimitDefault ) .describe('Maximum number of events to return.'), - }), - zod.object({ + }) + .optional() + .describe('Configuration patch for the recent events widget.'), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneOneNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), + zod.object({ + id: zod + .uuid() + .optional() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + widget_type: zod.enum(['error_tracking_list']), + config: zod + .object({ dateRange: zod .union([ zod.object({ @@ -436,21 +451,21 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneLimitDefault ) .describe('Maximum number of issues to return.'), orderBy: zod .enum(['last_seen', 'first_seen', 'occurrences', 'users', 'sessions']) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderByDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneOrderByDefault ) .describe('Issue ranking column.'), orderDirection: zod .enum(['ASC', 'DESC']) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderDirectionDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneOrderDirectionDefault ) .describe('Sort direction for orderBy.'), status: zod @@ -463,7 +478,7 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'all', ]) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoStatusDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneStatusDefault ) .describe('Issue status filter.'), assignee: zod @@ -478,14 +493,35 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Filter by assignee ({type: user|role, id}). Omit for any assignee.' ), - }), - zod.object({ + }) + .optional() + .describe('Configuration patch for the top issues widget.'), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneTwoNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), + zod.object({ + id: zod + .uuid() + .optional() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + widget_type: zod.enum(['live_activity']), + config: zod + .object({ limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneLimitDefault ) .describe('Maximum number of recent events to return.'), filterTestAccounts: zod @@ -497,19 +533,40 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod refreshIntervalSeconds: zod .number() .min( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMin + dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsMin ) .max( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMax + dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsMax ) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsDefault ) .describe( 'Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.' ), - }), - zod.object({ + }) + .optional() + .describe('Configuration patch for the live activity widget.'), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneThreeNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), + zod.object({ + id: zod + .uuid() + .optional() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + widget_type: zod.enum(['session_replay_list']), + config: zod + .object({ dateRange: zod .union([ zod.object({ @@ -587,9 +644,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneLimitDefault ) .describe('Maximum number of recordings to return.'), orderBy: zod @@ -602,13 +659,13 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'console_error_count', ]) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderByDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDefault ) .describe('Recording ranking column.'), orderDirection: zod .enum(['ASC', 'DESC']) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderDirectionDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault ) .describe('Sort direction for orderBy.'), savedFilterId: zod @@ -617,22 +674,23 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'short_id of a saved session replay filter to use as the recordings source. When set, the saved filter owns the date range and property filters; only orderBy, orderDirection, and limit still apply.' ), - }), - ]) - .optional() - .describe("Widget-specific configuration. Shape depends on the tile's widget_type."), - name: zod - .string() - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneNameMax) - .nullish() - .describe('Optional custom display name for the widget tile.'), - description: zod - .string() - .optional() - .describe('Optional markdown description shown when show_description is enabled.'), - }) + }) + .optional() + .describe('Configuration patch for the recent recordings widget.'), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), + ]) .optional() .describe('Nested widget row updates.'), + id: zod.number().optional().describe('Dashboard tile ID to update.'), }) ) .optional() diff --git a/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.test.ts b/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.test.ts index 376579d8b34d..810740c6f2cd 100644 --- a/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.test.ts +++ b/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.test.ts @@ -44,6 +44,21 @@ describe('liveActivityWidgetConfigValidation', () => { } }) + it('rejects fractional limit and refresh interval settings', () => { + const result = validateLiveActivityWidgetConfigInput({ + limit: 5.5, + refreshIntervalSeconds: 15.5, + filterTestAccounts: true, + baseConfig: liveActivityWidgetConfigSchema.parse({}), + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.fieldErrors.limit).toBe('Expected integer') + expect(result.fieldErrors.refreshIntervalSeconds).toBe('Expected integer') + } + }) + it('accepts valid config', () => { const result = validateLiveActivityWidgetConfigInput({ limit: 8, @@ -78,5 +93,21 @@ describe('liveActivityWidgetConfigValidation', () => { limit: 'Too big: expected number to be <=10', }) }) + + it('maps fractional config values to local field errors', () => { + const error = new ApiError('limit must be an integer.', 400, undefined, { + config: 'limit must be an integer.', + }) + + expect( + parseLiveActivityWidgetConfigApiError(error, { + limit: 5.5, + refreshIntervalSeconds: 15.5, + }) + ).toEqual({ + limit: 'Expected integer', + refreshIntervalSeconds: 'Expected integer', + }) + }) }) }) diff --git a/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.ts b/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.ts index 820a46f34291..550b31a7db7e 100644 --- a/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.ts +++ b/products/dashboards/frontend/widgets/activity/liveActivityWidgetConfigValidation.ts @@ -25,6 +25,21 @@ export type LiveActivityWidgetFormInput = { const liveActivityConfigDefaults = liveActivityWidgetConfigSchema.parse({}) +function addIntegerFieldIssues( + data: Pick, + context: z.RefinementCtx +): void { + if (!Number.isInteger(data.limit)) { + context.addIssue({ code: 'custom', path: ['limit'], message: 'Expected integer' }) + } + if (!Number.isInteger(data.refreshIntervalSeconds)) { + context.addIssue({ code: 'custom', path: ['refreshIntervalSeconds'], message: 'Expected integer' }) + } +} + +const strictLiveActivityWidgetFormSchema = liveActivityWidgetFormSchema.superRefine(addIntegerFieldIssues) +const strictLiveActivityWidgetConfigSchema = liveActivityWidgetConfigSchema.superRefine(addIntegerFieldIssues) + export function parseLiveActivityWidgetConfig(config: Record): LiveActivityWidgetConfig { return parseWidgetConfig(liveActivityWidgetConfigSchema, config) } @@ -33,7 +48,7 @@ export function buildLiveActivityWidgetConfig( formInput: LiveActivityWidgetFormInput, baseConfig: LiveActivityWidgetConfig ): LiveActivityWidgetConfig { - return liveActivityWidgetConfigSchema.parse({ + return strictLiveActivityWidgetConfigSchema.parse({ ...baseConfig, ...formInput, }) @@ -47,7 +62,7 @@ export function validateLiveActivityWidgetConfigInput(input: { }): | { success: true; config: LiveActivityWidgetConfig } | { success: false; fieldErrors: LiveActivityWidgetFieldErrors } { - const parsed = liveActivityWidgetFormSchema.safeParse({ + const parsed = strictLiveActivityWidgetFormSchema.safeParse({ limit: input.limit, refreshIntervalSeconds: input.refreshIntervalSeconds, filterTestAccounts: input.filterTestAccounts, @@ -78,12 +93,12 @@ export function parseLiveActivityWidgetConfigApiError( return null } - const parsedConfig = liveActivityWidgetConfigSchema.safeParse(config) + const parsedConfig = strictLiveActivityWidgetConfigSchema.safeParse(config) if (parsedConfig.success) { return null } - const parsedForm = liveActivityWidgetFormSchema.safeParse({ + const parsedForm = strictLiveActivityWidgetFormSchema.safeParse({ limit: (config.limit as number) ?? liveActivityConfigDefaults.limit ?? 0, refreshIntervalSeconds: (config.refreshIntervalSeconds as number) ?? liveActivityConfigDefaults.refreshIntervalSeconds ?? 0, diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 2a216f567cee..f7bba0198d1d 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -1738,6 +1738,29 @@ export namespace Schemas { required_product_access?: string | null; } + export type ActivityEventsListWidgetPatchRequestOpenApiWidgetType = typeof ActivityEventsListWidgetPatchRequestOpenApiWidgetType[keyof typeof ActivityEventsListWidgetPatchRequestOpenApiWidgetType]; + + + export const ActivityEventsListWidgetPatchRequestOpenApiWidgetType = { + ActivityEventsList: 'activity_events_list', + } as const; + + export interface ActivityEventsListWidgetPatchRequestOpenApi { + /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ + id?: string; + widget_type: ActivityEventsListWidgetPatchRequestOpenApiWidgetType; + /** Configuration patch for the recent events widget. */ + config?: ActivityEventsListWidgetConfig; + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null; + /** Optional markdown description shown when show_description is enabled. */ + description?: string; + } + /** * * `activity_events_list` - activity_events_list */ @@ -13778,34 +13801,65 @@ export namespace Schemas { properties?: (EventPropertyFilter | PersonPropertyFilter | ElementPropertyFilter | EventMetadataPropertyFilter | SessionPropertyFilter | CohortPropertyFilter | RecordingPropertyFilter | LogEntryPropertyFilter | GroupPropertyFilter | FeaturePropertyFilter | FlagPropertyFilter | HogQLPropertyFilter | EmptyPropertyFilter | DataWarehousePropertyFilter | DataWarehousePersonPropertyFilter | ErrorTrackingIssueFilter | LogPropertyFilter | SpanPropertyFilter | RevenueAnalyticsPropertyFilter | WorkflowVariablePropertyFilter)[] | null; } - /** - * * `activity_events_list` - activity_events_list - * * `error_tracking_list` - error_tracking_list - * * `live_activity` - live_activity - * * `session_replay_list` - session_replay_list - */ - export type DashboardPatchWidgetOpenApiWidgetTypeEnum = typeof DashboardPatchWidgetOpenApiWidgetTypeEnum[keyof typeof DashboardPatchWidgetOpenApiWidgetTypeEnum]; + export type ErrorTrackingListWidgetPatchRequestOpenApiWidgetType = typeof ErrorTrackingListWidgetPatchRequestOpenApiWidgetType[keyof typeof ErrorTrackingListWidgetPatchRequestOpenApiWidgetType]; - export const DashboardPatchWidgetOpenApiWidgetTypeEnum = { - ActivityEventsList: 'activity_events_list', + export const ErrorTrackingListWidgetPatchRequestOpenApiWidgetType = { ErrorTrackingList: 'error_tracking_list', + } as const; + + export interface ErrorTrackingListWidgetPatchRequestOpenApi { + /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ + id?: string; + widget_type: ErrorTrackingListWidgetPatchRequestOpenApiWidgetType; + /** Configuration patch for the top issues widget. */ + config?: ErrorTrackingListWidgetConfig; + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null; + /** Optional markdown description shown when show_description is enabled. */ + description?: string; + } + + export type LiveActivityWidgetPatchRequestOpenApiWidgetType = typeof LiveActivityWidgetPatchRequestOpenApiWidgetType[keyof typeof LiveActivityWidgetPatchRequestOpenApiWidgetType]; + + + export const LiveActivityWidgetPatchRequestOpenApiWidgetType = { LiveActivity: 'live_activity', + } as const; + + export interface LiveActivityWidgetPatchRequestOpenApi { + /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ + id?: string; + widget_type: LiveActivityWidgetPatchRequestOpenApiWidgetType; + /** Configuration patch for the live activity widget. */ + config?: LiveActivityWidgetConfig; + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null; + /** Optional markdown description shown when show_description is enabled. */ + description?: string; + } + + export type SessionReplayListWidgetPatchRequestOpenApiWidgetType = typeof SessionReplayListWidgetPatchRequestOpenApiWidgetType[keyof typeof SessionReplayListWidgetPatchRequestOpenApiWidgetType]; + + + export const SessionReplayListWidgetPatchRequestOpenApiWidgetType = { SessionReplayList: 'session_replay_list', } as const; - export interface DashboardPatchWidgetOpenApi { + export interface SessionReplayListWidgetPatchRequestOpenApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string; - /** Widget type identifier (cannot be changed on update). - * - * * `activity_events_list` - activity_events_list - * * `error_tracking_list` - error_tracking_list - * * `live_activity` - live_activity - * * `session_replay_list` - session_replay_list */ - widget_type?: DashboardPatchWidgetOpenApiWidgetTypeEnum; - /** Widget-specific configuration. Shape depends on the tile's widget_type. */ - config?: DashboardWidgetConfig; + widget_type: SessionReplayListWidgetPatchRequestOpenApiWidgetType; + /** Configuration patch for the recent recordings widget. */ + config?: SessionReplayListWidgetConfig; /** * Optional custom display name for the widget tile. * @maxLength 400 @@ -13816,11 +13870,13 @@ export namespace Schemas { description?: string; } + export type DashboardPatchWidgetRequest = ActivityEventsListWidgetPatchRequestOpenApi | ErrorTrackingListWidgetPatchRequestOpenApi | LiveActivityWidgetPatchRequestOpenApi | SessionReplayListWidgetPatchRequestOpenApi; + export interface DashboardPatchTileOpenApi { + /** Nested widget row updates. */ + widget?: DashboardPatchWidgetRequest; /** Dashboard tile ID to update. */ id?: number; - /** Nested widget row updates. */ - widget?: DashboardPatchWidgetOpenApi; } /** diff --git a/services/mcp/src/generated/dashboards/api.ts b/services/mcp/src/generated/dashboards/api.ts index 9b266c828c93..184933d83cc3 100644 --- a/services/mcp/src/generated/dashboards/api.ts +++ b/services/mcp/src/generated/dashboards/api.ts @@ -114,28 +114,34 @@ export const DashboardsPartialUpdateQueryParams = /* @__PURE__ */ zod.object({ export const dashboardsPartialUpdateBodyNameMax = 400 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneLimitDefault = 25 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneLimitMax = 50 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneOneConfigOneLimitDefault = 25 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneOneConfigOneLimitMax = 50 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitDefault = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitMax = 25 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneOneNameMax = 400 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderByDefault = `occurrences` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderDirectionDefault = `DESC` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoStatusDefault = `active` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault = 5 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax = 10 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneLimitDefault = 10 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneLimitMax = 25 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsDefault = 15 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMin = 15 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMax = 60 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneOrderByDefault = `occurrences` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneOrderDirectionDefault = `DESC` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneStatusDefault = `active` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneTwoNameMax = 400 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitDefault = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitMax = 25 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneLimitDefault = 5 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneLimitMax = 10 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderByDefault = `start_time` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderDirectionDefault = `DESC` -export const dashboardsPartialUpdateBodyTilesItemWidgetOneNameMax = 400 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsDefault = 15 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsMin = 15 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsMax = 60 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneThreeNameMax = 400 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneLimitDefault = 10 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneLimitMax = 25 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDefault = `start_time` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault = `DESC` +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax = 400 export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .object({ @@ -153,30 +159,18 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod tiles: zod .array( zod.object({ - id: zod.number().optional().describe('Dashboard tile ID to update.'), widget: zod - .object({ - id: zod - .string() - .optional() - .describe('Existing widget row ID when updating a widget tile via dashboard PATCH.'), - widget_type: zod - .enum([ - 'activity_events_list', - 'error_tracking_list', - 'live_activity', - 'session_replay_list', - ]) - .describe( - '* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `live_activity` - live_activity\n* `session_replay_list` - session_replay_list' - ) - .optional() - .describe( - 'Widget type identifier (cannot be changed on update).\n\n* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `live_activity` - live_activity\n* `session_replay_list` - session_replay_list' - ), - config: zod - .union([ - zod.object({ + .union([ + zod.object({ + id: zod + .string() + .optional() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + widget_type: zod.enum(['activity_events_list']), + config: zod + .object({ dateRange: zod .union([ zod.object({ @@ -254,13 +248,34 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneOneConfigOneLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneOneConfigOneLimitDefault ) .describe('Maximum number of events to return.'), - }), - zod.object({ + }) + .optional() + .describe('Configuration patch for the recent events widget.'), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneOneNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), + zod.object({ + id: zod + .string() + .optional() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + widget_type: zod.enum(['error_tracking_list']), + config: zod + .object({ dateRange: zod .union([ zod.object({ @@ -338,21 +353,21 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneLimitDefault ) .describe('Maximum number of issues to return.'), orderBy: zod .enum(['last_seen', 'first_seen', 'occurrences', 'users', 'sessions']) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderByDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneOrderByDefault ) .describe('Issue ranking column.'), orderDirection: zod .enum(['ASC', 'DESC']) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoOrderDirectionDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneOrderDirectionDefault ) .describe('Sort direction for orderBy.'), status: zod @@ -365,7 +380,7 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'all', ]) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoStatusDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneTwoConfigOneStatusDefault ) .describe('Issue status filter.'), assignee: zod @@ -380,14 +395,35 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Filter by assignee ({type: user|role, id}). Omit for any assignee.' ), - }), - zod.object({ + }) + .optional() + .describe('Configuration patch for the top issues widget.'), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneTwoNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), + zod.object({ + id: zod + .string() + .optional() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + widget_type: zod.enum(['live_activity']), + config: zod + .object({ limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneLimitDefault ) .describe('Maximum number of recent events to return.'), filterTestAccounts: zod @@ -399,19 +435,40 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod refreshIntervalSeconds: zod .number() .min( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMin + dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsMin ) .max( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsMax + dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsMax ) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneThreeRefreshIntervalSecondsDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneThreeConfigOneRefreshIntervalSecondsDefault ) .describe( 'Auto-refresh interval in seconds. The frontend pauses this timer while the tab is hidden.' ), - }), - zod.object({ + }) + .optional() + .describe('Configuration patch for the live activity widget.'), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneThreeNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), + zod.object({ + id: zod + .string() + .optional() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + widget_type: zod.enum(['session_replay_list']), + config: zod + .object({ dateRange: zod .union([ zod.object({ @@ -489,9 +546,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod limit: zod .number() .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitMax) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneLimitMax) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourLimitDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneLimitDefault ) .describe('Maximum number of recordings to return.'), orderBy: zod @@ -504,13 +561,13 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'console_error_count', ]) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderByDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDefault ) .describe('Recording ranking column.'), orderDirection: zod .enum(['ASC', 'DESC']) .default( - dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneFourOrderDirectionDefault + dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault ) .describe('Sort direction for orderBy.'), savedFilterId: zod @@ -519,22 +576,23 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'short_id of a saved session replay filter to use as the recordings source. When set, the saved filter owns the date range and property filters; only orderBy, orderDirection, and limit still apply.' ), - }), - ]) - .optional() - .describe("Widget-specific configuration. Shape depends on the tile's widget_type."), - name: zod - .string() - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneNameMax) - .nullish() - .describe('Optional custom display name for the widget tile.'), - description: zod - .string() - .optional() - .describe('Optional markdown description shown when show_description is enabled.'), - }) + }) + .optional() + .describe('Configuration patch for the recent recordings widget.'), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), + ]) .optional() .describe('Nested widget row updates.'), + id: zod.number().optional().describe('Dashboard tile ID to update.'), }) ) .optional() diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json index f28abb06ebe6..4d50603eab94 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json @@ -81,11 +81,11 @@ "type": "number" }, "widget": { - "description": "Nested widget row updates.", - "properties": { - "config": { - "anyOf": [ - { + "anyOf": [ + { + "properties": { + "config": { + "description": "Configuration patch for the recent events widget.", "properties": { "dateRange": { "anyOf": [ @@ -229,7 +229,38 @@ }, "type": "object" }, - { + "description": { + "description": "Optional markdown description shown when show_description is enabled.", + "type": "string" + }, + "id": { + "description": "Existing widget row ID when updating a widget tile via dashboard PATCH.", + "type": "string" + }, + "name": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional custom display name for the widget tile." + }, + "widget_type": { + "enum": ["activity_events_list"], + "type": "string" + } + }, + "required": ["widget_type"], + "type": "object" + }, + { + "properties": { + "config": { + "description": "Configuration patch for the top issues widget.", "properties": { "assignee": { "anyOf": [ @@ -426,7 +457,38 @@ }, "type": "object" }, - { + "description": { + "description": "Optional markdown description shown when show_description is enabled.", + "type": "string" + }, + "id": { + "description": "Existing widget row ID when updating a widget tile via dashboard PATCH.", + "type": "string" + }, + "name": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional custom display name for the widget tile." + }, + "widget_type": { + "enum": ["error_tracking_list"], + "type": "string" + } + }, + "required": ["widget_type"], + "type": "object" + }, + { + "properties": { + "config": { + "description": "Configuration patch for the live activity widget.", "properties": { "filterTestAccounts": { "anyOf": [ @@ -456,7 +518,38 @@ }, "type": "object" }, - { + "description": { + "description": "Optional markdown description shown when show_description is enabled.", + "type": "string" + }, + "id": { + "description": "Existing widget row ID when updating a widget tile via dashboard PATCH.", + "type": "string" + }, + "name": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional custom display name for the widget tile." + }, + "widget_type": { + "enum": ["live_activity"], + "type": "string" + } + }, + "required": ["widget_type"], + "type": "object" + }, + { + "properties": { + "config": { + "description": "Configuration patch for the recent recordings widget.", "properties": { "dateRange": { "anyOf": [ @@ -629,42 +722,37 @@ } }, "type": "object" - } - ], - "description": "Widget-specific configuration. Shape depends on the tile's widget_type." - }, - "description": { - "description": "Optional markdown description shown when show_description is enabled.", - "type": "string" - }, - "id": { - "description": "Existing widget row ID when updating a widget tile via dashboard PATCH.", - "type": "string" - }, - "name": { - "anyOf": [ - { - "maxLength": 400, + }, + "description": { + "description": "Optional markdown description shown when show_description is enabled.", "type": "string" }, - { - "type": "null" + "id": { + "description": "Existing widget row ID when updating a widget tile via dashboard PATCH.", + "type": "string" + }, + "name": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional custom display name for the widget tile." + }, + "widget_type": { + "enum": ["session_replay_list"], + "type": "string" } - ], - "description": "Optional custom display name for the widget tile." - }, - "widget_type": { - "description": "Widget type identifier (cannot be changed on update).\n\n* `activity_events_list` - activity_events_list\n* `error_tracking_list` - error_tracking_list\n* `live_activity` - live_activity\n* `session_replay_list` - session_replay_list", - "enum": [ - "activity_events_list", - "error_tracking_list", - "live_activity", - "session_replay_list" - ], - "type": "string" + }, + "required": ["widget_type"], + "type": "object" } - }, - "type": "object" + ], + "description": "Nested widget row updates." } }, "type": "object" diff --git a/services/mcp/tests/unit/dashboard-update-schema.test.ts b/services/mcp/tests/unit/dashboard-update-schema.test.ts index fc959d51debc..22f4333cfdc5 100644 --- a/services/mcp/tests/unit/dashboard-update-schema.test.ts +++ b/services/mcp/tests/unit/dashboard-update-schema.test.ts @@ -17,6 +17,7 @@ function getSchemaShape(schema: z.ZodTypeAny): Record { describe('dashboard-update schema', () => { const tool = GENERATED_TOOLS['dashboard-update']!() + const batchAddTool = GENERATED_TOOLS['dashboard-widgets-batch-add']!() it('includes every OpenAPI PATCH body field from DashboardsPartialUpdateBody', () => { const toolShape = getSchemaShape(tool.schema) @@ -36,9 +37,50 @@ describe('dashboard-update schema', () => { use_template: '', use_dashboard: null, delete_insights: false, - tiles: [{ id: 1, widget: { config: { limit: 10 } } }], + tiles: [{ id: 1, widget: { widget_type: 'live_activity', config: { limit: 10 } } }], }) expect(result.success).toBe(true) }) + + it('preserves live activity config fields when widget_type is provided', () => { + const result = tool.schema.safeParse({ + id: 1, + tiles: [ + { + id: 2, + widget: { + widget_type: 'live_activity', + config: { filterTestAccounts: false, refreshIntervalSeconds: 30 }, + }, + }, + ], + }) + + expect(result.success).toBe(true) + expect(result.data.tiles?.[0]?.widget?.config).toEqual({ + filterTestAccounts: false, + limit: 5, + refreshIntervalSeconds: 30, + }) + }) + + it('preserves live activity config fields for batch-add widgets', () => { + const result = batchAddTool.schema.safeParse({ + id: 1, + widgets: [ + { + widget_type: 'live_activity', + config: { filterTestAccounts: false, refreshIntervalSeconds: 30 }, + }, + ], + }) + + expect(result.success).toBe(true) + expect(result.data.widgets?.[0]?.config).toEqual({ + filterTestAccounts: false, + limit: 5, + refreshIntervalSeconds: 30, + }) + }) }) From 6b59c6f6aeb2e38dd122d6308f1fb86bf55f2997 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:40:45 +0000 Subject: [PATCH 3/5] fix(dashboards): preserve metadata-only widget patches Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../backend/widget_specs/openapi.py | 32 +++++- .../frontend/generated/api.schemas.ts | 100 ++++++++---------- .../dashboards/frontend/generated/api.zod.ts | 38 ++++++- services/mcp/src/api/generated.ts | 93 +++++++--------- services/mcp/src/generated/dashboards/api.ts | 38 ++++++- .../tool-schemas/dashboard-update.json | 33 ++++++ .../unit/dashboard-update-schema.test.ts | 25 ++++- 7 files changed, 239 insertions(+), 120 deletions(-) diff --git a/products/dashboards/backend/widget_specs/openapi.py b/products/dashboards/backend/widget_specs/openapi.py index 5bae951d7f94..b7cdad13962e 100644 --- a/products/dashboards/backend/widget_specs/openapi.py +++ b/products/dashboards/backend/widget_specs/openapi.py @@ -75,6 +75,31 @@ class _AddDashboardWidgetTileFieldsOpenApiSerializer(serializers.Serializer): ) +class _DashboardPatchExistingWidgetOpenApiSerializer(serializers.Serializer): + id = serializers.UUIDField( + help_text="Existing widget row ID when updating a widget tile via dashboard PATCH.", + ) + config = serializers.JSONField( + required=False, + help_text=( + "Widget-specific configuration patch for the existing widget row. " + "Include widget_type for a typed config schema; omit it for metadata-only updates." + ), + ) + name = serializers.CharField( + max_length=400, + required=False, + allow_null=True, + allow_blank=True, + help_text="Optional custom display name for the widget tile.", + ) + description = serializers.CharField( + required=False, + allow_blank=True, + help_text="Optional markdown description shown when show_description is enabled.", + ) + + def _build_openapi_serializers() -> tuple[ dict[str, type[serializers.Serializer]], dict[str, type[serializers.Serializer]], @@ -184,8 +209,11 @@ class DashboardWidgetConfigField(serializers.JSONField): DashboardPatchWidgetRequestOpenApi = PolymorphicProxySerializer( component_name="DashboardPatchWidgetRequest", - serializers=cast("dict[str, Any]", _WIDGET_PATCH_REQUEST_SERIALIZERS), - resource_type_field_name="widget_type", + serializers=[ + *cast("list[Any]", list(_WIDGET_PATCH_REQUEST_SERIALIZERS.values())), + _DashboardPatchExistingWidgetOpenApiSerializer, + ], + resource_type_field_name=None, required=False, help_text="Nested widget row updates.", ) diff --git a/products/dashboards/frontend/generated/api.schemas.ts b/products/dashboards/frontend/generated/api.schemas.ts index ea022978ccba..a1e7103cecc4 100644 --- a/products/dashboards/frontend/generated/api.schemas.ts +++ b/products/dashboards/frontend/generated/api.schemas.ts @@ -364,10 +364,13 @@ export interface DashboardCollaboratorApi { user_uuid: string } -export type ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType = - (typeof ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType)[keyof typeof ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType] +/** + * * `activity_events_list` - activity_events_list + */ +export type ActivityEventsListWidgetTypeEnumApi = + (typeof ActivityEventsListWidgetTypeEnumApi)[keyof typeof ActivityEventsListWidgetTypeEnumApi] -export const ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType = { +export const ActivityEventsListWidgetTypeEnumApi = { ActivityEventsList: 'activity_events_list', } as const @@ -456,7 +459,7 @@ export interface ActivityEventsListWidgetConfigApi { export interface ActivityEventsListWidgetPatchRequestOpenApiApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string - widget_type: ActivityEventsListWidgetPatchRequestOpenApiApiWidgetType + widget_type: ActivityEventsListWidgetTypeEnumApi /** Configuration patch for the recent events widget. */ config?: ActivityEventsListWidgetConfigApi /** @@ -469,10 +472,13 @@ export interface ActivityEventsListWidgetPatchRequestOpenApiApi { description?: string } -export type ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType = - (typeof ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType)[keyof typeof ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType] +/** + * * `error_tracking_list` - error_tracking_list + */ +export type ErrorTrackingListWidgetTypeEnumApi = + (typeof ErrorTrackingListWidgetTypeEnumApi)[keyof typeof ErrorTrackingListWidgetTypeEnumApi] -export const ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType = { +export const ErrorTrackingListWidgetTypeEnumApi = { ErrorTrackingList: 'error_tracking_list', } as const @@ -553,7 +559,7 @@ export interface ErrorTrackingListWidgetConfigApi { export interface ErrorTrackingListWidgetPatchRequestOpenApiApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string - widget_type: ErrorTrackingListWidgetPatchRequestOpenApiApiWidgetType + widget_type: ErrorTrackingListWidgetTypeEnumApi /** Configuration patch for the top issues widget. */ config?: ErrorTrackingListWidgetConfigApi /** @@ -566,10 +572,13 @@ export interface ErrorTrackingListWidgetPatchRequestOpenApiApi { description?: string } -export type LiveActivityWidgetPatchRequestOpenApiApiWidgetType = - (typeof LiveActivityWidgetPatchRequestOpenApiApiWidgetType)[keyof typeof LiveActivityWidgetPatchRequestOpenApiApiWidgetType] +/** + * * `live_activity` - live_activity + */ +export type LiveActivityWidgetTypeEnumApi = + (typeof LiveActivityWidgetTypeEnumApi)[keyof typeof LiveActivityWidgetTypeEnumApi] -export const LiveActivityWidgetPatchRequestOpenApiApiWidgetType = { +export const LiveActivityWidgetTypeEnumApi = { LiveActivity: 'live_activity', } as const @@ -593,7 +602,7 @@ export interface LiveActivityWidgetConfigApi { export interface LiveActivityWidgetPatchRequestOpenApiApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string - widget_type: LiveActivityWidgetPatchRequestOpenApiApiWidgetType + widget_type: LiveActivityWidgetTypeEnumApi /** Configuration patch for the live activity widget. */ config?: LiveActivityWidgetConfigApi /** @@ -606,10 +615,13 @@ export interface LiveActivityWidgetPatchRequestOpenApiApi { description?: string } -export type SessionReplayListWidgetPatchRequestOpenApiApiWidgetType = - (typeof SessionReplayListWidgetPatchRequestOpenApiApiWidgetType)[keyof typeof SessionReplayListWidgetPatchRequestOpenApiApiWidgetType] +/** + * * `session_replay_list` - session_replay_list + */ +export type SessionReplayListWidgetTypeEnumApi = + (typeof SessionReplayListWidgetTypeEnumApi)[keyof typeof SessionReplayListWidgetTypeEnumApi] -export const SessionReplayListWidgetPatchRequestOpenApiApiWidgetType = { +export const SessionReplayListWidgetTypeEnumApi = { SessionReplayList: 'session_replay_list', } as const @@ -662,7 +674,7 @@ export interface SessionReplayListWidgetConfigApi { export interface SessionReplayListWidgetPatchRequestOpenApiApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string - widget_type: SessionReplayListWidgetPatchRequestOpenApiApiWidgetType + widget_type: SessionReplayListWidgetTypeEnumApi /** Configuration patch for the recent recordings widget. */ config?: SessionReplayListWidgetConfigApi /** @@ -675,11 +687,27 @@ export interface SessionReplayListWidgetPatchRequestOpenApiApi { description?: string } +export interface _DashboardPatchExistingWidgetOpenApiApi { + /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ + id: string + /** Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates. */ + config?: unknown + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null + /** Optional markdown description shown when show_description is enabled. */ + description?: string +} + export type DashboardPatchWidgetRequestApi = | ActivityEventsListWidgetPatchRequestOpenApiApi | ErrorTrackingListWidgetPatchRequestOpenApiApi | LiveActivityWidgetPatchRequestOpenApiApi | SessionReplayListWidgetPatchRequestOpenApiApi + | _DashboardPatchExistingWidgetOpenApiApi export interface DashboardPatchTileOpenApiApi { /** Nested widget row updates. */ @@ -8292,46 +8320,6 @@ export interface PatchedDataColorThemeApi { readonly created_by?: UserBasicApi } -/** - * * `activity_events_list` - activity_events_list - */ -export type ActivityEventsListWidgetTypeEnumApi = - (typeof ActivityEventsListWidgetTypeEnumApi)[keyof typeof ActivityEventsListWidgetTypeEnumApi] - -export const ActivityEventsListWidgetTypeEnumApi = { - ActivityEventsList: 'activity_events_list', -} as const - -/** - * * `error_tracking_list` - error_tracking_list - */ -export type ErrorTrackingListWidgetTypeEnumApi = - (typeof ErrorTrackingListWidgetTypeEnumApi)[keyof typeof ErrorTrackingListWidgetTypeEnumApi] - -export const ErrorTrackingListWidgetTypeEnumApi = { - ErrorTrackingList: 'error_tracking_list', -} as const - -/** - * * `live_activity` - live_activity - */ -export type LiveActivityWidgetTypeEnumApi = - (typeof LiveActivityWidgetTypeEnumApi)[keyof typeof LiveActivityWidgetTypeEnumApi] - -export const LiveActivityWidgetTypeEnumApi = { - LiveActivity: 'live_activity', -} as const - -/** - * * `session_replay_list` - session_replay_list - */ -export type SessionReplayListWidgetTypeEnumApi = - (typeof SessionReplayListWidgetTypeEnumApi)[keyof typeof SessionReplayListWidgetTypeEnumApi] - -export const SessionReplayListWidgetTypeEnumApi = { - SessionReplayList: 'session_replay_list', -} as const - export type DashboardTemplatesListParams = { /** * Omit for all templates. When set, filter by featured flag; parsed with str_to_bool (same as other API query booleans). diff --git a/products/dashboards/frontend/generated/api.zod.ts b/products/dashboards/frontend/generated/api.zod.ts index 088bc34c600d..e2f9ed2a42e1 100644 --- a/products/dashboards/frontend/generated/api.zod.ts +++ b/products/dashboards/frontend/generated/api.zod.ts @@ -239,6 +239,8 @@ export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDe export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax = 400 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveNameMax = 400 + export const dashboardsPartialUpdateBodyDeleteInsightsDefault = false export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod @@ -266,7 +268,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), - widget_type: zod.enum(['activity_events_list']), + widget_type: zod + .enum(['activity_events_list']) + .describe('\* `activity_events_list` - activity_events_list'), config: zod .object({ dateRange: zod @@ -371,7 +375,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), - widget_type: zod.enum(['error_tracking_list']), + widget_type: zod + .enum(['error_tracking_list']) + .describe('\* `error_tracking_list` - error_tracking_list'), config: zod .object({ dateRange: zod @@ -513,7 +519,7 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), - widget_type: zod.enum(['live_activity']), + widget_type: zod.enum(['live_activity']).describe('\* `live_activity` - live_activity'), config: zod .object({ limit: zod @@ -564,7 +570,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), - widget_type: zod.enum(['session_replay_list']), + widget_type: zod + .enum(['session_replay_list']) + .describe('\* `session_replay_list` - session_replay_list'), config: zod .object({ dateRange: zod @@ -687,6 +695,28 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .optional() .describe('Optional markdown description shown when show_description is enabled.'), }), + zod.object({ + id: zod + .uuid() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + config: zod + .unknown() + .optional() + .describe( + 'Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates.' + ), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), ]) .optional() .describe('Nested widget row updates.'), diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index f7bba0198d1d..3da841c96e5c 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -1738,17 +1738,20 @@ export namespace Schemas { required_product_access?: string | null; } - export type ActivityEventsListWidgetPatchRequestOpenApiWidgetType = typeof ActivityEventsListWidgetPatchRequestOpenApiWidgetType[keyof typeof ActivityEventsListWidgetPatchRequestOpenApiWidgetType]; + /** + * * `activity_events_list` - activity_events_list + */ + export type ActivityEventsListWidgetTypeEnum = typeof ActivityEventsListWidgetTypeEnum[keyof typeof ActivityEventsListWidgetTypeEnum]; - export const ActivityEventsListWidgetPatchRequestOpenApiWidgetType = { + export const ActivityEventsListWidgetTypeEnum = { ActivityEventsList: 'activity_events_list', } as const; export interface ActivityEventsListWidgetPatchRequestOpenApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string; - widget_type: ActivityEventsListWidgetPatchRequestOpenApiWidgetType; + widget_type: ActivityEventsListWidgetTypeEnum; /** Configuration patch for the recent events widget. */ config?: ActivityEventsListWidgetConfig; /** @@ -1761,16 +1764,6 @@ export namespace Schemas { description?: string; } - /** - * * `activity_events_list` - activity_events_list - */ - export type ActivityEventsListWidgetTypeEnum = typeof ActivityEventsListWidgetTypeEnum[keyof typeof ActivityEventsListWidgetTypeEnum]; - - - export const ActivityEventsListWidgetTypeEnum = { - ActivityEventsList: 'activity_events_list', - } as const; - export interface ActivityLog { readonly id: string; user: UserBasic; @@ -13801,17 +13794,20 @@ export namespace Schemas { properties?: (EventPropertyFilter | PersonPropertyFilter | ElementPropertyFilter | EventMetadataPropertyFilter | SessionPropertyFilter | CohortPropertyFilter | RecordingPropertyFilter | LogEntryPropertyFilter | GroupPropertyFilter | FeaturePropertyFilter | FlagPropertyFilter | HogQLPropertyFilter | EmptyPropertyFilter | DataWarehousePropertyFilter | DataWarehousePersonPropertyFilter | ErrorTrackingIssueFilter | LogPropertyFilter | SpanPropertyFilter | RevenueAnalyticsPropertyFilter | WorkflowVariablePropertyFilter)[] | null; } - export type ErrorTrackingListWidgetPatchRequestOpenApiWidgetType = typeof ErrorTrackingListWidgetPatchRequestOpenApiWidgetType[keyof typeof ErrorTrackingListWidgetPatchRequestOpenApiWidgetType]; + /** + * * `error_tracking_list` - error_tracking_list + */ + export type ErrorTrackingListWidgetTypeEnum = typeof ErrorTrackingListWidgetTypeEnum[keyof typeof ErrorTrackingListWidgetTypeEnum]; - export const ErrorTrackingListWidgetPatchRequestOpenApiWidgetType = { + export const ErrorTrackingListWidgetTypeEnum = { ErrorTrackingList: 'error_tracking_list', } as const; export interface ErrorTrackingListWidgetPatchRequestOpenApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string; - widget_type: ErrorTrackingListWidgetPatchRequestOpenApiWidgetType; + widget_type: ErrorTrackingListWidgetTypeEnum; /** Configuration patch for the top issues widget. */ config?: ErrorTrackingListWidgetConfig; /** @@ -13824,17 +13820,20 @@ export namespace Schemas { description?: string; } - export type LiveActivityWidgetPatchRequestOpenApiWidgetType = typeof LiveActivityWidgetPatchRequestOpenApiWidgetType[keyof typeof LiveActivityWidgetPatchRequestOpenApiWidgetType]; + /** + * * `live_activity` - live_activity + */ + export type LiveActivityWidgetTypeEnum = typeof LiveActivityWidgetTypeEnum[keyof typeof LiveActivityWidgetTypeEnum]; - export const LiveActivityWidgetPatchRequestOpenApiWidgetType = { + export const LiveActivityWidgetTypeEnum = { LiveActivity: 'live_activity', } as const; export interface LiveActivityWidgetPatchRequestOpenApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string; - widget_type: LiveActivityWidgetPatchRequestOpenApiWidgetType; + widget_type: LiveActivityWidgetTypeEnum; /** Configuration patch for the live activity widget. */ config?: LiveActivityWidgetConfig; /** @@ -13847,17 +13846,20 @@ export namespace Schemas { description?: string; } - export type SessionReplayListWidgetPatchRequestOpenApiWidgetType = typeof SessionReplayListWidgetPatchRequestOpenApiWidgetType[keyof typeof SessionReplayListWidgetPatchRequestOpenApiWidgetType]; + /** + * * `session_replay_list` - session_replay_list + */ + export type SessionReplayListWidgetTypeEnum = typeof SessionReplayListWidgetTypeEnum[keyof typeof SessionReplayListWidgetTypeEnum]; - export const SessionReplayListWidgetPatchRequestOpenApiWidgetType = { + export const SessionReplayListWidgetTypeEnum = { SessionReplayList: 'session_replay_list', } as const; export interface SessionReplayListWidgetPatchRequestOpenApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id?: string; - widget_type: SessionReplayListWidgetPatchRequestOpenApiWidgetType; + widget_type: SessionReplayListWidgetTypeEnum; /** Configuration patch for the recent recordings widget. */ config?: SessionReplayListWidgetConfig; /** @@ -13870,7 +13872,22 @@ export namespace Schemas { description?: string; } - export type DashboardPatchWidgetRequest = ActivityEventsListWidgetPatchRequestOpenApi | ErrorTrackingListWidgetPatchRequestOpenApi | LiveActivityWidgetPatchRequestOpenApi | SessionReplayListWidgetPatchRequestOpenApi; + export interface _DashboardPatchExistingWidgetOpenApi { + /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ + id: string; + /** Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates. */ + config?: unknown; + /** + * Optional custom display name for the widget tile. + * @maxLength 400 + * @nullable + */ + name?: string | null; + /** Optional markdown description shown when show_description is enabled. */ + description?: string; + } + + export type DashboardPatchWidgetRequest = ActivityEventsListWidgetPatchRequestOpenApi | ErrorTrackingListWidgetPatchRequestOpenApi | LiveActivityWidgetPatchRequestOpenApi | SessionReplayListWidgetPatchRequestOpenApi | _DashboardPatchExistingWidgetOpenApi; export interface DashboardPatchTileOpenApi { /** Nested widget row updates. */ @@ -18709,16 +18726,6 @@ export namespace Schemas { required_product_access?: string | null; } - /** - * * `error_tracking_list` - error_tracking_list - */ - export type ErrorTrackingListWidgetTypeEnum = typeof ErrorTrackingListWidgetTypeEnum[keyof typeof ErrorTrackingListWidgetTypeEnum]; - - - export const ErrorTrackingListWidgetTypeEnum = { - ErrorTrackingList: 'error_tracking_list', - } as const; - /** * Recommendation payload, shape depends on type. */ @@ -25843,16 +25850,6 @@ export namespace Schemas { required_product_access?: string | null; } - /** - * * `live_activity` - live_activity - */ - export type LiveActivityWidgetTypeEnum = typeof LiveActivityWidgetTypeEnum[keyof typeof LiveActivityWidgetTypeEnum]; - - - export const LiveActivityWidgetTypeEnum = { - LiveActivity: 'live_activity', - } as const; - export interface LiveDebuggerBreakpoint { readonly id: string; /** @nullable */ @@ -43691,16 +43688,6 @@ export namespace Schemas { required_product_access?: string | null; } - /** - * * `session_replay_list` - session_replay_list - */ - export type SessionReplayListWidgetTypeEnum = typeof SessionReplayListWidgetTypeEnum[keyof typeof SessionReplayListWidgetTypeEnum]; - - - export const SessionReplayListWidgetTypeEnum = { - SessionReplayList: 'session_replay_list', - } as const; - export interface SessionSummaries { /** * List of session IDs to summarize (max 300) diff --git a/services/mcp/src/generated/dashboards/api.ts b/services/mcp/src/generated/dashboards/api.ts index 184933d83cc3..b5b16db0b520 100644 --- a/services/mcp/src/generated/dashboards/api.ts +++ b/services/mcp/src/generated/dashboards/api.ts @@ -143,6 +143,8 @@ export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDe export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax = 400 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveNameMax = 400 + export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .object({ name: zod.string().max(dashboardsPartialUpdateBodyNameMax).nullish(), @@ -168,7 +170,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), - widget_type: zod.enum(['activity_events_list']), + widget_type: zod + .enum(['activity_events_list']) + .describe('* `activity_events_list` - activity_events_list'), config: zod .object({ dateRange: zod @@ -273,7 +277,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), - widget_type: zod.enum(['error_tracking_list']), + widget_type: zod + .enum(['error_tracking_list']) + .describe('* `error_tracking_list` - error_tracking_list'), config: zod .object({ dateRange: zod @@ -415,7 +421,7 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), - widget_type: zod.enum(['live_activity']), + widget_type: zod.enum(['live_activity']).describe('* `live_activity` - live_activity'), config: zod .object({ limit: zod @@ -466,7 +472,9 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), - widget_type: zod.enum(['session_replay_list']), + widget_type: zod + .enum(['session_replay_list']) + .describe('* `session_replay_list` - session_replay_list'), config: zod .object({ dateRange: zod @@ -589,6 +597,28 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .optional() .describe('Optional markdown description shown when show_description is enabled.'), }), + zod.object({ + id: zod + .string() + .describe( + 'Existing widget row ID when updating a widget tile via dashboard PATCH.' + ), + config: zod + .unknown() + .optional() + .describe( + 'Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates.' + ), + name: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveNameMax) + .nullish() + .describe('Optional custom display name for the widget tile.'), + description: zod + .string() + .optional() + .describe('Optional markdown description shown when show_description is enabled.'), + }), ]) .optional() .describe('Nested widget row updates.'), diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json index 4d50603eab94..5b65f37efa90 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json @@ -250,6 +250,7 @@ "description": "Optional custom display name for the widget tile." }, "widget_type": { + "description": "* `activity_events_list` - activity_events_list", "enum": ["activity_events_list"], "type": "string" } @@ -478,6 +479,7 @@ "description": "Optional custom display name for the widget tile." }, "widget_type": { + "description": "* `error_tracking_list` - error_tracking_list", "enum": ["error_tracking_list"], "type": "string" } @@ -539,6 +541,7 @@ "description": "Optional custom display name for the widget tile." }, "widget_type": { + "description": "* `live_activity` - live_activity", "enum": ["live_activity"], "type": "string" } @@ -744,12 +747,42 @@ "description": "Optional custom display name for the widget tile." }, "widget_type": { + "description": "* `session_replay_list` - session_replay_list", "enum": ["session_replay_list"], "type": "string" } }, "required": ["widget_type"], "type": "object" + }, + { + "properties": { + "config": { + "description": "Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates." + }, + "description": { + "description": "Optional markdown description shown when show_description is enabled.", + "type": "string" + }, + "id": { + "description": "Existing widget row ID when updating a widget tile via dashboard PATCH.", + "type": "string" + }, + "name": { + "anyOf": [ + { + "maxLength": 400, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional custom display name for the widget tile." + } + }, + "required": ["id"], + "type": "object" } ], "description": "Nested widget row updates." diff --git a/services/mcp/tests/unit/dashboard-update-schema.test.ts b/services/mcp/tests/unit/dashboard-update-schema.test.ts index 22f4333cfdc5..505d3be6c84f 100644 --- a/services/mcp/tests/unit/dashboard-update-schema.test.ts +++ b/services/mcp/tests/unit/dashboard-update-schema.test.ts @@ -37,12 +37,35 @@ describe('dashboard-update schema', () => { use_template: '', use_dashboard: null, delete_insights: false, - tiles: [{ id: 1, widget: { widget_type: 'live_activity', config: { limit: 10 } } }], + tiles: [{ id: 1, widget: { id: '00000000-0000-4000-8000-000000000002', name: 'Renamed widget' } }], }) expect(result.success).toBe(true) }) + it('accepts metadata-only widget PATCHes without widget_type', () => { + const result = tool.schema.safeParse({ + id: 1, + tiles: [ + { + id: 2, + widget: { + id: '00000000-0000-4000-8000-000000000002', + name: 'Renamed widget', + description: 'Updated description', + }, + }, + ], + }) + + expect(result.success).toBe(true) + expect(result.data.tiles?.[0]?.widget).toEqual({ + id: '00000000-0000-4000-8000-000000000002', + name: 'Renamed widget', + description: 'Updated description', + }) + }) + it('preserves live activity config fields when widget_type is provided', () => { const result = tool.schema.safeParse({ id: 1, From 01df4e2f819ad2cd378010ec22ee669aba60bdc1 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:08:13 +0000 Subject: [PATCH 4/5] fix(dashboards): constrain metadata-only widget patches Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../backend/widget_specs/openapi.py | 16 ++++-- .../frontend/generated/api.schemas.ts | 14 ++++- .../dashboards/frontend/generated/api.zod.ts | 18 ++++++- services/mcp/src/api/generated.ts | 14 ++++- services/mcp/src/generated/dashboards/api.ts | 18 ++++++- .../tool-schemas/dashboard-update.json | 11 +++- .../unit/dashboard-update-schema.test.ts | 53 +++++++++++++++++++ 7 files changed, 130 insertions(+), 14 deletions(-) diff --git a/products/dashboards/backend/widget_specs/openapi.py b/products/dashboards/backend/widget_specs/openapi.py index b7cdad13962e..1c0bb0a31b5e 100644 --- a/products/dashboards/backend/widget_specs/openapi.py +++ b/products/dashboards/backend/widget_specs/openapi.py @@ -75,16 +75,22 @@ class _AddDashboardWidgetTileFieldsOpenApiSerializer(serializers.Serializer): ) +@extend_schema_field({"type": "number", "minimum": 1, "maximum": 0}) +class _OmittedOnlyOpenApiField(serializers.Field): + pass + + class _DashboardPatchExistingWidgetOpenApiSerializer(serializers.Serializer): id = serializers.UUIDField( help_text="Existing widget row ID when updating a widget tile via dashboard PATCH.", ) - config = serializers.JSONField( + widget_type = _OmittedOnlyOpenApiField( + required=False, + help_text="Omit for metadata-only updates. Include a supported widget_type when patching config.", + ) + config = _OmittedOnlyOpenApiField( required=False, - help_text=( - "Widget-specific configuration patch for the existing widget row. " - "Include widget_type for a typed config schema; omit it for metadata-only updates." - ), + help_text="Omit for metadata-only updates. Include widget_type to use a typed config schema.", ) name = serializers.CharField( max_length=400, diff --git a/products/dashboards/frontend/generated/api.schemas.ts b/products/dashboards/frontend/generated/api.schemas.ts index a1e7103cecc4..3bd2cbd632b2 100644 --- a/products/dashboards/frontend/generated/api.schemas.ts +++ b/products/dashboards/frontend/generated/api.schemas.ts @@ -690,8 +690,18 @@ export interface SessionReplayListWidgetPatchRequestOpenApiApi { export interface _DashboardPatchExistingWidgetOpenApiApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id: string - /** Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates. */ - config?: unknown + /** + * Omit for metadata-only updates. Include a supported widget_type when patching config. + * @minimum 1 + * @maximum 0 + */ + widget_type?: number + /** + * Omit for metadata-only updates. Include widget_type to use a typed config schema. + * @minimum 1 + * @maximum 0 + */ + config?: number /** * Optional custom display name for the widget tile. * @maxLength 400 diff --git a/products/dashboards/frontend/generated/api.zod.ts b/products/dashboards/frontend/generated/api.zod.ts index e2f9ed2a42e1..ba70d0bee8f5 100644 --- a/products/dashboards/frontend/generated/api.zod.ts +++ b/products/dashboards/frontend/generated/api.zod.ts @@ -239,6 +239,10 @@ export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDe export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax = 400 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveWidgetTypeMax = 0 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveConfigMax = 0 + export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveNameMax = 400 export const dashboardsPartialUpdateBodyDeleteInsightsDefault = false @@ -701,11 +705,21 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), + widget_type: zod + .number() + .min(1) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveWidgetTypeMax) + .optional() + .describe( + 'Omit for metadata-only updates. Include a supported widget_type when patching config.' + ), config: zod - .unknown() + .number() + .min(1) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveConfigMax) .optional() .describe( - 'Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates.' + 'Omit for metadata-only updates. Include widget_type to use a typed config schema.' ), name: zod .string() diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 3da841c96e5c..083c83d3edf0 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -13875,8 +13875,18 @@ export namespace Schemas { export interface _DashboardPatchExistingWidgetOpenApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id: string; - /** Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates. */ - config?: unknown; + /** + * Omit for metadata-only updates. Include a supported widget_type when patching config. + * @minimum 1 + * @maximum 0 + */ + widget_type?: number; + /** + * Omit for metadata-only updates. Include widget_type to use a typed config schema. + * @minimum 1 + * @maximum 0 + */ + config?: number; /** * Optional custom display name for the widget tile. * @maxLength 400 diff --git a/services/mcp/src/generated/dashboards/api.ts b/services/mcp/src/generated/dashboards/api.ts index b5b16db0b520..5de2f2609cf1 100644 --- a/services/mcp/src/generated/dashboards/api.ts +++ b/services/mcp/src/generated/dashboards/api.ts @@ -143,6 +143,10 @@ export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDe export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax = 400 +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveWidgetTypeMax = 0 + +export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveConfigMax = 0 + export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveNameMax = 400 export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod @@ -603,11 +607,21 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), + widget_type: zod + .number() + .min(1) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveWidgetTypeMax) + .optional() + .describe( + 'Omit for metadata-only updates. Include a supported widget_type when patching config.' + ), config: zod - .unknown() + .number() + .min(1) + .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveConfigMax) .optional() .describe( - 'Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates.' + 'Omit for metadata-only updates. Include widget_type to use a typed config schema.' ), name: zod .string() diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json index 5b65f37efa90..1dd689a1deb9 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json @@ -758,7 +758,10 @@ { "properties": { "config": { - "description": "Widget-specific configuration patch for the existing widget row. Include widget_type for a typed config schema; omit it for metadata-only updates." + "description": "Omit for metadata-only updates. Include widget_type to use a typed config schema.", + "maximum": 0, + "minimum": 1, + "type": "number" }, "description": { "description": "Optional markdown description shown when show_description is enabled.", @@ -779,6 +782,12 @@ } ], "description": "Optional custom display name for the widget tile." + }, + "widget_type": { + "description": "Omit for metadata-only updates. Include a supported widget_type when patching config.", + "maximum": 0, + "minimum": 1, + "type": "number" } }, "required": ["id"], diff --git a/services/mcp/tests/unit/dashboard-update-schema.test.ts b/services/mcp/tests/unit/dashboard-update-schema.test.ts index 505d3be6c84f..4692f45c19bb 100644 --- a/services/mcp/tests/unit/dashboard-update-schema.test.ts +++ b/services/mcp/tests/unit/dashboard-update-schema.test.ts @@ -88,6 +88,59 @@ describe('dashboard-update schema', () => { }) }) + it('rejects widget config PATCHes without widget_type', () => { + const result = tool.schema.safeParse({ + id: 1, + tiles: [ + { + id: 2, + widget: { + id: '00000000-0000-4000-8000-000000000002', + config: { limit: 10 }, + }, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects unknown widget types before the metadata-only fallback', () => { + const result = tool.schema.safeParse({ + id: 1, + tiles: [ + { + id: 2, + widget: { + id: '00000000-0000-4000-8000-000000000002', + widget_type: 'unknown_widget', + config: { limit: 10 }, + }, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects invalid live activity config before the metadata-only fallback', () => { + const result = tool.schema.safeParse({ + id: 1, + tiles: [ + { + id: 2, + widget: { + id: '00000000-0000-4000-8000-000000000002', + widget_type: 'live_activity', + config: { limit: 11, refreshIntervalSeconds: 30 }, + }, + }, + ], + }) + + expect(result.success).toBe(false) + }) + it('preserves live activity config fields for batch-add widgets', () => { const result = batchAddTool.schema.safeParse({ id: 1, From 53be4c006abbf46a43fa97c85c4b4fed85a8c091 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:28:37 +0000 Subject: [PATCH 5/5] fix(dashboards): make omitted widget patch fields uninhabitable Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../backend/widget_specs/openapi.py | 2 +- .../frontend/generated/api.schemas.ts | 32 ++++++++----- .../dashboards/frontend/generated/api.zod.ts | 12 +---- .../frontend/widgets/registry.test.tsx | 21 +++++++++ services/mcp/src/api/generated.ts | 34 +++++++++----- services/mcp/src/generated/dashboards/api.ts | 12 +---- .../tool-schemas/dashboard-update.json | 10 ++-- .../unit/dashboard-update-schema.test.ts | 21 +++++++++ tools/openapi-codegen/index.mjs | 1 + tools/openapi-codegen/src/preprocess.mjs | 47 ++++++++++++++++++- .../openapi-codegen/tests/preprocess.test.mjs | 26 ++++++++++ 11 files changed, 166 insertions(+), 52 deletions(-) diff --git a/products/dashboards/backend/widget_specs/openapi.py b/products/dashboards/backend/widget_specs/openapi.py index 1c0bb0a31b5e..f77f36bae21a 100644 --- a/products/dashboards/backend/widget_specs/openapi.py +++ b/products/dashboards/backend/widget_specs/openapi.py @@ -75,7 +75,7 @@ class _AddDashboardWidgetTileFieldsOpenApiSerializer(serializers.Serializer): ) -@extend_schema_field({"type": "number", "minimum": 1, "maximum": 0}) +@extend_schema_field({"not": {}, "x-posthog-codegen-never": True}) class _OmittedOnlyOpenApiField(serializers.Field): pass diff --git a/products/dashboards/frontend/generated/api.schemas.ts b/products/dashboards/frontend/generated/api.schemas.ts index 3bd2cbd632b2..1653b1bba3e0 100644 --- a/products/dashboards/frontend/generated/api.schemas.ts +++ b/products/dashboards/frontend/generated/api.schemas.ts @@ -687,21 +687,29 @@ export interface SessionReplayListWidgetPatchRequestOpenApiApi { description?: string } +/** + * Omit for metadata-only updates. Include a supported widget_type when patching config. + */ +export type _DashboardPatchExistingWidgetOpenApiApiWidgetType = + (typeof _DashboardPatchExistingWidgetOpenApiApiWidgetType)[keyof typeof _DashboardPatchExistingWidgetOpenApiApiWidgetType] + +export const _DashboardPatchExistingWidgetOpenApiApiWidgetType = {} as const + +/** + * Omit for metadata-only updates. Include widget_type to use a typed config schema. + */ +export type _DashboardPatchExistingWidgetOpenApiApiConfig = + (typeof _DashboardPatchExistingWidgetOpenApiApiConfig)[keyof typeof _DashboardPatchExistingWidgetOpenApiApiConfig] + +export const _DashboardPatchExistingWidgetOpenApiApiConfig = {} as const + export interface _DashboardPatchExistingWidgetOpenApiApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id: string - /** - * Omit for metadata-only updates. Include a supported widget_type when patching config. - * @minimum 1 - * @maximum 0 - */ - widget_type?: number - /** - * Omit for metadata-only updates. Include widget_type to use a typed config schema. - * @minimum 1 - * @maximum 0 - */ - config?: number + /** Omit for metadata-only updates. Include a supported widget_type when patching config. */ + widget_type?: _DashboardPatchExistingWidgetOpenApiApiWidgetType + /** Omit for metadata-only updates. Include widget_type to use a typed config schema. */ + config?: _DashboardPatchExistingWidgetOpenApiApiConfig /** * Optional custom display name for the widget tile. * @maxLength 400 diff --git a/products/dashboards/frontend/generated/api.zod.ts b/products/dashboards/frontend/generated/api.zod.ts index ba70d0bee8f5..e3c42341ab21 100644 --- a/products/dashboards/frontend/generated/api.zod.ts +++ b/products/dashboards/frontend/generated/api.zod.ts @@ -239,10 +239,6 @@ export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDe export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax = 400 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveWidgetTypeMax = 0 - -export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveConfigMax = 0 - export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveNameMax = 400 export const dashboardsPartialUpdateBodyDeleteInsightsDefault = false @@ -706,17 +702,13 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), widget_type: zod - .number() - .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveWidgetTypeMax) + .enum([]) .optional() .describe( 'Omit for metadata-only updates. Include a supported widget_type when patching config.' ), config: zod - .number() - .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveConfigMax) + .enum([]) .optional() .describe( 'Omit for metadata-only updates. Include widget_type to use a typed config schema.' diff --git a/products/dashboards/frontend/widgets/registry.test.tsx b/products/dashboards/frontend/widgets/registry.test.tsx index ec6b09f3cd8f..f48e956d0181 100644 --- a/products/dashboards/frontend/widgets/registry.test.tsx +++ b/products/dashboards/frontend/widgets/registry.test.tsx @@ -2,6 +2,7 @@ import posthog from 'posthog-js' import { ApiError } from 'lib/api-error' +import type { _DashboardPatchExistingWidgetOpenApiApi } from '../generated/api.schemas' import { DASHBOARD_WIDGET_CATALOG, DASHBOARD_WIDGET_PREVIEWS, @@ -21,6 +22,26 @@ jest.mock('posthog-js', () => ({ }, })) +const metadataOnlyWidgetPatch: _DashboardPatchExistingWidgetOpenApiApi = { + id: '00000000-0000-4000-8000-000000000002', + name: 'Renamed widget', +} +void metadataOnlyWidgetPatch + +const metadataOnlyWidgetPatchWithType: _DashboardPatchExistingWidgetOpenApiApi = { + id: '00000000-0000-4000-8000-000000000002', + // @ts-expect-error metadata-only widget PATCHes must not accept widget_type. + widget_type: 'live_activity', +} +void metadataOnlyWidgetPatchWithType + +const metadataOnlyWidgetPatchWithConfig: _DashboardPatchExistingWidgetOpenApiApi = { + id: '00000000-0000-4000-8000-000000000002', + // @ts-expect-error metadata-only widget PATCHes must not accept config. + config: { limit: 5 }, +} +void metadataOnlyWidgetPatchWithConfig + describe('dashboard widget registry', () => { beforeEach(() => { jest.mocked(posthog.captureException).mockClear() diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 083c83d3edf0..d099d3408f1b 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -13872,21 +13872,31 @@ export namespace Schemas { description?: string; } + /** + * Omit for metadata-only updates. Include a supported widget_type when patching config. + */ + export type _DashboardPatchExistingWidgetOpenApiWidgetType = typeof _DashboardPatchExistingWidgetOpenApiWidgetType[keyof typeof _DashboardPatchExistingWidgetOpenApiWidgetType]; + + + export const _DashboardPatchExistingWidgetOpenApiWidgetType = { + } as const; + + /** + * Omit for metadata-only updates. Include widget_type to use a typed config schema. + */ + export type _DashboardPatchExistingWidgetOpenApiConfig = typeof _DashboardPatchExistingWidgetOpenApiConfig[keyof typeof _DashboardPatchExistingWidgetOpenApiConfig]; + + + export const _DashboardPatchExistingWidgetOpenApiConfig = { + } as const; + export interface _DashboardPatchExistingWidgetOpenApi { /** Existing widget row ID when updating a widget tile via dashboard PATCH. */ id: string; - /** - * Omit for metadata-only updates. Include a supported widget_type when patching config. - * @minimum 1 - * @maximum 0 - */ - widget_type?: number; - /** - * Omit for metadata-only updates. Include widget_type to use a typed config schema. - * @minimum 1 - * @maximum 0 - */ - config?: number; + /** Omit for metadata-only updates. Include a supported widget_type when patching config. */ + widget_type?: _DashboardPatchExistingWidgetOpenApiWidgetType; + /** Omit for metadata-only updates. Include widget_type to use a typed config schema. */ + config?: _DashboardPatchExistingWidgetOpenApiConfig; /** * Optional custom display name for the widget tile. * @maxLength 400 diff --git a/services/mcp/src/generated/dashboards/api.ts b/services/mcp/src/generated/dashboards/api.ts index 5de2f2609cf1..c42315469916 100644 --- a/services/mcp/src/generated/dashboards/api.ts +++ b/services/mcp/src/generated/dashboards/api.ts @@ -143,10 +143,6 @@ export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderByDe export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourConfigOneOrderDirectionDefault = `DESC` export const dashboardsPartialUpdateBodyTilesItemWidgetOneFourNameMax = 400 -export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveWidgetTypeMax = 0 - -export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveConfigMax = 0 - export const dashboardsPartialUpdateBodyTilesItemWidgetOneFiveNameMax = 400 export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod @@ -608,17 +604,13 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod 'Existing widget row ID when updating a widget tile via dashboard PATCH.' ), widget_type: zod - .number() - .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveWidgetTypeMax) + .enum([]) .optional() .describe( 'Omit for metadata-only updates. Include a supported widget_type when patching config.' ), config: zod - .number() - .min(1) - .max(dashboardsPartialUpdateBodyTilesItemWidgetOneFiveConfigMax) + .enum([]) .optional() .describe( 'Omit for metadata-only updates. Include widget_type to use a typed config schema.' diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json index 1dd689a1deb9..b419b9f37445 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json @@ -759,9 +759,8 @@ "properties": { "config": { "description": "Omit for metadata-only updates. Include widget_type to use a typed config schema.", - "maximum": 0, - "minimum": 1, - "type": "number" + "enum": [], + "type": "string" }, "description": { "description": "Optional markdown description shown when show_description is enabled.", @@ -785,9 +784,8 @@ }, "widget_type": { "description": "Omit for metadata-only updates. Include a supported widget_type when patching config.", - "maximum": 0, - "minimum": 1, - "type": "number" + "enum": [], + "type": "string" } }, "required": ["id"], diff --git a/services/mcp/tests/unit/dashboard-update-schema.test.ts b/services/mcp/tests/unit/dashboard-update-schema.test.ts index 4692f45c19bb..2bd6c54d396e 100644 --- a/services/mcp/tests/unit/dashboard-update-schema.test.ts +++ b/services/mcp/tests/unit/dashboard-update-schema.test.ts @@ -1,9 +1,30 @@ import { describe, expect, it } from 'vitest' import { z } from 'zod' +import type { Schemas } from '@/api/generated' import { DashboardsPartialUpdateBody } from '@/generated/dashboards/api' import { GENERATED_TOOLS } from '@/tools/generated/dashboards' +const metadataOnlyWidgetPatch: Schemas._DashboardPatchExistingWidgetOpenApi = { + id: '00000000-0000-4000-8000-000000000002', + name: 'Renamed widget', +} +void metadataOnlyWidgetPatch + +const metadataOnlyWidgetPatchWithType: Schemas._DashboardPatchExistingWidgetOpenApi = { + id: '00000000-0000-4000-8000-000000000002', + // @ts-expect-error metadata-only widget PATCHes must not accept widget_type. + widget_type: 'live_activity', +} +void metadataOnlyWidgetPatchWithType + +const metadataOnlyWidgetPatchWithConfig: Schemas._DashboardPatchExistingWidgetOpenApi = { + id: '00000000-0000-4000-8000-000000000002', + // @ts-expect-error metadata-only widget PATCHes must not accept config. + config: { limit: 5 }, +} +void metadataOnlyWidgetPatchWithConfig + function getSchemaShape(schema: z.ZodTypeAny): Record { if ('shape' in schema && schema.shape && typeof schema.shape === 'object') { return schema.shape as Record diff --git a/tools/openapi-codegen/index.mjs b/tools/openapi-codegen/index.mjs index 7ef32ff58b9f..2a1606169c7a 100644 --- a/tools/openapi-codegen/index.mjs +++ b/tools/openapi-codegen/index.mjs @@ -8,6 +8,7 @@ export { } from './src/schema.mjs' export { clampIntegerBounds, + applyCodegenNeverSchemas, inlineSchemaRefs, INT32_MAX, INT32_MIN, diff --git a/tools/openapi-codegen/src/preprocess.mjs b/tools/openapi-codegen/src/preprocess.mjs index 4155fa9ca1bc..fc701f5464f9 100644 --- a/tools/openapi-codegen/src/preprocess.mjs +++ b/tools/openapi-codegen/src/preprocess.mjs @@ -30,7 +30,15 @@ * We clamp any integer schema's min/max/exclusiveMin/exclusiveMax to int32 * range, which is far more than enough for every PostHog identifier. * - * 4. `default: null` on null-permitting schemas (stripNullDefaults) + * 4. Omitted-only properties (applyCodegenNeverSchemas) + * Some schema-only request variants need a field to stay optional, but become + * uninhabitable if present so a fallback branch can't accept config-bearing + * payloads. OpenAPI expresses this with `not: {}` plus the + * `x-posthog-codegen-never` extension. Orval does not generate useful TS/Zod + * for `not: {}`, so we translate these fields to an empty enum just before + * codegen. That produces a TypeScript `never` alias and `z.enum([])`. + * + * 5. `default: null` on null-permitting schemas (stripNullDefaults) * Pydantic emits `default: null` for every `Optional[...] = None` field. * Orval mirrors that into `.default(null)`, which makes Zod fill the field * with `null` whenever the caller omits it (`safeParse` applies defaults). @@ -43,6 +51,7 @@ export const INT32_MIN = -2147483648 export const INT32_MAX = 2147483647 const INTEGER_BOUND_KEYS = ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum'] +const CODEGEN_NEVER_EXTENSION = 'x-posthog-codegen-never' /** * Component names whose $refs get replaced inline, mapped to the substitute schema. @@ -191,6 +200,41 @@ export function stripNullDefaults(obj) { } } +/** + * Translate explicit codegen-only never markers into the shape Orval handles best. + * + * The source OpenAPI schema keeps `not: {}` so the public contract still says + * the field must be omitted. Orval currently emits `unknown` for `not: {}` in + * TS and Zod, while an empty string enum emits a `never` type alias and + * `z.enum([])` runtime rejection. + */ +export function applyCodegenNeverSchemas(obj) { + if (!obj || typeof obj !== 'object') { + return + } + if (Array.isArray(obj)) { + obj.forEach(applyCodegenNeverSchemas) + return + } + + if (obj[CODEGEN_NEVER_EXTENSION] === true) { + const description = obj.description + for (const key of Object.keys(obj)) { + delete obj[key] + } + obj.type = 'string' + obj.enum = [] + if (description !== undefined) { + obj.description = description + } + return + } + + for (const value of Object.values(obj)) { + applyCodegenNeverSchemas(value) + } +} + /** * Run standard preprocessing on a full OpenAPI schema. * Mutates the schema in place, also returns it for convenience. @@ -202,6 +246,7 @@ export function preprocessSchema(schema) { } stripCollidingInlineEnums(schema) clampIntegerBounds(schema) + applyCodegenNeverSchemas(schema) stripNullDefaults(schema) return schema } diff --git a/tools/openapi-codegen/tests/preprocess.test.mjs b/tools/openapi-codegen/tests/preprocess.test.mjs index e49bf3132690..afe2e15dd12d 100644 --- a/tools/openapi-codegen/tests/preprocess.test.mjs +++ b/tools/openapi-codegen/tests/preprocess.test.mjs @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + applyCodegenNeverSchemas, clampIntegerBounds, INT32_MAX, INT32_MIN, @@ -9,6 +10,31 @@ import { stripNullDefaults, } from '../src/preprocess.mjs' +describe('applyCodegenNeverSchemas', () => { + it('converts x-posthog-codegen-never schemas into empty enums for Orval', () => { + const schema = { + type: 'object', + properties: { + widget_type: { + not: {}, + 'x-posthog-codegen-never': true, + description: 'Omit for metadata-only updates.', + }, + name: { type: 'string' }, + }, + } + + applyCodegenNeverSchemas(schema) + + expect(schema.properties.widget_type).toEqual({ + type: 'string', + enum: [], + description: 'Omit for metadata-only updates.', + }) + expect(schema.properties.name).toEqual({ type: 'string' }) + }) +}) + describe('clampIntegerBounds', () => { it('clamps i64 bounds to int32 range on a type: integer schema', () => { const schema = {