From 2bbdedfb86781e460382d35862f102441eb81b4a Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 5 Mar 2025 11:51:13 -0800 Subject: [PATCH 01/41] test(ui): Swap spyOn date for mock date (#86409) --- .../gsApp/components/partnerPlanEndingModal.spec.tsx | 7 ++++++- static/gsApp/components/trialEndingModal.spec.tsx | 7 ++++++- .../gsApp/views/subscriptionPage/trialAlert.spec.tsx | 11 ++++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/static/gsApp/components/partnerPlanEndingModal.spec.tsx b/static/gsApp/components/partnerPlanEndingModal.spec.tsx index 0d9e43e9fb8ac0..d9205695fb7fc8 100644 --- a/static/gsApp/components/partnerPlanEndingModal.spec.tsx +++ b/static/gsApp/components/partnerPlanEndingModal.spec.tsx @@ -3,6 +3,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {resetMockDate, setMockDate} from 'sentry-test/utils'; import {PlanFixture} from 'getsentry/__fixtures__/plan'; import PartnerPlanEndingModal from 'getsentry/components/partnerPlanEndingModal'; @@ -11,7 +12,7 @@ import {PlanName} from 'getsentry/types'; describe('PartnerPlanEndingModal', function () { beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => new Date('2024-08-01').getTime()); + setMockDate(new Date('2024-08-01')); MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ @@ -25,6 +26,10 @@ describe('PartnerPlanEndingModal', function () { }); }); + afterEach(() => { + resetMockDate(); + }); + it('shows request upgrade when user does not have billing permissions', async function () { const org = OrganizationFixture({access: []}); const sub = SubscriptionFixture({organization: org, contractPeriodEnd: '2024-08-08'}); diff --git a/static/gsApp/components/trialEndingModal.spec.tsx b/static/gsApp/components/trialEndingModal.spec.tsx index f9212dde0e21de..3c0bb2b1252be0 100644 --- a/static/gsApp/components/trialEndingModal.spec.tsx +++ b/static/gsApp/components/trialEndingModal.spec.tsx @@ -3,13 +3,14 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {resetMockDate, setMockDate} from 'sentry-test/utils'; import TrialEndingModal from 'getsentry/components/trialEndingModal'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; describe('TrialEndingModal', function () { beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => new Date('2021-03-03').getTime()); + setMockDate(new Date('2021-03-03')); MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ @@ -23,6 +24,10 @@ describe('TrialEndingModal', function () { }); }); + afterEach(() => { + resetMockDate(); + }); + it('shows request upgrade when user does not have billing permissions', function () { const org = OrganizationFixture({access: []}); const sub = SubscriptionFixture({organization: org}); diff --git a/static/gsApp/views/subscriptionPage/trialAlert.spec.tsx b/static/gsApp/views/subscriptionPage/trialAlert.spec.tsx index 1ed78af6283cc3..5559949355c911 100644 --- a/static/gsApp/views/subscriptionPage/trialAlert.spec.tsx +++ b/static/gsApp/views/subscriptionPage/trialAlert.spec.tsx @@ -2,6 +2,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {resetMockDate, setMockDate} from 'sentry-test/utils'; import TrialAlert from 'getsentry/views/subscriptionPage/trialAlert'; @@ -9,9 +10,13 @@ describe('Subscription > TrialAlert', function () { const organization = OrganizationFixture(); const subscription = SubscriptionFixture({organization}); - beforeEach(() => - jest.spyOn(Date, 'now').mockImplementation(() => new Date('2021-01-01').getTime()) - ); + beforeEach(() => { + setMockDate(new Date('2021-01-01')); + }); + + afterEach(() => { + resetMockDate(); + }); it('does not render not on trial', function () { const sub = { From 437fc409342529176ac0348c7e0c1d08a315c3f0 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Wed, 5 Mar 2025 12:00:20 -0800 Subject: [PATCH 02/41] feat(ACI): Create Action serializer for old alert rules (#86248) When we switch over to single read after the backfill migration we still need to support the old alert rule endpoints, so this PR creates a serializer for the `Action` model that will be called when the current `AlertRuleTriggerAction` serializer is called that instead reads from the workflow engine tables but returns the same data the endpoint expects. --- .../serializers/workflow_engine_action.py | 54 ++++++++ .../handlers/action/notification/handler.py | 22 +++ .../test_workflow_engine_action.py | 131 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 src/sentry/incidents/endpoints/serializers/workflow_engine_action.py create mode 100644 tests/sentry/incidents/serializers/test_workflow_engine_action.py diff --git a/src/sentry/incidents/endpoints/serializers/workflow_engine_action.py b/src/sentry/incidents/endpoints/serializers/workflow_engine_action.py new file mode 100644 index 00000000000000..b0536a4a28e73b --- /dev/null +++ b/src/sentry/incidents/endpoints/serializers/workflow_engine_action.py @@ -0,0 +1,54 @@ +from sentry.api.serializers import Serializer +from sentry.incidents.endpoints.serializers.alert_rule_trigger_action import ( + get_identifier_from_action, + get_input_channel_id, + human_desc, +) +from sentry.incidents.models.alert_rule import AlertRuleTriggerAction +from sentry.notifications.models.notificationaction import ActionService +from sentry.workflow_engine.handlers.action.notification.handler import MetricAlertRegistryInvoker +from sentry.workflow_engine.models import ActionAlertRuleTriggerAction + + +class WorkflowEngineActionSerializer(Serializer): + def serialize(self, obj, attrs, user, **kwargs): + """ + Temporary serializer to take an Action and serialize it for the old metric alert rule endpoints + """ + from sentry.incidents.serializers import ACTION_TARGET_TYPE_TO_STRING + + aarta = ActionAlertRuleTriggerAction.objects.get(action=obj.id) + priority = obj.data.get("priority") + type_value = ActionService.get_value(obj.type) + target = MetricAlertRegistryInvoker.target(obj) + result = { + "id": str(aarta.alert_rule_trigger_action.id), + "alertRuleTriggerId": str(aarta.alert_rule_trigger_action.alert_rule_trigger.id), + "type": obj.type, + "targetType": ACTION_TARGET_TYPE_TO_STRING[ + AlertRuleTriggerAction.TargetType(obj.target_type) + ], + "targetIdentifier": get_identifier_from_action( + type_value, str(obj.target_identifier), obj.target_display + ), + "inputChannelId": get_input_channel_id(type_value, obj.target_identifier), + "integrationId": obj.integration_id, + "sentryAppId": obj.data.get("sentry_app_id"), + "dateCreated": obj.date_added, + "desc": human_desc( + type_value, + obj.target_type, + obj.target_identifier, + target, + obj.target_display, + obj.target_identifier, + priority, + ), + "priority": priority, + } + + # Check if action is a Sentry App that has Alert Rule UI Component settings + if obj.data.get("sentry_app_id") and obj.data.get("sentry_app_config"): + result["settings"] = obj.data.get("sentry_app_config") + + return result diff --git a/src/sentry/workflow_engine/handlers/action/notification/handler.py b/src/sentry/workflow_engine/handlers/action/notification/handler.py index 41a5eeda13a9a3..99b9b0f0a54fb4 100644 --- a/src/sentry/workflow_engine/handlers/action/notification/handler.py +++ b/src/sentry/workflow_engine/handlers/action/notification/handler.py @@ -3,6 +3,10 @@ from sentry.grouping.grouptype import ErrorGroupType from sentry.issues.grouptype import MetricIssuePOC +from sentry.models.team import Team +from sentry.notifications.models.notificationaction import ActionTarget +from sentry.users.services.user import RpcUser +from sentry.users.services.user.service import user_service from sentry.utils.registry import NoRegistrationExistsError, Registry from sentry.workflow_engine.handlers.action.notification.issue_alert import ( issue_alert_handler_registry, @@ -95,3 +99,21 @@ class MetricAlertRegistryInvoker(LegacyRegistryInvoker): def handle_workflow_action(job: WorkflowJob, action: Action, detector: Detector) -> None: # TODO(iamrajjoshi): Implement this pass + + @staticmethod + def target(action: Action) -> RpcUser | Team | str | None: + if action.target_identifier is None: + return None + + if action.target_type == ActionTarget.USER.value: + return user_service.get_user(user_id=int(action.target_identifier)) + elif action.target_type == ActionTarget.TEAM.value: + try: + return Team.objects.get(id=int(action.target_identifier)) + except Team.DoesNotExist: + pass + elif action.target_type == ActionTarget.SPECIFIC.value: + # TODO: This is only for email. We should have a way of validating that it's + # ok to contact this email. + return action.target_identifier + return None diff --git a/tests/sentry/incidents/serializers/test_workflow_engine_action.py b/tests/sentry/incidents/serializers/test_workflow_engine_action.py new file mode 100644 index 00000000000000..66dc94c3fc480a --- /dev/null +++ b/tests/sentry/incidents/serializers/test_workflow_engine_action.py @@ -0,0 +1,131 @@ +from sentry.api.serializers import serialize +from sentry.incidents.endpoints.serializers.alert_rule_trigger_action import ( + AlertRuleTriggerActionSerializer, +) +from sentry.incidents.endpoints.serializers.workflow_engine_action import ( + WorkflowEngineActionSerializer, +) +from sentry.incidents.models.alert_rule import AlertRuleTriggerAction +from sentry.notifications.models.notificationaction import ActionTarget +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.datetime import freeze_time +from sentry.workflow_engine.models import Action, ActionAlertRuleTriggerAction + + +@freeze_time("2018-12-11 03:21:34") +class TestActionSerializer(TestCase): + def setUp(self) -> None: + self.alert_rule = self.create_alert_rule() + self.trigger = self.create_alert_rule_trigger(alert_rule=self.alert_rule) + self.trigger_action = self.create_alert_rule_trigger_action(alert_rule_trigger=self.trigger) + + self.action = self.create_action( + type=Action.Type.EMAIL.value, + target_type=ActionTarget.USER, + target_identifier=self.user.id, + ) + ActionAlertRuleTriggerAction.objects.create( + action_id=self.action.id, + alert_rule_trigger_action_id=self.trigger_action.id, + ) + + def test_simple(self) -> None: + serialized_action = serialize(self.action, self.user, WorkflowEngineActionSerializer()) + assert serialized_action["type"] == "email" + assert serialized_action["targetType"] == "user" + assert serialized_action["targetIdentifier"] == str(self.user.id) + + serialized_alert_rule_trigger_action = serialize( + self.trigger_action, self.user, AlertRuleTriggerActionSerializer() + ) + assert serialized_action == serialized_alert_rule_trigger_action + + def test_sentry_app_action(self) -> None: + sentry_app = self.create_sentry_app( + organization=self.organization, + published=True, + verify_install=False, + name="Super Awesome App", + schema={"elements": [self.create_alert_rule_action_schema()]}, + ) + self.sentry_app_trigger = self.create_alert_rule_trigger(alert_rule=self.alert_rule) + self.create_sentry_app_installation( + slug=sentry_app.slug, organization=self.organization, user=self.user + ) + self.sentry_app_trigger_action = self.create_alert_rule_trigger_action( + alert_rule_trigger=self.sentry_app_trigger, + target_identifier=sentry_app.id, + type=AlertRuleTriggerAction.Type.SENTRY_APP, + target_type=AlertRuleTriggerAction.TargetType.SENTRY_APP, + sentry_app=sentry_app, + sentry_app_config=[ + {"name": "title", "value": "An alert"}, + {"summary": "Something happened here..."}, + {"name": "points", "value": "3"}, + {"name": "assignee", "value": "Hellboy"}, + ], + ) + self.sentry_app_action = self.create_action( + type=Action.Type.SENTRY_APP.value, + target_type=ActionTarget.SENTRY_APP, + target_identifier=sentry_app.id, + target_display=sentry_app.name, + data={ + "sentry_app_config": self.sentry_app_trigger_action.sentry_app_config, + "sentry_app_id": sentry_app.id, + }, + ) + ActionAlertRuleTriggerAction.objects.create( + action_id=self.sentry_app_action.id, + alert_rule_trigger_action_id=self.sentry_app_trigger_action.id, + ) + + serialized_action = serialize( + self.sentry_app_action, self.user, WorkflowEngineActionSerializer() + ) + assert serialized_action["type"] == "sentry_app" + assert serialized_action["alertRuleTriggerId"] == str(self.sentry_app_trigger.id) + assert serialized_action["targetType"] == "sentry_app" + assert serialized_action["targetIdentifier"] == sentry_app.id + assert serialized_action["sentryAppId"] == sentry_app.id + assert serialized_action["settings"] == self.sentry_app_action.data["sentry_app_config"] + + serialized_alert_rule_trigger_action = serialize( + self.sentry_app_trigger_action, self.user, AlertRuleTriggerActionSerializer() + ) + assert serialized_action == serialized_alert_rule_trigger_action + + def test_slack_action(self) -> None: + self.integration = self.create_slack_integration( + self.organization, + external_id="TXXXXXXX1", + user=self.user, + ) + self.slack_trigger = self.create_alert_rule_trigger(alert_rule=self.alert_rule) + self.slack_trigger_action = AlertRuleTriggerAction.objects.create( + alert_rule_trigger=self.slack_trigger, + target_identifier="123", + target_display="myChannel", + type=AlertRuleTriggerAction.Type.SLACK, + target_type=AlertRuleTriggerAction.TargetType.SPECIFIC, + integration_id=self.integration.id, + ) + self.slack_action = self.create_action( + type=Action.Type.SLACK.value, + target_type=ActionTarget.SPECIFIC, + target_identifier=self.slack_trigger_action.target_identifier, + target_display=self.slack_trigger_action.target_display, + integration_id=self.integration.id, + ) + ActionAlertRuleTriggerAction.objects.create( + action_id=self.slack_action.id, + alert_rule_trigger_action_id=self.slack_trigger_action.id, + ) + + serialized_action = serialize( + self.slack_action, self.user, WorkflowEngineActionSerializer() + ) + serialized_alert_rule_trigger_action = serialize( + self.slack_trigger_action, self.user, AlertRuleTriggerActionSerializer() + ) + assert serialized_action == serialized_alert_rule_trigger_action From 8a4cdbfc0ae0458e1c08d01123028623fbbdf27e Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:32:30 -0500 Subject: [PATCH 03/41] feat(schema-hints): Implement ordering for displayed hints (#86393) The hints are supposed to show up in the order they appear in the query builder dropdown (at least for now). That order is 1. section order 2. the rest of the tags --- .../explore/components/schemaHintsList.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/static/app/views/explore/components/schemaHintsList.tsx b/static/app/views/explore/components/schemaHintsList.tsx index 62822d50f6224b..a0aab10d92361b 100644 --- a/static/app/views/explore/components/schemaHintsList.tsx +++ b/static/app/views/explore/components/schemaHintsList.tsx @@ -7,6 +7,7 @@ import {getFunctionTags} from 'sentry/components/performance/spanSearchQueryBuil import {space} from 'sentry/styles/space'; import type {TagCollection} from 'sentry/types/group'; import type {AggregationKey} from 'sentry/utils/fields'; +import {SPANS_FILTER_KEY_SECTIONS} from 'sentry/views/insights/constants'; interface SchemaHintsListProps { numberTags: TagCollection; @@ -29,14 +30,20 @@ function SchemaHintsList({ return tags; }, [numberTags, stringTags, functionTags]); - const filterTagsList = useMemo(() => { - return Object.keys(filterTags).map(tag => filterTags[tag]); + // sort tags by the order they show up in the query builder + const filterTagsSorted = useMemo(() => { + const sectionKeys = SPANS_FILTER_KEY_SECTIONS.flatMap(section => section.children); + const sectionSortedTags = sectionKeys.map(key => filterTags[key]).filter(Boolean); + const otherKeys = Object.keys(filterTags).filter(key => !sectionKeys.includes(key)); + const otherTags = otherKeys.map(key => filterTags[key]).filter(Boolean); + return [...sectionSortedTags, ...otherTags]; }, [filterTags]); // only show 8 tags for now until we have a better way to decide to display them + // TODO: use resize observer to dynamically show more/less tags const first8Tags = useMemo(() => { - return filterTagsList.slice(0, 8); - }, [filterTagsList]); + return filterTagsSorted.slice(0, 8); + }, [filterTagsSorted]); const tagHintsText = useMemo(() => { return first8Tags.map(tag => `${tag?.key} is ...`); @@ -57,6 +64,7 @@ const SchemaHintsContainer = styled('div')` display: flex; flex-direction: row; gap: ${space(1)}; + overflow: hidden; `; const SchemaHintOption = styled(Button)` @@ -72,7 +80,7 @@ const SchemaHintOption = styled(Button)` flex-wrap: wrap; /* Ensures that filters do not grow outside of the container */ - min-width: 0; + min-width: fit-content; &[aria-selected='true'] { background-color: ${p => p.theme.gray100}; From 2ebcc6218b264ad14452268a8d4474f125a5c9c9 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 5 Mar 2025 12:32:42 -0800 Subject: [PATCH 04/41] fix(issues): Cleanup expect-errors in issue details components (#86361) --- static/app/views/issueDetails/groupDetails.tsx | 3 +-- .../groupSimilarIssues/similarStackTrace/item.tsx | 10 +++------- .../views/issueDetails/groupTags/groupTagsDrawer.tsx | 6 ++---- .../app/views/issueDetails/streamline/eventGraph.tsx | 3 +-- .../views/issueDetails/streamline/eventNavigation.tsx | 3 +-- .../issueDetails/streamline/sidebar/resources.tsx | 5 +++-- .../traceTimeline/useTraceTimelineEvents.tsx | 6 +++--- static/app/views/issueDetails/utils.tsx | 3 +-- 8 files changed, 15 insertions(+), 24 deletions(-) diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx index 8180463dce1906..9da21a6aca4d2c 100644 --- a/static/app/views/issueDetails/groupDetails.tsx +++ b/static/app/views/issueDetails/groupDetails.tsx @@ -581,8 +581,7 @@ const trackTabChanged = ({ const analyticsData = event ? event.tags .filter(({key}) => ['device', 'os', 'browser'].includes(key)) - .reduce((acc, {key, value}) => { - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + .reduce>((acc, {key, value}) => { acc[key] = value; return acc; }, {}) diff --git a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx index ec2913e159d25f..440d646320b81c 100644 --- a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx +++ b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx @@ -96,8 +96,7 @@ class Item extends Component { } Object.keys(stateForId).forEach(key => { - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - if (stateForId[key] === this.state[key]) { + if (stateForId[key] === this.state[key as keyof State]) { return; } this.setState(prevState => ({ @@ -111,9 +110,8 @@ class Item extends Component { const {aggregate, scoresByInterface, issue, hasSimilarityEmbeddingsFeature} = this.props; const {visible, busy} = this.state; - const similarInterfaces = hasSimilarityEmbeddingsFeature - ? ['exception'] - : ['exception', 'message']; + const similarInterfaces: Array<'exception' | 'message'> = + hasSimilarityEmbeddingsFeature ? ['exception'] : ['exception', 'message']; if (!visible) { return null; @@ -152,9 +150,7 @@ class Item extends Component { {similarInterfaces.map(interfaceName => { - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message const avgScore = aggregate?.[interfaceName]; - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message const scoreList = scoresByInterface?.[interfaceName] || []; // Check for valid number (and not NaN) diff --git a/static/app/views/issueDetails/groupTags/groupTagsDrawer.tsx b/static/app/views/issueDetails/groupTags/groupTagsDrawer.tsx index 75fba09f49b3f6..f879eebd12c740 100644 --- a/static/app/views/issueDetails/groupTags/groupTagsDrawer.tsx +++ b/static/app/views/issueDetails/groupTags/groupTagsDrawer.tsx @@ -79,8 +79,7 @@ export function GroupTagsDrawer({group}: {group: Group}) { const tagValues = useMemo( () => - data.reduce((valueMap, tag) => { - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + data.reduce>((valueMap, tag) => { valueMap[tag.key] = tag.topValues.map(tv => tv.value).join(' '); return valueMap; }, {}), @@ -99,8 +98,7 @@ export function GroupTagsDrawer({group}: {group: Group}) { tag => tag.key.includes(search) || tag.name.includes(search) || - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - tagValues[tag.key].includes(search) + tagValues[tag.key]?.includes(search) ); return searchedTags; }, [data, search, tagValues, highlightTagKeys]); diff --git a/static/app/views/issueDetails/streamline/eventGraph.tsx b/static/app/views/issueDetails/streamline/eventGraph.tsx index 09e03701c2738a..69674dcb070fc4 100644 --- a/static/app/views/issueDetails/streamline/eventGraph.tsx +++ b/static/app/views/issueDetails/streamline/eventGraph.tsx @@ -111,7 +111,7 @@ export function EventGraph({group, event, ...styleProps}: EventGraphProps) { }); const {data: uniqueUsersCount, isPending: isPendingUniqueUsersCount} = useApiQuery<{ - data: Array<{count_unique: number}>; + data: Array<{'count_unique(user)': number}>; }>( [ `/organizations/${organization.slug}/events/`, @@ -133,7 +133,6 @@ export function EventGraph({group, event, ...styleProps}: EventGraphProps) { staleTime: 60_000, } ); - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message const userCount = uniqueUsersCount?.data[0]?.['count_unique(user)'] ?? 0; const {series: eventSeries, count: eventCount} = useMemo(() => { diff --git a/static/app/views/issueDetails/streamline/eventNavigation.tsx b/static/app/views/issueDetails/streamline/eventNavigation.tsx index adc51e1d5fad91..0ae3792a063fc0 100644 --- a/static/app/views/issueDetails/streamline/eventNavigation.tsx +++ b/static/app/views/issueDetails/streamline/eventNavigation.tsx @@ -93,8 +93,7 @@ export function IssueEventNavigation({event, group}: IssueEventNavigationProps) onAction={key => { trackAnalytics('issue_details.issue_content_selected', { organization, - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - content: TabName[key], + content: TabName[key as keyof typeof TabName]!, }); }} items={[ diff --git a/static/app/views/issueDetails/streamline/sidebar/resources.tsx b/static/app/views/issueDetails/streamline/sidebar/resources.tsx index d812fd62583617..8c718bfff0cced 100644 --- a/static/app/views/issueDetails/streamline/sidebar/resources.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/resources.tsx @@ -18,8 +18,9 @@ export default function Resources({configResources, eventPlatform, group}: Props const organization = useOrganization(); const links: ResourceLink[] = [ ...configResources.links, - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - ...(configResources.linksByPlatform[eventPlatform ?? ''] ?? []), + ...(configResources.linksByPlatform[ + (eventPlatform ?? '') as keyof typeof configResources.linksByPlatform + ] ?? []), ]; return ( diff --git a/static/app/views/issueDetails/traceTimeline/useTraceTimelineEvents.tsx b/static/app/views/issueDetails/traceTimeline/useTraceTimelineEvents.tsx index a15159928eecac..f9661a92303f5d 100644 --- a/static/app/views/issueDetails/traceTimeline/useTraceTimelineEvents.tsx +++ b/static/app/views/issueDetails/traceTimeline/useTraceTimelineEvents.tsx @@ -158,15 +158,15 @@ export function useTraceTimelineEvents({event}: UseTraceTimelineEventsOptions): culprit: event.culprit, id: event.id, 'issue.id': Number(event.groupID), - message: event.message, project: event.projectID, // The project name for current event is not used 'project.name': '', timestamp: event.dateCreated!, title: event.title, transaction: '', - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - 'event.type': event['event.type'], + 'event.type': event.type === 'default' ? 'default' : 'error', + 'error.value': [event.message], + 'stack.function': [], }); } const timestamps = events.map(e => new Date(e.timestamp).getTime()); diff --git a/static/app/views/issueDetails/utils.tsx b/static/app/views/issueDetails/utils.tsx index 58c350194d1190..59c5c057cceb62 100644 --- a/static/app/views/issueDetails/utils.tsx +++ b/static/app/views/issueDetails/utils.tsx @@ -134,8 +134,7 @@ export function getSubscriptionReason(group: Group) { } if (reason && SUBSCRIPTION_REASONS.hasOwnProperty(reason)) { - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - return SUBSCRIPTION_REASONS[reason]; + return SUBSCRIPTION_REASONS[reason as keyof typeof SUBSCRIPTION_REASONS]; } } From 5023d1d35b83d5b985f59924195b36a76a7b705d Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 5 Mar 2025 12:32:55 -0800 Subject: [PATCH 05/41] feat(ui): Cleanup old bootstrap process (#85392) --- static/app/actionCreators/guides.tsx | 2 +- .../app/actionCreators/organization.spec.tsx | 55 +----------- static/app/actionCreators/organization.tsx | 85 +++++-------------- static/app/stores/projectsStore.tsx | 2 +- static/app/utils/getPreloadedData.spec.tsx | 22 ----- static/app/utils/getPreloadedData.ts | 43 ---------- .../projectDetail/projectDetail.spec.tsx | 7 +- .../app/views/projectDetail/projectDetail.tsx | 2 +- 8 files changed, 27 insertions(+), 191 deletions(-) delete mode 100644 static/app/utils/getPreloadedData.spec.tsx delete mode 100644 static/app/utils/getPreloadedData.ts diff --git a/static/app/actionCreators/guides.tsx b/static/app/actionCreators/guides.tsx index 7e155631ed24ef..395a9ebbc99030 100644 --- a/static/app/actionCreators/guides.tsx +++ b/static/app/actionCreators/guides.tsx @@ -79,7 +79,7 @@ export function recordFinish( if (isDemoModeEnabled() && tourTask && org) { const {tour, task} = tourTask; updateOnboardingTask(api, org, {task, status: 'complete', completionSeen: true}); - fetchOrganizationDetails(api, org.slug, true, false); + fetchOrganizationDetails(api, org.slug); demoEndModal({tour, orgSlug}); } diff --git a/static/app/actionCreators/organization.spec.tsx b/static/app/actionCreators/organization.spec.tsx index 4f56dd99ae2108..dcb07b0d950586 100644 --- a/static/app/actionCreators/organization.spec.tsx +++ b/static/app/actionCreators/organization.spec.tsx @@ -5,7 +5,6 @@ import {TeamFixture} from 'sentry-fixture/team'; import {fetchOrganizationDetails} from 'sentry/actionCreators/organization'; import * as OrganizationsActionCreator from 'sentry/actionCreators/organizations'; import OrganizationStore from 'sentry/stores/organizationStore'; -import PageFiltersStore from 'sentry/stores/pageFiltersStore'; import ProjectsStore from 'sentry/stores/projectsStore'; import TeamStore from 'sentry/stores/teamStore'; @@ -18,13 +17,8 @@ describe('OrganizationActionCreator', function () { const api = new MockApiClient(); beforeEach(function () { - MockApiClient.clearMockResponses(); jest.spyOn(TeamStore, 'loadInitialData'); - jest.spyOn(TeamStore, 'reset'); - jest.spyOn(PageFiltersStore, 'onReset'); jest.spyOn(ProjectsStore, 'loadInitialData'); - jest.spyOn(ProjectsStore, 'reset'); - jest.spyOn(OrganizationStore, 'reset'); jest.spyOn(OrganizationStore, 'onUpdate'); jest.spyOn(OrganizationStore, 'onFetchOrgError'); jest.spyOn(OrganizationsActionCreator, 'setActiveOrganization'); @@ -49,14 +43,9 @@ describe('OrganizationActionCreator', function () { body: teams, }); - fetchOrganizationDetails(api, org.slug, false); + fetchOrganizationDetails(api, org.slug); await tick(); - expect(OrganizationStore.reset).toHaveBeenCalled(); - expect(PageFiltersStore.onReset).toHaveBeenCalled(); - expect(ProjectsStore.reset).toHaveBeenCalled(); - expect(TeamStore.reset).toHaveBeenCalled(); - expect(getOrgMock).toHaveBeenCalledWith( `/organizations/${org.slug}/`, expect.anything() @@ -74,42 +63,6 @@ describe('OrganizationActionCreator', function () { expect(TeamStore.loadInitialData).toHaveBeenCalledWith(teams); expect(ProjectsStore.loadInitialData).toHaveBeenCalledWith(projects); - - expect(OrganizationStore.get().organization).toEqual(org); - }); - - it('silently fetches organization details', async function () { - const getOrgMock = MockApiClient.addMockResponse({ - url: `/organizations/${org.slug}/`, - body: org, - }); - MockApiClient.addMockResponse({ - url: `/organizations/${org.slug}/projects/`, - body: projects, - }); - MockApiClient.addMockResponse({ - url: `/organizations/${org.slug}/teams/`, - body: teams, - }); - - fetchOrganizationDetails(api, org.slug, true, true); - await tick(); - - expect(OrganizationStore.reset).not.toHaveBeenCalled(); - expect(PageFiltersStore.onReset).not.toHaveBeenCalled(); - expect(ProjectsStore.reset).not.toHaveBeenCalled(); - expect(TeamStore.reset).not.toHaveBeenCalled(); - - expect(getOrgMock).toHaveBeenCalledWith( - `/organizations/${org.slug}/`, - expect.anything() - ); - - expect(OrganizationStore.onUpdate).toHaveBeenCalledWith(org, {replace: true}); - expect(OrganizationsActionCreator.setActiveOrganization).toHaveBeenCalled(); - - expect(TeamStore.loadInitialData).toHaveBeenCalledWith(teams); - expect(ProjectsStore.loadInitialData).toHaveBeenCalledWith(projects); }); it('errors out correctly', async function () { @@ -126,13 +79,9 @@ describe('OrganizationActionCreator', function () { body: teams, }); - fetchOrganizationDetails(api, org.slug, false); + fetchOrganizationDetails(api, org.slug); await tick(); - expect(OrganizationStore.reset).toHaveBeenCalled(); - expect(PageFiltersStore.onReset).toHaveBeenCalled(); - expect(ProjectsStore.reset).toHaveBeenCalled(); - expect(TeamStore.reset).toHaveBeenCalled(); expect(getOrgMock).toHaveBeenCalledWith( `/organizations/${org.slug}/`, expect.anything() diff --git a/static/app/actionCreators/organization.tsx b/static/app/actionCreators/organization.tsx index eccc68bd317d31..612fba69cfec46 100644 --- a/static/app/actionCreators/organization.tsx +++ b/static/app/actionCreators/organization.tsx @@ -9,7 +9,6 @@ import {setActiveOrganization} from 'sentry/actionCreators/organizations'; import type {ApiResult} from 'sentry/api'; import {Client} from 'sentry/api'; import OrganizationStore from 'sentry/stores/organizationStore'; -import PageFiltersStore from 'sentry/stores/pageFiltersStore'; import ProjectsStore from 'sentry/stores/projectsStore'; import TeamStore from 'sentry/stores/teamStore'; import type {Organization, Team} from 'sentry/types/organization'; @@ -19,27 +18,14 @@ import { addOrganizationFeaturesHandler, buildSentryFeaturesHandler, } from 'sentry/utils/featureFlags'; -import {getPreloadedDataPromise} from 'sentry/utils/getPreloadedData'; import parseLinkHeader from 'sentry/utils/parseLinkHeader'; import type RequestError from 'sentry/utils/requestError/requestError'; -async function fetchOrg( - api: Client, - slug: string, - usePreload?: boolean -): Promise { - const [org] = await getPreloadedDataPromise( - 'organization', - slug, - () => - // This data should get preloaded in static/sentry/index.ejs - // If this url changes make sure to update the preload - api.requestPromise(`/organizations/${slug}/`, { - includeAllArgs: true, - query: {detailed: 0, include_feature_flags: 1}, - }), - usePreload - ); +async function fetchOrg(api: Client, slug: string): Promise { + const [org] = await api.requestPromise(`/organizations/${slug}/`, { + includeAllArgs: true, + query: {detailed: 0, include_feature_flags: 1}, + }); if (!org) { throw new Error('retrieved organization is falsey'); @@ -64,39 +50,25 @@ async function fetchOrg( } async function fetchProjectsAndTeams( - slug: string, - usePreload?: boolean + slug: string ): Promise<[ApiResult, ApiResult]> { // Create a new client so the request is not cancelled const uncancelableApi = new Client(); - const projectsPromise = getPreloadedDataPromise( - 'projects', - slug, - () => - // This data should get preloaded in static/sentry/index.ejs - // If this url changes make sure to update the preload - uncancelableApi.requestPromise(`/organizations/${slug}/projects/`, { - includeAllArgs: true, - query: { - all_projects: 1, - collapse: ['latestDeploys', 'unusedFeatures'], - }, - }), - usePreload + const projectsPromise = uncancelableApi.requestPromise( + `/organizations/${slug}/projects/`, + { + includeAllArgs: true, + query: { + all_projects: 1, + collapse: ['latestDeploys', 'unusedFeatures'], + }, + } ); - const teamsPromise = getPreloadedDataPromise( - 'teams', - slug, - // This data should get preloaded in static/sentry/index.ejs - // If this url changes make sure to update the preload - () => - uncancelableApi.requestPromise(`/organizations/${slug}/teams/`, { - includeAllArgs: true, - }), - usePreload - ); + const teamsPromise = uncancelableApi.requestPromise(`/organizations/${slug}/teams/`, { + includeAllArgs: true, + }); try { return await Promise.all([projectsPromise, teamsPromise]); @@ -122,23 +94,8 @@ async function fetchProjectsAndTeams( * * @param api A reference to the api client * @param slug The organization slug - * @param silent Should we silently update the organization (do not clear the - * current organization in the store) - * @param usePreload Should the preloaded data be used if available? */ -export async function fetchOrganizationDetails( - api: Client, - slug: string, - silent = true, - usePreload = false -): Promise { - if (!silent) { - OrganizationStore.reset(); - ProjectsStore.reset(); - TeamStore.reset(); - PageFiltersStore.onReset(); - } - +export async function fetchOrganizationDetails(api: Client, slug: string): Promise { const getErrorMessage = (err: RequestError) => { if (typeof err.responseJSON?.detail === 'string') { return err.responseJSON?.detail; @@ -152,7 +109,7 @@ export async function fetchOrganizationDetails( const loadOrganization = async () => { let org: Organization | undefined = undefined; try { - org = await fetchOrg(api, slug, usePreload); + org = await fetchOrg(api, slug); } catch (err) { if (!err) { throw err; @@ -176,7 +133,7 @@ export async function fetchOrganizationDetails( }; const loadTeamsAndProjects = async () => { - const [[projects], [teams, , resp]] = await fetchProjectsAndTeams(slug, usePreload); + const [[projects], [teams, , resp]] = await fetchProjectsAndTeams(slug); ProjectsStore.loadInitialData(projects ?? []); diff --git a/static/app/stores/projectsStore.tsx b/static/app/stores/projectsStore.tsx index 5a0b72bbadc887..0583552a70c12e 100644 --- a/static/app/stores/projectsStore.tsx +++ b/static/app/stores/projectsStore.tsx @@ -93,7 +93,7 @@ const storeConfig: ProjectsStoreDefinition = { this.state = {...this.state, projects: newProjects}; // Reload organization details since we've created a new project - fetchOrganizationDetails(this.api, orgSlug, true, false); + fetchOrganizationDetails(this.api, orgSlug); this.trigger(new Set([project.id])); }, diff --git a/static/app/utils/getPreloadedData.spec.tsx b/static/app/utils/getPreloadedData.spec.tsx deleted file mode 100644 index 097734cb6cdfe5..00000000000000 --- a/static/app/utils/getPreloadedData.spec.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {getPreloadedDataPromise} from './getPreloadedData'; - -describe('getPreloadedDataPromise', () => { - beforeEach(() => { - window.__sentry_preload = { - orgSlug: 'slug', - }; - }); - it('should register fallback promise', async () => { - const fallback = jest.fn(() => Promise.resolve('fallback')); - const result = await getPreloadedDataPromise('organization', 'slug', fallback as any); - expect(result).toBe('fallback'); - expect(window.__sentry_preload!.organization_fallback).toBeInstanceOf(Promise); - }); - it('should only call fallback on failure', async () => { - window.__sentry_preload!.organization = Promise.resolve('success') as any; - const fallback = jest.fn(); - const result = await getPreloadedDataPromise('organization', 'slug', fallback, true); - expect(result).toBe('success'); - expect(fallback).not.toHaveBeenCalled(); - }); -}); diff --git a/static/app/utils/getPreloadedData.ts b/static/app/utils/getPreloadedData.ts deleted file mode 100644 index 408c99a9526f28..00000000000000 --- a/static/app/utils/getPreloadedData.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type {ApiResult} from 'sentry/api'; - -export async function getPreloadedDataPromise( - name: 'organization' | 'projects' | 'teams', - slug: string, - fallback: () => Promise, - usePreload?: boolean -): Promise { - const data = window.__sentry_preload; - /** - * Save the fallback promise to `__sentry_preload` to allow the sudo modal to wait - * for the promise to resolve - */ - const wrappedFallback = () => { - const fallbackAttribute = `${name}_fallback` as const; - const promise = fallback(); - if (data) { - data[fallbackAttribute] = promise; - } - return promise; - }; - - try { - if ( - !usePreload || - !data || - !data.orgSlug || - data.orgSlug.toLowerCase() !== slug.toLowerCase() || - !data[name] || - !data[name].then - ) { - return await wrappedFallback(); - } - const result = await data[name].catch(() => null); - if (!result) { - return await wrappedFallback(); - } - return await result; - } catch (_) { - // - } - return await wrappedFallback(); -} diff --git a/static/app/views/projectDetail/projectDetail.spec.tsx b/static/app/views/projectDetail/projectDetail.spec.tsx index b987ee03575eab..4f4d7c13988db9 100644 --- a/static/app/views/projectDetail/projectDetail.spec.tsx +++ b/static/app/views/projectDetail/projectDetail.spec.tsx @@ -45,12 +45,7 @@ describe('ProjectDetail', function () { // By clicking on the retry button, we should attempt to fetch the organization details again await userEvent.click(screen.getByRole('button', {name: 'Retry'})); - expect(fetchOrganizationDetails).toHaveBeenCalledWith( - api, - organization.slug, - true, - false - ); + expect(fetchOrganizationDetails).toHaveBeenCalledWith(api, organization.slug); }); it('Render warning if user is not a member of the project', async function () { diff --git a/static/app/views/projectDetail/projectDetail.tsx b/static/app/views/projectDetail/projectDetail.tsx index 70bdfbc6eaa479..1540fa42dabb05 100644 --- a/static/app/views/projectDetail/projectDetail.tsx +++ b/static/app/views/projectDetail/projectDetail.tsx @@ -85,7 +85,7 @@ export default function ProjectDetail({router, location, organization}: Props) { }, [hasTransactions, hasSessions]); const onRetryProjects = useCallback(() => { - fetchOrganizationDetails(api, params.orgId!, true, false); + fetchOrganizationDetails(api, params.orgId!); }, [api, params.orgId]); const handleSearch = useCallback( From 921855818561cd073675415d89b4a848ec244da5 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 5 Mar 2025 15:54:54 -0500 Subject: [PATCH 06/41] ref(insights): Replace usage of `` in `` (#86300) This reverts commit 3febd2b9b25230116f32b3928092318f52ed2f92 and extends the [original PR](https://github.com/getsentry/sentry/pull/86129) It was reverted because it was causing crashes (https://sentry.sentry.io/issues/6352301875/?project=11276&referrer=github-pr-bot) due to a cache key conflict in react-query between `useQuery` and `useInfiniteQuery` ([read more about it here](https://tkdodo.eu/blog/effective-react-query-keys#caching-data)). This would happen in Sentry if you visited an insights page (which uses an "infinite" query) and then went to e.g. the issue details page (which has it's own hook [useReleaseMarkLineSeries](https://github.com/getsentry/sentry/blob/master/static/app/views/issueDetails/streamline/hooks/useReleaseMarkLineSeries.tsx#L25-L45) that uses a "normal" query). Both queries use the same cache key because infinite queries handles the `cursor` param for us (thus it not being part of the query key). I've made some additional changes to the hook: it's now moved to `app/utils` as it'll be needed in other places. it's also more generic now. From the original PR: > This will be needed for the new release bubbles feature as we will show releases on the mini widgets instead of only on fullscreen. Using `` renderer will cause duplicate requests to the API. ref https://github.com/getsentry/sentry/issues/85779 --- static/app/utils/queryClient.tsx | 4 + static/app/utils/useReleaseStats.tsx | 75 +++++++++++++++++++ .../views/resourcesLandingPage.spec.tsx | 10 +++ .../cache/views/cacheLandingPage.spec.tsx | 11 +++ .../components/insightsTimeSeriesWidget.tsx | 43 ++++------- .../views/databaseLandingPage.spec.tsx | 11 +++ .../views/databaseSpanSummaryPage.spec.tsx | 11 +++ .../http/views/httpDomainSummaryPage.spec.tsx | 11 +++ .../http/views/httpLandingPage.spec.tsx | 11 +++ .../queues/charts/latencyChart.spec.tsx | 11 +++ .../queues/charts/throughputChart.spec.tsx | 11 +++ .../views/destinationSummaryPage.spec.tsx | 10 +++ .../queues/views/queuesLandingPage.spec.tsx | 10 +++ 13 files changed, 199 insertions(+), 30 deletions(-) create mode 100644 static/app/utils/useReleaseStats.tsx diff --git a/static/app/utils/queryClient.tsx b/static/app/utils/queryClient.tsx index faa41d2c91dfd8..6795b27ef12768 100644 --- a/static/app/utils/queryClient.tsx +++ b/static/app/utils/queryClient.tsx @@ -62,6 +62,7 @@ export type ApiQueryKey = Record, Record >, + additionalKey?: string, ]; export interface UseApiQueryOptions @@ -287,9 +288,11 @@ function parsePageParam(dir: 'previous' | 'next') { export function useInfiniteApiQuery({ queryKey, enabled, + staleTime, }: { queryKey: ApiQueryKey; enabled?: boolean; + staleTime?: number; }) { const api = useApi({persistInFlight: PERSIST_IN_FLIGHT}); const query = useInfiniteQuery({ @@ -299,6 +302,7 @@ export function useInfiniteApiQuery({ getNextPageParam: parsePageParam('next'), initialPageParam: undefined, enabled: enabled ?? true, + staleTime, }); return query; diff --git a/static/app/utils/useReleaseStats.tsx b/static/app/utils/useReleaseStats.tsx new file mode 100644 index 00000000000000..525040d47c5231 --- /dev/null +++ b/static/app/utils/useReleaseStats.tsx @@ -0,0 +1,75 @@ +import {useEffect} from 'react'; + +import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; +import {useInfiniteApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; + +interface ReleaseMetaBasic { + date: string; + version: string; +} + +interface UseReleaseStatsParams { + datetime: Parameters[0]; + environments: readonly string[]; + projects: readonly number[]; + + /** + * Max number of pages to fetch. Default is 10 pages, which should be + * sufficient to fetch "all" releases. + */ + maxPages?: number; +} + +/** + * This is intended to fetch "all" releases, we have a default limit of + * 10 pages (of 1000 results) to be slightly cautious. + */ +export function useReleaseStats( + {datetime, environments, projects, maxPages = 10}: UseReleaseStatsParams, + queryOptions: {staleTime: number} = {staleTime: Infinity} +) { + const organization = useOrganization(); + + const { + isLoading, + isFetching, + fetchNextPage, + hasNextPage, + isPending, + isError, + error, + data, + } = useInfiniteApiQuery({ + queryKey: [ + `/organizations/${organization.slug}/releases/stats/`, + { + query: { + environment: environments, + project: projects, + ...normalizeDateTimeParams(datetime), + }, + }, + // This is here to prevent a cache key conflict between normal queries and + // "infinite" queries. Read more here: https://tkdodo.eu/blog/effective-react-query-keys#caching-data + 'load-all', + ], + ...queryOptions, + }); + + const currentNumberPages = data?.pages.length ?? 0; + + useEffect(() => { + if (!isFetching && hasNextPage && currentNumberPages + 1 < maxPages) { + fetchNextPage(); + } + }, [isFetching, hasNextPage, fetchNextPage, maxPages, currentNumberPages]); + + return { + isLoading, + isPending, + isError, + error, + releases: data?.pages.flatMap(([pageData]) => pageData), + }; +} diff --git a/static/app/views/insights/browser/resources/views/resourcesLandingPage.spec.tsx b/static/app/views/insights/browser/resources/views/resourcesLandingPage.spec.tsx index cc479fd495c115..64ad8fb7bfeda6 100644 --- a/static/app/views/insights/browser/resources/views/resourcesLandingPage.spec.tsx +++ b/static/app/views/insights/browser/resources/views/resourcesLandingPage.spec.tsx @@ -27,6 +27,9 @@ jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/usePageFilters'); jest.mock('sentry/utils/useProjects'); jest.mock('sentry/views/insights/common/queries/useOnboardingProject'); +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; + +jest.mock('sentry/utils/useReleaseStats'); const requestMocks: Record = {}; @@ -171,6 +174,13 @@ const setupMocks = () => { reloadProjects: jest.fn(), placeholders: [], }); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); }; const setupMockRequests = (organization: Organization) => { diff --git a/static/app/views/insights/cache/views/cacheLandingPage.spec.tsx b/static/app/views/insights/cache/views/cacheLandingPage.spec.tsx index f57a6c1565fce0..b89a0c99094d62 100644 --- a/static/app/views/insights/cache/views/cacheLandingPage.spec.tsx +++ b/static/app/views/insights/cache/views/cacheLandingPage.spec.tsx @@ -19,6 +19,9 @@ jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/usePageFilters'); jest.mock('sentry/utils/useProjects'); jest.mock('sentry/views/insights/common/queries/useOnboardingProject'); +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; + +jest.mock('sentry/utils/useReleaseStats'); const requestMocks = { missRateChart: jest.fn(), @@ -79,6 +82,14 @@ describe('CacheLandingPage', function () { initiallyLoaded: false, }); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + beforeEach(function () { jest.clearAllMocks(); setRequestMocks(organization); diff --git a/static/app/views/insights/common/components/insightsTimeSeriesWidget.tsx b/static/app/views/insights/common/components/insightsTimeSeriesWidget.tsx index 05a2f261b47c8f..a175daea8e8725 100644 --- a/static/app/views/insights/common/components/insightsTimeSeriesWidget.tsx +++ b/static/app/views/insights/common/components/insightsTimeSeriesWidget.tsx @@ -2,11 +2,11 @@ import styled from '@emotion/styled'; import {openInsightChartModal} from 'sentry/actionCreators/modal'; import {Button} from 'sentry/components/button'; -import ReleaseSeries from 'sentry/components/charts/releaseSeries'; import {CHART_PALETTE} from 'sentry/constants/chartPalette'; import {IconExpand} from 'sentry/icons'; import {t} from 'sentry/locale'; import usePageFilters from 'sentry/utils/usePageFilters'; +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; import {MISSING_DATA_MESSAGE} from 'sentry/views/dashboards/widgets/common/settings'; import type {Aliases} from 'sentry/views/dashboards/widgets/common/types'; import { @@ -38,8 +38,12 @@ export interface InsightsTimeSeriesWidgetProps { export function InsightsTimeSeriesWidget(props: InsightsTimeSeriesWidgetProps) { const pageFilters = usePageFilters(); - const {start, end, period, utc} = pageFilters.selection.datetime; - const {projects, environments} = pageFilters.selection; + const {releases: releasesWithDate} = useReleaseStats(pageFilters.selection); + const releases = + releasesWithDate?.map(({date, version}) => ({ + timestamp: date, + version, + })) ?? []; const visualizationProps: TimeSeriesWidgetVisualizationProps = { visualizationType: props.visualizationType, @@ -108,33 +112,12 @@ export function InsightsTimeSeriesWidget(props: InsightsTimeSeriesWidgetProps) { openInsightChartModal({ title: props.title, children: ( - - {({releases}) => { - return ( - - ({ - timestamp: release.date, - version: release.version, - })) - : [] - } - /> - - ); - }} - + + + ), }); }} diff --git a/static/app/views/insights/database/views/databaseLandingPage.spec.tsx b/static/app/views/insights/database/views/databaseLandingPage.spec.tsx index ab830349c0b2f1..19ad1e41ada697 100644 --- a/static/app/views/insights/database/views/databaseLandingPage.spec.tsx +++ b/static/app/views/insights/database/views/databaseLandingPage.spec.tsx @@ -13,6 +13,9 @@ jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/usePageFilters'); jest.mock('sentry/utils/useProjects'); jest.mock('sentry/views/insights/common/queries/useOnboardingProject'); +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; + +jest.mock('sentry/utils/useReleaseStats'); describe('DatabaseLandingPage', function () { const organization = OrganizationFixture({features: ['insights-initial-modules']}); @@ -60,6 +63,14 @@ describe('DatabaseLandingPage', function () { key: '', }); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + beforeEach(function () { MockApiClient.addMockResponse({ url: '/organizations/org-slug/sdk-updates/', diff --git a/static/app/views/insights/database/views/databaseSpanSummaryPage.spec.tsx b/static/app/views/insights/database/views/databaseSpanSummaryPage.spec.tsx index 3062081e9c43e6..dc13db23a3613c 100644 --- a/static/app/views/insights/database/views/databaseSpanSummaryPage.spec.tsx +++ b/static/app/views/insights/database/views/databaseSpanSummaryPage.spec.tsx @@ -10,6 +10,9 @@ import {DatabaseSpanSummaryPage} from 'sentry/views/insights/database/views/data jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/usePageFilters'); +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; + +jest.mock('sentry/utils/useReleaseStats'); describe('DatabaseSpanSummaryPage', function () { const organization = OrganizationFixture({ @@ -44,6 +47,14 @@ describe('DatabaseSpanSummaryPage', function () { key: '', }); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + beforeEach(function () { jest.clearAllMocks(); }); diff --git a/static/app/views/insights/http/views/httpDomainSummaryPage.spec.tsx b/static/app/views/insights/http/views/httpDomainSummaryPage.spec.tsx index 27c1409dc750dc..27753f3ec47771 100644 --- a/static/app/views/insights/http/views/httpDomainSummaryPage.spec.tsx +++ b/static/app/views/insights/http/views/httpDomainSummaryPage.spec.tsx @@ -13,6 +13,9 @@ import {HTTPDomainSummaryPage} from 'sentry/views/insights/http/views/httpDomain jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/usePageFilters'); +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; + +jest.mock('sentry/utils/useReleaseStats'); describe('HTTPSummaryPage', function () { const organization = OrganizationFixture({features: ['insights-initial-modules']}); @@ -52,6 +55,14 @@ describe('HTTPSummaryPage', function () { key: '', }); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + beforeEach(function () { jest.clearAllMocks(); diff --git a/static/app/views/insights/http/views/httpLandingPage.spec.tsx b/static/app/views/insights/http/views/httpLandingPage.spec.tsx index 570d61a0c7184b..409522d7db29ca 100644 --- a/static/app/views/insights/http/views/httpLandingPage.spec.tsx +++ b/static/app/views/insights/http/views/httpLandingPage.spec.tsx @@ -13,6 +13,9 @@ jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/usePageFilters'); jest.mock('sentry/utils/useProjects'); jest.mock('sentry/views/insights/common/queries/useOnboardingProject'); +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; + +jest.mock('sentry/utils/useReleaseStats'); describe('HTTPLandingPage', function () { const organization = OrganizationFixture({ @@ -75,6 +78,14 @@ describe('HTTPLandingPage', function () { initiallyLoaded: false, }); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + beforeEach(function () { jest.clearAllMocks(); diff --git a/static/app/views/insights/queues/charts/latencyChart.spec.tsx b/static/app/views/insights/queues/charts/latencyChart.spec.tsx index 10ac0943eeb122..98cdc5fbcc354e 100644 --- a/static/app/views/insights/queues/charts/latencyChart.spec.tsx +++ b/static/app/views/insights/queues/charts/latencyChart.spec.tsx @@ -2,12 +2,23 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary'; +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; import {LatencyChart} from 'sentry/views/insights/queues/charts/latencyChart'; import {Referrer} from 'sentry/views/insights/queues/referrers'; +jest.mock('sentry/utils/useReleaseStats'); + describe('latencyChart', () => { const organization = OrganizationFixture(); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + let eventsStatsMock: jest.Mock; beforeEach(() => { diff --git a/static/app/views/insights/queues/charts/throughputChart.spec.tsx b/static/app/views/insights/queues/charts/throughputChart.spec.tsx index 916cd6f7ba24b3..62da32d53f55e3 100644 --- a/static/app/views/insights/queues/charts/throughputChart.spec.tsx +++ b/static/app/views/insights/queues/charts/throughputChart.spec.tsx @@ -2,14 +2,25 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary'; +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; import {ThroughputChart} from 'sentry/views/insights/queues/charts/throughputChart'; import {Referrer} from 'sentry/views/insights/queues/referrers'; +jest.mock('sentry/utils/useReleaseStats'); + describe('throughputChart', () => { const organization = OrganizationFixture(); let eventsStatsMock!: jest.Mock; + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + beforeEach(() => { eventsStatsMock = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events-stats/`, diff --git a/static/app/views/insights/queues/views/destinationSummaryPage.spec.tsx b/static/app/views/insights/queues/views/destinationSummaryPage.spec.tsx index 717d50ec607e0e..f91bbb8ff2b01f 100644 --- a/static/app/views/insights/queues/views/destinationSummaryPage.spec.tsx +++ b/static/app/views/insights/queues/views/destinationSummaryPage.spec.tsx @@ -5,11 +5,13 @@ import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestin import {useLocation} from 'sentry/utils/useLocation'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; import PageWithProviders from 'sentry/views/insights/queues/views/destinationSummaryPage'; jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/usePageFilters'); jest.mock('sentry/utils/useProjects'); +jest.mock('sentry/utils/useReleaseStats'); describe('destinationSummaryPage', () => { const organization = OrganizationFixture({ @@ -54,6 +56,14 @@ describe('destinationSummaryPage', () => { initiallyLoaded: false, }); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + let eventsMock: jest.Mock; let eventsStatsMock: jest.Mock; diff --git a/static/app/views/insights/queues/views/queuesLandingPage.spec.tsx b/static/app/views/insights/queues/views/queuesLandingPage.spec.tsx index c9f3a876c0887b..5b9d32bc1fd058 100644 --- a/static/app/views/insights/queues/views/queuesLandingPage.spec.tsx +++ b/static/app/views/insights/queues/views/queuesLandingPage.spec.tsx @@ -6,11 +6,13 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {useLocation} from 'sentry/utils/useLocation'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; +import {useReleaseStats} from 'sentry/utils/useReleaseStats'; import QueuesLandingPage from 'sentry/views/insights/queues/views/queuesLandingPage'; jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/usePageFilters'); jest.mock('sentry/utils/useProjects'); +jest.mock('sentry/utils/useReleaseStats'); describe('queuesLandingPage', () => { const organization = OrganizationFixture({ @@ -58,6 +60,14 @@ describe('queuesLandingPage', () => { initiallyLoaded: false, }); + jest.mocked(useReleaseStats).mockReturnValue({ + isLoading: false, + isPending: false, + isError: false, + error: null, + releases: [], + }); + let eventsMock: jest.Mock; let eventsStatsMock: jest.Mock; From a55f3d8109297d5be4e2eebc9bf3b7fbbb4f37c0 Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Wed, 5 Mar 2025 12:59:29 -0800 Subject: [PATCH 07/41] chore(grouping): Remove `organizations:grouphash-metadata-creation` feature flag (#86310) This removes the no-longer-used[1] `organizations:grouphash-metadata-creation` feature flag now that grouphash metadata has been rolled out to everyone. [1] https://github.com/getsentry/sentry/pull/86309 --- src/sentry/features/temporary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 18e6869a08f301..7e11c7e66e8567 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -117,8 +117,6 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:gen-ai-features", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable disabling gitlab integrations when broken is detected manager.add("organizations:gitlab-disable-on-broken", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) - # Allow creating `GroupHashMetadata` records - manager.add("organizations:grouphash-metadata-creation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Allow events with hybrid fingerprints to be sent to Seer for grouping manager.add("organizations:grouping-hybrid-fingerprint-seer-usage", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable increased issue_owners rate limit for auto-assignment From 8d8c13ff1e2b304ed250b44bf8cc724494abf4bb Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Thu, 6 Mar 2025 04:13:19 +0700 Subject: [PATCH 08/41] feat(autofix): Show root cause and solution in sidebar (#86354) Shows the root cause and solution when it's available on the sidebar ![CleanShot 2025-03-04 at 15 37 20](https://github.com/user-attachments/assets/9e987ff9-9329-40df-8c5e-bb7b7036fe8e) Loading State: https://github.com/user-attachments/assets/ceee2133-e0c3-464b-b8d0-fde163d0ee82 Adds the root cause summary and solution summary to the cards in the drawer too: ![CleanShot 2025-03-04 at 16 01 04](https://github.com/user-attachments/assets/c50d5729-d0d1-48d5-a253-7e6947acf5eb) --- .../events/autofix/autofixSolution.tsx | 9 + .../events/autofix/autofixSteps.tsx | 1 + static/app/components/events/autofix/types.ts | 2 + .../group/groupSummaryWithAutofix.tsx | 275 ++++++++++++++++++ .../streamline/sidebar/solutionsHubDrawer.tsx | 39 ++- .../streamline/sidebar/solutionsSection.tsx | 94 +++--- .../sidebar/solutionsSectionCtaButton.tsx | 36 +-- 7 files changed, 389 insertions(+), 67 deletions(-) create mode 100644 static/app/components/group/groupSummaryWithAutofix.tsx diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx index 12661f963a51d6..217e15ffd82f35 100644 --- a/static/app/components/events/autofix/autofixSolution.tsx +++ b/static/app/components/events/autofix/autofixSolution.tsx @@ -24,6 +24,7 @@ import { import {IconCheckmark, IconClose, IconEdit, IconFix} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import {singleLineRenderer} from 'sentry/utils/marked'; import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient'; import testableTransition from 'sentry/utils/testableTransition'; import useApi from 'sentry/utils/useApi'; @@ -117,6 +118,7 @@ type AutofixSolutionProps = { solutionSelected: boolean; changesDisabled?: boolean; customSolution?: string; + description?: string; previousDefaultStepIndex?: number; previousInsightCount?: number; }; @@ -148,10 +150,12 @@ function SolutionDescription({ runId, previousDefaultStepIndex, previousInsightCount, + description, }: { groupId: string; runId: string; solution: AutofixSolutionTimelineEvent[]; + description?: string; previousDefaultStepIndex?: number; previousInsightCount?: number; }) { @@ -177,6 +181,9 @@ function SolutionDescription({ )}
+ {description && ( +

+ )}

@@ -240,6 +247,7 @@ function CopySolutionButton({ function AutofixSolutionDisplay({ solution, + description, groupId, runId, previousDefaultStepIndex, @@ -406,6 +414,7 @@ function AutofixSolutionDisplay({ ) : ( void; +} + +const getRootCauseDescription = (autofixData: AutofixData) => { + const rootCause = autofixData.steps?.find( + step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS + ); + if (!rootCause) { + return null; + } + return rootCause.causes.at(0)?.description ?? null; +}; + +const getSolutionDescription = (autofixData: AutofixData) => { + const solution = autofixData.steps?.find( + step => step.type === AutofixStepType.SOLUTION + ); + if (!solution) { + return null; + } + + return solution.description ?? null; +}; + +const getSolutionIsLoading = (autofixData: AutofixData) => { + const solutionProgressStep = autofixData.steps?.find( + step => step.key === 'solution_processing' + ); + return solutionProgressStep?.status === AutofixStatus.PROCESSING; +}; + +export function GroupSummaryWithAutofix({ + group, + event, + project, + preview = false, +}: { + event: Event | undefined; + group: Group; + project: Project; + preview?: boolean; +}) { + const autofixData = useAutofixData({groupId: group.id}); + + const openSolutionsDrawer = useOpenSolutionsDrawer(group, project, event); + + const rootCauseDescription = useMemo( + () => (autofixData ? getRootCauseDescription(autofixData) : null), + [autofixData] + ); + + const solutionDescription = useMemo( + () => (autofixData ? getSolutionDescription(autofixData) : null), + [autofixData] + ); + + const solutionIsLoading = useMemo( + () => (autofixData ? getSolutionIsLoading(autofixData) : false), + [autofixData] + ); + + if (rootCauseDescription) { + return ( + + ); + } + + return ; +} + +function AutofixSummary({ + rootCauseDescription, + solutionDescription, + solutionIsLoading, + openSolutionsDrawer, +}: { + openSolutionsDrawer: () => void; + rootCauseDescription: string | null; + solutionDescription: string | null; + solutionIsLoading: boolean; +}) { + const insightCards: InsightCardObject[] = [ + { + id: 'root_cause_description', + title: t('Root cause'), + insight: rootCauseDescription, + icon: , + onClick: openSolutionsDrawer, + }, + + ...(solutionDescription || solutionIsLoading + ? [ + { + id: 'solution_description', + title: t('Solution'), + insight: solutionDescription, + icon: , + isLoading: solutionIsLoading, + onClick: openSolutionsDrawer, + }, + ] + : []), + ]; + + return ( +
+ + + {insightCards.map(card => { + if (!card.isLoading && !card.insight) { + return null; + } + + return ( + + + + {card.icon} + {card.title} + + + {card.isLoading ? ( + + ) : ( + + {card.insightElement} + {card.insight && ( +
+ )} + + )} + + + + ); + })} + + +
+ ); +} + +const Content = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(1)}; + position: relative; +`; + +const InsightGrid = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(1.5)}; +`; + +const InsightCardButton = styled(motion.button)` + border-radius: ${p => p.theme.borderRadius}; + border: 1px solid ${p => p.theme.border}; + width: 100%; + min-height: 0; + position: relative; + overflow: hidden; + cursor: pointer; + padding: 0; + box-shadow: ${p => p.theme.dropShadowLight}; + background-color: ${p => p.theme.background}; + + &:hover { + background-color: ${p => p.theme.backgroundSecondary}; + } + + &:active { + opacity: 0.8; + } +`; + +const InsightCard = styled('div')` + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; +`; + +const CardTitle = styled('div')<{preview?: boolean}>` + display: flex; + align-items: center; + gap: ${space(1)}; + color: ${p => p.theme.subText}; + padding: ${space(1)} ${space(1.5)} ${space(1)}; + border-bottom: 1px solid ${p => p.theme.innerBorder}; +`; + +const CardTitleText = styled('p')` + margin: 0; + font-size: ${p => p.theme.fontSizeMedium}; + font-weight: ${p => p.theme.fontWeightBold}; +`; + +const CardTitleIcon = styled('div')` + display: flex; + align-items: center; + color: ${p => p.theme.subText}; +`; + +const CardContent = styled('div')` + overflow-wrap: break-word; + word-break: break-word; + padding: ${space(1)} ${space(1.5)} ${space(1)}; + text-align: left; + p { + margin: 0; + white-space: pre-wrap; + } + code { + word-break: break-all; + } + flex: 1; +`; diff --git a/static/app/views/issueDetails/streamline/sidebar/solutionsHubDrawer.tsx b/static/app/views/issueDetails/streamline/sidebar/solutionsHubDrawer.tsx index ff31aa0d5698ac..829c812da386c5 100644 --- a/static/app/views/issueDetails/streamline/sidebar/solutionsHubDrawer.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/solutionsHubDrawer.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect, useRef, useState} from 'react'; +import {Fragment, useCallback, useEffect, useRef, useState} from 'react'; import styled from '@emotion/styled'; import starImage from 'sentry-images/spot/banner-star.svg'; @@ -13,6 +13,7 @@ import {Input} from 'sentry/components/core/input'; import AutofixFeedback from 'sentry/components/events/autofix/autofixFeedback'; import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps'; import {useAiAutofix} from 'sentry/components/events/autofix/useAutofix'; +import useDrawer from 'sentry/components/globalDrawer'; import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components'; import {GroupSummary} from 'sentry/components/group/groupSummary'; import HookOrDefault from 'sentry/components/hookOrDefault'; @@ -259,6 +260,42 @@ export function SolutionsHubDrawer({group, project, event}: SolutionsHubDrawerPr ); } +export const useOpenSolutionsDrawer = ( + group: Group, + project: Project, + event: Event | undefined, + buttonRef?: React.RefObject +) => { + const {openDrawer} = useDrawer(); + + return useCallback(() => { + if (!event) { + return; + } + + openDrawer( + () => , + { + ariaLabel: t('Solutions drawer'), + shouldCloseOnInteractOutside: element => { + const viewAllButton = buttonRef?.current; + if ( + viewAllButton?.contains(element) || + document.getElementById('sentry-feedback')?.contains(element) || + document.getElementById('autofix-rethink-input')?.contains(element) || + document.getElementById('autofix-output-stream')?.contains(element) || + document.getElementById('autofix-write-access-modal')?.contains(element) || + element.closest('[data-overlay="true"]') + ) { + return false; + } + return true; + }, + } + ); + }, [openDrawer, buttonRef, event, group, project]); +}; + const Wrapper = styled('div')` display: flex; flex-direction: column; diff --git a/static/app/views/issueDetails/streamline/sidebar/solutionsSection.tsx b/static/app/views/issueDetails/streamline/sidebar/solutionsSection.tsx index 61c1f787a85739..2e7a47c1064231 100644 --- a/static/app/views/issueDetails/streamline/sidebar/solutionsSection.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/solutionsSection.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import {Button} from 'sentry/components/button'; import {FeatureBadge} from 'sentry/components/core/badge/featureBadge'; import {GroupSummary} from 'sentry/components/group/groupSummary'; +import {GroupSummaryWithAutofix} from 'sentry/components/group/groupSummaryWithAutofix'; import {IconMegaphone} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; @@ -43,6 +44,41 @@ function SolutionsHubFeedbackButton({hidden}: {hidden: boolean}) { ); } +function SolutionsSectionContent({ + group, + project, + event, +}: { + event: Event | undefined; + group: Group; + project: Project; +}) { + const aiConfig = useAiConfig(group, event, project); + + if (aiConfig.hasSummary) { + if (aiConfig.hasAutofix) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return null; +} + export default function SolutionsSection({ group, project, @@ -63,43 +99,6 @@ export default function SolutionsSection({ aiConfig.hasAutofix || (aiConfig.hasSummary && aiConfig.hasResources); - const renderContent = () => { - if (aiConfig.needsGenAIConsent) { - return ( - {t('Explore potential root causes and solutions with Seer.')} - ); - } - - if (aiConfig.hasSummary) { - return ( - - - - ); - } - - if (!aiConfig.hasSummary && issueTypeConfig.resources) { - return ( - - - - - {!hasStreamlinedUI && ( - setIsExpanded(!isExpanded)} size="zero"> - {isExpanded ? t('SHOW LESS') : t('READ MORE')} - - )} - - ); - } - - return null; - }; - const titleComponent = ( {t('Solutions Hub')} @@ -127,7 +126,26 @@ export default function SolutionsSection({ preventCollapse={!hasStreamlinedUI} > - {renderContent()} + {aiConfig.needsGenAIConsent ? ( + {t('Explore potential root causes and solutions with Seer.')} + ) : aiConfig.hasAutofix || aiConfig.hasSummary ? ( + + ) : issueTypeConfig.resources ? ( + + + + + {!hasStreamlinedUI && ( + setIsExpanded(!isExpanded)} size="zero"> + {isExpanded ? t('SHOW LESS') : t('READ MORE')} + + )} + + ) : null} {event && showCtaButton && ( (null); - const {openDrawer} = useDrawer(); + const {autofixData} = useAiAutofix(group, event); - const openSolutionsDrawer = () => { - if (!event) { - return; - } - openDrawer( - () => , - { - ariaLabel: t('Solutions drawer'), - shouldCloseOnInteractOutside: element => { - const viewAllButton = openButtonRef.current; - if ( - viewAllButton?.contains(element) || - document.getElementById('sentry-feedback')?.contains(element) || - document.getElementById('autofix-rethink-input')?.contains(element) || - document.getElementById('autofix-output-stream')?.contains(element) || - document.getElementById('autofix-write-access-modal')?.contains(element) || - element.closest('[data-overlay="true"]') - ) { - return false; - } - return true; - }, - } - ); - }; + const openSolutionsDrawer = useOpenSolutionsDrawer( + group, + project, + event, + openButtonRef + ); const showCtaButton = aiConfig.needsGenAIConsent || From 5b8d21992dcde7dd1d9e6e57653097995efa0a07 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Wed, 5 Mar 2025 13:16:51 -0800 Subject: [PATCH 09/41] fix(nav): Fix positioning of menu overlays in mobile view (#86414) Previously, overlays were not visible in the mobile view because they were positioned to the right. This extracts the overlay code into a reusable hook/component so they all work consistently in mobile. --- static/app/components/dropdownMenu/index.tsx | 3 + .../app/components/nav/primary/components.tsx | 3 +- .../app/components/nav/primary/onboarding.tsx | 30 +++------- .../nav/primary/primaryButtonOverlay.tsx | 58 +++++++++++++++++++ .../nav/primary/serviceIncidents.tsx | 39 ++++--------- .../app/components/nav/primary/whatsNew.tsx | 33 +++-------- 6 files changed, 91 insertions(+), 75 deletions(-) create mode 100644 static/app/components/nav/primary/primaryButtonOverlay.tsx diff --git a/static/app/components/dropdownMenu/index.tsx b/static/app/components/dropdownMenu/index.tsx index afcad67322cc41..1864da7ec4f84e 100644 --- a/static/app/components/dropdownMenu/index.tsx +++ b/static/app/components/dropdownMenu/index.tsx @@ -65,6 +65,7 @@ export interface DropdownMenuProps | 'onOpenChange' | 'preventOverflowOptions' | 'flipOptions' + | 'shouldApplyMinWidth' > { /** * Items to display inside the dropdown menu. If the item has a `children` @@ -155,6 +156,7 @@ function DropdownMenu({ preventOverflowOptions, flipOptions, portalContainerRef, + shouldApplyMinWidth, ...props }: DropdownMenuProps) { const isDisabled = disabledProp ?? (!items || items.length === 0); @@ -179,6 +181,7 @@ function DropdownMenu({ preventOverflowOptions, flipOptions, onOpenChange, + shouldApplyMinWidth, }); const {menuTriggerProps, menuProps} = useMenuTrigger( diff --git a/static/app/components/nav/primary/components.tsx b/static/app/components/nav/primary/components.tsx index 1bc68497058cfe..6a16f4b6ac5c88 100644 --- a/static/app/components/nav/primary/components.tsx +++ b/static/app/components/nav/primary/components.tsx @@ -73,7 +73,8 @@ export function SidebarMenu({ return ( { return ( { if (newIsOpen) { @@ -109,13 +106,9 @@ function OnboardingItem({ )} {isOpen && ( - - - - SidebarPanelStore.hidePanel()} /> - - - + + SidebarPanelStore.hidePanel()} /> + )} @@ -212,10 +205,3 @@ export function PrimaryNavigationOnboarding() { /> ); } - -const ScrollableOverlay = styled(Overlay)` - min-height: 300px; - max-height: 60vh; - overflow-y: auto; - width: 400px; -`; diff --git a/static/app/components/nav/primary/primaryButtonOverlay.tsx b/static/app/components/nav/primary/primaryButtonOverlay.tsx new file mode 100644 index 00000000000000..2332cd985626e8 --- /dev/null +++ b/static/app/components/nav/primary/primaryButtonOverlay.tsx @@ -0,0 +1,58 @@ +import styled from '@emotion/styled'; +import {FocusScope} from '@react-aria/focus'; + +import {useNavContext} from 'sentry/components/nav/context'; +import {NavLayout} from 'sentry/components/nav/types'; +import {Overlay, PositionWrapper} from 'sentry/components/overlay'; +import {space} from 'sentry/styles/space'; +import theme from 'sentry/utils/theme'; +import useOverlay, {type UseOverlayProps} from 'sentry/utils/useOverlay'; + +type PrimaryButtonOverlayProps = { + children: React.ReactNode; + overlayProps: React.HTMLAttributes; +}; + +export function usePrimaryButtonOverlay(props: UseOverlayProps = {}) { + const {layout} = useNavContext(); + + return useOverlay({ + offset: 8, + position: layout === NavLayout.MOBILE ? 'bottom' : 'right-end', + isDismissable: true, + shouldApplyMinWidth: false, + ...props, + }); +} + +/** + * Overlay to be used for primary navigation buttons in footer, such as + * "what's new" and "onboarding". This will appear as a normal overlay + * on desktop and a modified overlay in mobile to match the design of + * the mobile topbar. + */ +export function PrimaryButtonOverlay({ + children, + overlayProps, +}: PrimaryButtonOverlayProps) { + const {layout} = useNavContext(); + + return ( + + + + {children} + + + + ); +} + +const ScrollableOverlay = styled(Overlay)<{ + isMobile: boolean; +}>` + min-height: 300px; + max-height: ${p => (p.isMobile ? '80vh' : '60vh')}; + overflow-y: auto; + width: ${p => (p.isMobile ? `calc(100vw - ${space(4)})` : '400px')}; +`; diff --git a/static/app/components/nav/primary/serviceIncidents.tsx b/static/app/components/nav/primary/serviceIncidents.tsx index f12aaf71550ac2..2e47a71c2c2d5c 100644 --- a/static/app/components/nav/primary/serviceIncidents.tsx +++ b/static/app/components/nav/primary/serviceIncidents.tsx @@ -1,32 +1,27 @@ -import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; -import {FocusScope} from '@react-aria/focus'; import { SidebarButton, SidebarItem, SidebarItemUnreadIndicator, } from 'sentry/components/nav/primary/components'; -import {Overlay, PositionWrapper} from 'sentry/components/overlay'; +import { + PrimaryButtonOverlay, + usePrimaryButtonOverlay, +} from 'sentry/components/nav/primary/primaryButtonOverlay'; import {ServiceIncidentDetails} from 'sentry/components/serviceIncidentDetails'; import {IconWarning} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {StatuspageIncident} from 'sentry/types/system'; -import useOverlay from 'sentry/utils/useOverlay'; import {useServiceIncidents} from 'sentry/utils/useServiceIncidents'; function ServiceIncidentsButton({incidents}: {incidents: StatuspageIncident[]}) { - const theme = useTheme(); const { isOpen, triggerProps: overlayTriggerProps, overlayProps, - } = useOverlay({ - offset: 8, - position: 'right-end', - isDismissable: true, - }); + } = usePrimaryButtonOverlay(); return ( @@ -39,17 +34,13 @@ function ServiceIncidentsButton({incidents}: {incidents: StatuspageIncident[]}) {isOpen && ( - - - - {incidents.map(incident => ( - - - - ))} - - - + + {incidents.map(incident => ( + + + + ))} + )} ); @@ -75,12 +66,6 @@ const IncidentItemWrapper = styled('div')` } `; -const ScrollableOverlay = styled(Overlay)` - max-height: 60vh; - width: 400px; - overflow-y: auto; -`; - const WarningUnreadIndicator = styled(SidebarItemUnreadIndicator)` background: ${p => p.theme.warning}; `; diff --git a/static/app/components/nav/primary/whatsNew.tsx b/static/app/components/nav/primary/whatsNew.tsx index 1f24f94581d43d..6db26720ca5f78 100644 --- a/static/app/components/nav/primary/whatsNew.tsx +++ b/static/app/components/nav/primary/whatsNew.tsx @@ -1,7 +1,4 @@ import {Fragment, useEffect, useMemo} from 'react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import {FocusScope} from '@react-aria/focus'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import { @@ -9,7 +6,10 @@ import { SidebarItem, SidebarItemUnreadIndicator, } from 'sentry/components/nav/primary/components'; -import {Overlay, PositionWrapper} from 'sentry/components/overlay'; +import { + PrimaryButtonOverlay, + usePrimaryButtonOverlay, +} from 'sentry/components/nav/primary/primaryButtonOverlay'; import {BroadcastPanelItem} from 'sentry/components/sidebar/broadcastPanelItem'; import SidebarPanelEmpty from 'sentry/components/sidebar/sidebarPanelEmpty'; import {IconBroadcast} from 'sentry/icons'; @@ -25,7 +25,6 @@ import { } from 'sentry/utils/queryClient'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; -import useOverlay from 'sentry/utils/useOverlay'; const MARK_SEEN_DELAY = 1000; @@ -121,17 +120,11 @@ export function PrimaryNavigationWhatsNew() { [broadcasts] ); - const theme = useTheme(); - const { isOpen, triggerProps: overlayTriggerProps, overlayProps, - } = useOverlay({ - offset: 8, - position: 'right-end', - isDismissable: true, - }); + } = usePrimaryButtonOverlay(); return ( @@ -146,20 +139,10 @@ export function PrimaryNavigationWhatsNew() { )} {isOpen && ( - - - - - - - + + + )} ); } - -const ScrollableOverlay = styled(Overlay)` - max-height: 60vh; - width: 400px; - overflow-y: auto; -`; From 1bf7b9915b6eaa03a49b61d2bafba2f181f42fe5 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Wed, 5 Mar 2025 13:19:04 -0800 Subject: [PATCH 10/41] fix(nav): Add /settings/stats/ to django web routes (#86398) --- src/sentry/web/urls.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sentry/web/urls.py b/src/sentry/web/urls.py index 54f19f8244b940..4bf216f04da5c7 100644 --- a/src/sentry/web/urls.py +++ b/src/sentry/web/urls.py @@ -658,6 +658,11 @@ react_page_view, name="sentry-customer-domain-feature-flags-settings", ), + re_path( + r"^stats/", + react_page_view, + name="sentry-customer-domain-stats-settings", + ), re_path( r"^developer-settings/", react_page_view, From 51bbc39b652311cf662adc98844ae23f865d330f Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 5 Mar 2025 13:28:35 -0800 Subject: [PATCH 11/41] fix(issues): Prevent mutation of group tags response (#86423) --- .../app/views/issueDetails/groupTags/groupTagsTab.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/static/app/views/issueDetails/groupTags/groupTagsTab.tsx b/static/app/views/issueDetails/groupTags/groupTagsTab.tsx index 1b3eca018b2baa..cb8e9b60d84b07 100644 --- a/static/app/views/issueDetails/groupTags/groupTagsTab.tsx +++ b/static/app/views/issueDetails/groupTags/groupTagsTab.tsx @@ -52,18 +52,11 @@ export function GroupTagsTab() { refetch: refetchGroup, } = useGroup({groupId: params.groupId}); - const { - data = [], - isPending, - isError, - refetch, - } = useGroupTags({ + const {data, isPending, isError, refetch} = useGroupTags({ groupId: group?.id, environment: environments, }); - const alphabeticalTags = data.sort((a, b) => a.key.localeCompare(b.key)); - if (isPending || isGroupPending) { return ; } @@ -87,6 +80,7 @@ export function GroupTagsTab() { }; }; + const alphabeticalTags = data.toSorted((a, b) => a.key.localeCompare(b.key)); return ( From 262b4572cdd4a02fa58ed5705bff007936bd4659 Mon Sep 17 00:00:00 2001 From: Michael Sun <55160142+MichaelSun48@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:38:36 -0800 Subject: [PATCH 12/41] fix(issue-views): Fix project being overwritten (#86426) Fixes #86274 Fixes an issue where the default view's projects were sometimes being overwritten with the first alphabetical project. This was caused by both the issueViewHeader component and the pageFilterContainer both trying to write a project to the query params simultaneously. I'm not exactly sure why the pageFilterContainer wins the collision, but we can fix this by just disabling it from trying to write a project to the query params if we have the issue stream --- static/app/views/issueList/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/static/app/views/issueList/index.tsx b/static/app/views/issueList/index.tsx index e7a21dc53c8f24..dc2b61469c746d 100644 --- a/static/app/views/issueList/index.tsx +++ b/static/app/views/issueList/index.tsx @@ -26,6 +26,9 @@ function IssueListContainer({children}: Props) { {children} From 6c85d09c2f22110340ddf6390fb313941e13142f Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Wed, 5 Mar 2025 13:55:10 -0800 Subject: [PATCH 13/41] fix(nav): Revert CSS addition on body element which broke sticky interactions (#86430) --- static/app/views/organizationLayout/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/static/app/views/organizationLayout/index.tsx b/static/app/views/organizationLayout/index.tsx index 7cf2a5c70db7f3..28902a256fb1ac 100644 --- a/static/app/views/organizationLayout/index.tsx +++ b/static/app/views/organizationLayout/index.tsx @@ -122,7 +122,6 @@ const BodyContainer = styled('div')` display: flex; flex-direction: column; flex: 1; - overflow-x: hidden; `; export default OrganizationLayout; From 1c4c60520963871293344bbd2806460b9b86d8fd Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 5 Mar 2025 14:00:57 -0800 Subject: [PATCH 14/41] ref(uptime): Remove usage of uptime-create-disabled (#86428) --- static/app/views/alerts/wizard/options.tsx | 5 +---- static/app/views/insights/uptime/views/overview.tsx | 10 ---------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/static/app/views/alerts/wizard/options.tsx b/static/app/views/alerts/wizard/options.tsx index 50867559167e2a..8beddb23550bf2 100644 --- a/static/app/views/alerts/wizard/options.tsx +++ b/static/app/views/alerts/wizard/options.tsx @@ -134,10 +134,7 @@ export const getAlertWizardCategories = (org: Organization) => { ], }); - if ( - org.features.includes('uptime') && - !org.features.includes('uptime-create-disabled') - ) { + if (org.features.includes('uptime')) { result.push({ categoryHeading: t('Uptime Monitoring'), options: ['uptime_monitor'], diff --git a/static/app/views/insights/uptime/views/overview.tsx b/static/app/views/insights/uptime/views/overview.tsx index 9e959408caf7b5..87bfea1f22a443 100644 --- a/static/app/views/insights/uptime/views/overview.tsx +++ b/static/app/views/insights/uptime/views/overview.tsx @@ -81,8 +81,6 @@ export default function UptimeOverview() { }); }; - const creationDisabled = organization.features.includes('uptime-create-disabled'); - return ( Date: Wed, 5 Mar 2025 14:04:43 -0800 Subject: [PATCH 15/41] fix(nav): Use proper beta badge styles for project settings (#86432) --- .../app/views/settings/project/navigationConfiguration.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/static/app/views/settings/project/navigationConfiguration.tsx b/static/app/views/settings/project/navigationConfiguration.tsx index c325ca68926a40..20944ee2384e82 100644 --- a/static/app/views/settings/project/navigationConfiguration.tsx +++ b/static/app/views/settings/project/navigationConfiguration.tsx @@ -1,4 +1,3 @@ -import {Badge} from 'sentry/components/core/badge'; import {t} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import type {Organization} from 'sentry/types/organization'; @@ -70,7 +69,7 @@ export default function getConfiguration({ path: `${pathPrefix}/toolbar/`, title: t('Dev Toolbar'), show: () => !!organization?.features?.includes('dev-toolbar-ui'), - badge: () => Beta, + badge: () => 'beta', }, ], }, @@ -126,7 +125,7 @@ export default function getConfiguration({ { path: `${pathPrefix}/playstation/`, title: t('PlayStation'), - badge: () => Beta, + badge: () => 'beta', show: () => !!(organization && hasTempestAccess(organization)) && !isSelfHosted, }, ], From 079b642369521f0a6a639ac6db7b780643114844 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 5 Mar 2025 14:18:36 -0800 Subject: [PATCH 16/41] ref(uptime): Remove backend usage of uptime-create-disabled (#86431) --- src/sentry/features/temporary.py | 2 -- src/sentry/uptime/detectors/tasks.py | 8 +++----- src/sentry/uptime/endpoints/validators.py | 7 +------ tests/sentry/uptime/detectors/test_tasks.py | 4 ---- .../endpoints/test_project_uptime_alert_index.py | 14 -------------- 5 files changed, 4 insertions(+), 31 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 7e11c7e66e8567..fa66f5ef0a5f0f 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -429,8 +429,6 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:uptime-create-issues", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enables uptime related settings for projects and orgs manager.add('organizations:uptime-settings', OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Disallows creation of uptime monitors - manager.add('organizations:uptime-create-disabled', OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enables detailed logging for uptime results manager.add("organizations:uptime-detailed-logging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:use-metrics-layer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) diff --git a/src/sentry/uptime/detectors/tasks.py b/src/sentry/uptime/detectors/tasks.py index 1fb7b4b64d4835..b0ef14b2988e8d 100644 --- a/src/sentry/uptime/detectors/tasks.py +++ b/src/sentry/uptime/detectors/tasks.py @@ -220,11 +220,9 @@ def process_candidate_url( "project": project.id, }, ) - if ( - features.has("organizations:uptime-automatic-subscription-creation", project.organization) - and features.has("organizations:uptime", project.organization) - and not features.has("organizations:uptime-create-disabled", project.organization) - ): + if features.has( + "organizations:uptime-automatic-subscription-creation", project.organization + ) and features.has("organizations:uptime", project.organization): # If we hit this point, then the url looks worth monitoring. Create an uptime subscription in monitor mode. uptime_monitor = monitor_url_for_project(project, url) # Disable auto-detection on this project and organization now that we've successfully found a hostname diff --git a/src/sentry/uptime/endpoints/validators.py b/src/sentry/uptime/endpoints/validators.py index 4f7e5571c5d4ed..5379c603a872bd 100644 --- a/src/sentry/uptime/endpoints/validators.py +++ b/src/sentry/uptime/endpoints/validators.py @@ -5,7 +5,7 @@ from rest_framework import serializers from rest_framework.fields import URLField -from sentry import audit_log, features +from sentry import audit_log from sentry.api.fields import ActorField from sentry.api.serializers.rest_framework import CamelSnakeSerializer from sentry.auth.superuser import is_active_superuser @@ -127,11 +127,6 @@ class UptimeMonitorValidator(CamelSnakeSerializer): ) def validate(self, attrs): - if features.has("organization:uptime-create-disabled", self.context["organization"]): - raise serializers.ValidationError( - "The uptime feature is disabled for your organization" - ) - headers = [] method = "GET" body = None diff --git a/tests/sentry/uptime/detectors/test_tasks.py b/tests/sentry/uptime/detectors/test_tasks.py index 7352c02256d8a9..d694e619555a72 100644 --- a/tests/sentry/uptime/detectors/test_tasks.py +++ b/tests/sentry/uptime/detectors/test_tasks.py @@ -232,10 +232,6 @@ def test_succeeds_new_no_feature(self): assert process_candidate_url(self.project, 100, "https://sentry.io", 50) mock_monitor_url_for_project.assert_not_called() - with self.feature(["organizations:uptime", "organizations:uptime-create-disabled"]): - assert process_candidate_url(self.project, 100, "https://sentry.io", 50) - mock_monitor_url_for_project.assert_not_called() - @with_feature(["organizations:uptime", "organizations:uptime-automatic-subscription-creation"]) def test_succeeds_existing_subscription_other_project(self): other_project = self.create_project() diff --git a/tests/sentry/uptime/endpoints/test_project_uptime_alert_index.py b/tests/sentry/uptime/endpoints/test_project_uptime_alert_index.py index fbe394ffd0a174..2b5020d3d37899 100644 --- a/tests/sentry/uptime/endpoints/test_project_uptime_alert_index.py +++ b/tests/sentry/uptime/endpoints/test_project_uptime_alert_index.py @@ -297,17 +297,3 @@ def test_no_seat_assignment(self, _mock_check_assign_seat): ) uptime_monitor = ProjectUptimeSubscription.objects.get(id=resp.data["id"]) assert uptime_monitor.status == ObjectStatus.DISABLED - - def test_flag_disabled(self): - with self.feature("organizations:uptime-create-disabled"): - self.get_error_response( - self.organization.slug, - self.project.slug, - environment=self.environment.name, - name="test", - url="http://santry.io", - interval_seconds=60, - timeout_ms=1000, - owner=f"user:{self.user.id}", - status=400, - ) From efa66f6827df44879bd2e866dc7ebb7673b489a8 Mon Sep 17 00:00:00 2001 From: joshuarli Date: Wed, 5 Mar 2025 14:21:22 -0800 Subject: [PATCH 17/41] fix: restore gsApp for single tenant (#86434) --- static/app/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/index.tsx b/static/app/index.tsx index 5ee7b9dd205a8e..0c615addbd1f3c 100644 --- a/static/app/index.tsx +++ b/static/app/index.tsx @@ -76,7 +76,7 @@ async function app() { const {bootstrap} = await bootstrapImport; const config = await bootstrap(); - if (config.sentryMode !== 'SAAS') { + if (config.sentryMode === 'SELF_HOSTED') { const {initializeMain} = await initalizeMainImport; initializeMain(config); return; From 2e45fafe5c1200f2e3c97c5a68ec335826c9180e Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 5 Mar 2025 14:56:58 -0800 Subject: [PATCH 18/41] fix(ui): Tooltip alignment on uptimeChecksGrid (#86424) --- static/app/views/alerts/rules/uptime/uptimeChecksGrid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/alerts/rules/uptime/uptimeChecksGrid.tsx b/static/app/views/alerts/rules/uptime/uptimeChecksGrid.tsx index ac10fc37b4191c..54d001d3a8cd4f 100644 --- a/static/app/views/alerts/rules/uptime/uptimeChecksGrid.tsx +++ b/static/app/views/alerts/rules/uptime/uptimeChecksGrid.tsx @@ -212,6 +212,6 @@ const TimeCell = styled(Cell)` const TraceCell = styled(Cell)` display: grid; - grid-template-columns: 65px auto; + grid-template-columns: 65px max-content; gap: ${space(1)}; `; From f89a334835b45d6f08a08ec4463ccc44a668b2c9 Mon Sep 17 00:00:00 2001 From: Michael Sun <55160142+MichaelSun48@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:02:21 -0800 Subject: [PATCH 19/41] migrations(shared-views): Backfill positions column in groupsearchviewstarred table (#86164) We're migrating the positions column from the `groupsearchview` column to its own table, `groupsearchviewstarred`. This PR copies all of the position columns to the new table. An update to the PUT endpoint to ensure the columns remain in sync has already been merged [here](https://github.com/getsentry/sentry/pull/86153) Note: this migration is configured as a post-deployment migration. --- migrations_lockfile.txt | 2 +- ...groupsearchview_positions_to_gsvstarred.py | 51 +++++++++++++++++++ ...groupsearchview_positions_to_gsvstarred.py | 35 +++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/sentry/migrations/0838_backfill_groupsearchview_positions_to_gsvstarred.py create mode 100644 tests/sentry/migrations/test_0838_backfill_groupsearchview_positions_to_gsvstarred.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 9f8f7fa6218a5a..ef5dca6dfbdf2f 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -15,7 +15,7 @@ remote_subscriptions: 0003_drop_remote_subscription replays: 0004_index_together -sentry: 0837_create_groupsearchviewlastseen_table +sentry: 0838_backfill_groupsearchview_positions_to_gsvstarred social_auth: 0002_default_auto_field diff --git a/src/sentry/migrations/0838_backfill_groupsearchview_positions_to_gsvstarred.py b/src/sentry/migrations/0838_backfill_groupsearchview_positions_to_gsvstarred.py new file mode 100644 index 00000000000000..83bec0ca54cf53 --- /dev/null +++ b/src/sentry/migrations/0838_backfill_groupsearchview_positions_to_gsvstarred.py @@ -0,0 +1,51 @@ +# Generated by Django 5.1.5 on 2025-03-01 00:20 + +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.utils.query import RangeQuerySetWrapperWithProgressBar + + +def backfill_groupsearchview_positions_to_gsvstarred( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + GroupSearchView = apps.get_model("sentry", "GroupSearchView") + GroupSearchViewStarred = apps.get_model("sentry", "GroupSearchViewStarred") + + for gsv in RangeQuerySetWrapperWithProgressBar(GroupSearchView.objects.all()): + GroupSearchViewStarred.objects.update_or_create( + group_search_view=gsv, + user_id=gsv.user_id, + organization_id=gsv.organization_id, + defaults={"position": gsv.position}, + ) + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = True + + dependencies = [ + ("sentry", "0837_create_groupsearchviewlastseen_table"), + ] + + operations = [ + migrations.RunPython( + backfill_groupsearchview_positions_to_gsvstarred, + reverse_code=migrations.RunPython.noop, + hints={"tables": ["sentry_groupsearchview", "sentry_groupsearchviewstarred"]}, + ), + ] diff --git a/tests/sentry/migrations/test_0838_backfill_groupsearchview_positions_to_gsvstarred.py b/tests/sentry/migrations/test_0838_backfill_groupsearchview_positions_to_gsvstarred.py new file mode 100644 index 00000000000000..831d8d81bd3ccb --- /dev/null +++ b/tests/sentry/migrations/test_0838_backfill_groupsearchview_positions_to_gsvstarred.py @@ -0,0 +1,35 @@ +from sentry.models.groupsearchview import GroupSearchView +from sentry.models.groupsearchviewstarred import GroupSearchViewStarred +from sentry.testutils.cases import TestMigrations + + +class BackfillGroupSearchViewPositionsTest(TestMigrations): + migrate_from = "0837_create_groupsearchviewlastseen_table" + migrate_to = "0838_backfill_groupsearchview_positions_to_gsvstarred" + + def setup_initial_state(self): + self.user = self.create_user() + self.org = self.create_organization(owner=self.user) + + self.project = self.create_project(organization=self.organization) + + self.gsv1 = GroupSearchView.objects.create( + user_id=self.user.id, + organization_id=self.organization.id, + name="Test View 1", + query="is:unresolved", + position=1, + ) + + self.gsv2 = GroupSearchView.objects.create( + user_id=self.user.id, + organization_id=self.organization.id, + name="Test View 2", + query="is:resolved", + position=2, + ) + + def test(self): + assert GroupSearchViewStarred.objects.count() == 2 + assert GroupSearchViewStarred.objects.get(group_search_view=self.gsv1).position == 1 + assert GroupSearchViewStarred.objects.get(group_search_view=self.gsv2).position == 2 From e868ee87ec10d656d32772887afe3e166a5ac4a4 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 6 Mar 2025 00:38:43 +0100 Subject: [PATCH 20/41] feat(hybrid-cloud): Ensure release registry cache is available in control (#86400) When [setting up the AWS integration in Sentry](https://docs.sentry.io/organization/integrations/cloud-monitoring/aws-lambda/) and trying to automatically add the lambda layer to lambda functions the underlying code encounters an empty release-registry cache: https://sentry.sentry.io/issues/5289437499/events/f3e461d3164a442587c3f9da8ef86255/ This happens from this view: ![image](https://github.com/user-attachments/assets/55ef7664-ba11-4677-8c98-e2d9cc139233) But it is possible to do from this view: ![image](https://github.com/user-attachments/assets/5dd745d4-2f5c-4c4a-938c-9522c0a66b9d) From the linked issue above, it looks like that code runs in the control silo, but the task to populate the cache with release-registry data is only running for region silos. This PR adds the celery task to the control queue. Related: https://github.com/getsentry/sentry/issues/85322, https://github.com/getsentry/sentry/issues/86365 Might stem from: https://github.com/getsentry/sentry/pull/53038 --- src/sentry/conf/server.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 7a5ca4ee702134..f2aad9a7f027d3 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1054,6 +1054,12 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "schedule": timedelta(seconds=10), "options": {"expires": 60, "queue": "webhook.control"}, }, + "fetch-release-registry-data": { + "task": "sentry.tasks.release_registry.fetch_release_registry_data", + # Run every 5 minutes + "schedule": crontab(minute="*/5"), + "options": {"expires": 3600}, + }, } # Most tasks run in the regions From 6d2085feec8f6ccfa408e285838c1d710eff14c7 Mon Sep 17 00:00:00 2001 From: Maggie Bauer <33526611+mbauer404@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:51:11 -0800 Subject: [PATCH 21/41] ui(billing): Add id field to subscription fixture (#86444) Updates `Am3DsEnterpriseSubscriptionFixture` to include an "id" field value on the reserved budget. --- tests/js/getsentry-test/fixtures/subscription.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/js/getsentry-test/fixtures/subscription.ts b/tests/js/getsentry-test/fixtures/subscription.ts index 1772c32feadcbf..52eb3b49a33c7d 100644 --- a/tests/js/getsentry-test/fixtures/subscription.ts +++ b/tests/js/getsentry-test/fixtures/subscription.ts @@ -397,6 +397,7 @@ export function Am3DsEnterpriseSubscriptionFixture(props: Props): TSubscription subscription.reservedBudgetCategories = ['spans', 'spansIndexed']; subscription.reservedBudgets = [ ReservedBudgetFixture({ + id: '11', reservedBudget: 100_000_00, totalReservedSpend: 60_000_00, freeBudget: 0, From 7bb4445064c014255ef02e25ea1fbcac5ff7b64d Mon Sep 17 00:00:00 2001 From: Michael Sun <55160142+MichaelSun48@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:53:51 -0800 Subject: [PATCH 22/41] chore(shared-views): Create flag for sharing views (#86442) [SOA pr](https://github.com/getsentry/sentry-options-automator/pull/3281) --- src/sentry/features/temporary.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index fa66f5ef0a5f0f..d5ea0f86af07bb 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -156,6 +156,8 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:issue-search-snuba", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable issue stream table layout changes manager.add("organizations:issue-stream-table-layout", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable issue view sharing + manager.add("organizations:issue-view-sharing", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:large-debug-files", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) manager.add("organizations:metric-issue-poc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("projects:metric-issue-creation", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) From 38e80e0df65d40412088f7f62fb98f1e8472692a Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Wed, 5 Mar 2025 16:15:37 -0800 Subject: [PATCH 23/41] chore(grouping): Yet more race condition logging (#86437) This makes two small changes to our logging for grouphash metadata race conditions: - Log fewer things in `record_exists` log. - Even after marking all of the events whose grouphash metadata has lost the creation race as ones that should skip the Seer call, we're still seeing multiple copies of the same hash without the skip. (Ideally, there should be exactly one.) Therefore, add a log for records which don't get the skip but also don't have a group attached to them. This should correspond to hashes which have the potential to get sent to Seer, and let us see if perhaps `grouphash_is_new` is a reliable way to pick out a single winner. --- .../grouping/ingest/grouphash_metadata.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/sentry/grouping/ingest/grouphash_metadata.py b/src/sentry/grouping/ingest/grouphash_metadata.py index 00cb94e6c69ef1..c8c2fe0585507f 100644 --- a/src/sentry/grouping/ingest/grouphash_metadata.py +++ b/src/sentry/grouping/ingest/grouphash_metadata.py @@ -144,12 +144,10 @@ def create_or_update_grouphash_metadata_if_needed( logger.info( "grouphash_metadata.creation_race_condition.record_exists", extra={ - "grouphash_metadata_id": grouphash_metadata.id, - "linked_metadata_id": grouphash.metadata.id if grouphash.metadata else None, "grouphash_id": grouphash.id, "grouphash_is_new": grouphash_is_new, + "grouphash_has_group": bool(grouphash.group_id), "event_id": event.event_id, - "hash_basis": new_data["hash_basis"], "hash": grouphash.hash, }, ) @@ -162,6 +160,19 @@ def create_or_update_grouphash_metadata_if_needed( event.should_skip_seer = True return + # TODO: Temporary log to investigate race condition. Restricted to grouphashes without an + # assigned group because those are the only ones which might get sent to Seer. + if not grouphash.group_id: + logger.info( + "grouphash_metadata.creation_race_condition.no_group_id", + extra={ + "grouphash_id": grouphash.id, + "grouphash_is_new": grouphash_is_new, + "event_id": event.event_id, + "hash": grouphash.hash, + }, + ) + db_hit_metadata = {"reason": "new_grouphash" if grouphash_is_new else "missing_metadata"} if not grouphash_is_new: From 63459c2b780fab7fdc6bd9c3afe286c1f385aa2c Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Wed, 5 Mar 2025 16:34:19 -0800 Subject: [PATCH 24/41] fix(issue-stream): Fix hover interaction on safari (#86439) On Safari, showing the checkbox on hover wasn't working correctly. For some reason the top-level wrapper element did not think it was being hovered, probably due to the way the link is positioned. Changing the CSS so that is checks for any hovered element fixes this. --- static/app/components/stream/group.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/stream/group.tsx b/static/app/components/stream/group.tsx index ed6c513b805e14..69b3780b377582 100644 --- a/static/app/components/stream/group.tsx +++ b/static/app/components/stream/group.tsx @@ -757,7 +757,7 @@ const Wrapper = styled(PanelItem)<{ padding: ${space(1)} 0; min-height: 86px; - &:not(:hover):not(:focus-within):not(:has(input:checked)) { + &:not(:has(:hover)):not(:focus-within):not(:has(input:checked)) { ${CheckboxLabel} { ${p.theme.visuallyHidden}; } From c3638d9d70881fb4a9624f6346b3633efbaf1dc2 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Wed, 5 Mar 2025 16:47:23 -0800 Subject: [PATCH 25/41] chore(nav): Change copy from 'Search' to 'Feed' (#86449) --- static/app/views/issues/navigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/issues/navigation.tsx b/static/app/views/issues/navigation.tsx index b1e3cbef4dcf0d..168839cb94ce78 100644 --- a/static/app/views/issues/navigation.tsx +++ b/static/app/views/issues/navigation.tsx @@ -38,7 +38,7 @@ export function IssueNavigation({children}: IssuesWrapperProps) { - {t('Search')} + {t('Feed')} {t('Feedback')} From f47fb416675051e714eefc19be39d197b9fbf3cd Mon Sep 17 00:00:00 2001 From: Rohan Agarwal <47861399+roaga@users.noreply.github.com> Date: Wed, 5 Mar 2025 20:01:22 -0500 Subject: [PATCH 26/41] feat(autofix): Add support for agent to leave comments (#86402) The agent can leave a comment on the root cause, solution, or code changes output. It looks the same as the user-initiated comments. Adds an X button on the comment thread in the top right to allow users to dismiss threads too. Requires Seer-side changes to go in first. --- .../events/autofix/autofixChanges.tsx | 25 ++- .../events/autofix/autofixHighlightPopup.tsx | 157 +++++++++++++++--- .../events/autofix/autofixRootCause.tsx | 29 +++- .../events/autofix/autofixSolution.tsx | 29 +++- .../events/autofix/autofixSteps.tsx | 3 + static/app/components/events/autofix/types.ts | 1 + .../components/events/autofix/useAutofix.tsx | 4 +- .../streamline/sidebar/solutionsHubDrawer.tsx | 29 +++- 8 files changed, 248 insertions(+), 29 deletions(-) diff --git a/static/app/components/events/autofix/autofixChanges.tsx b/static/app/components/events/autofix/autofixChanges.tsx index 999846ca895416..775d7f6654b6e4 100644 --- a/static/app/components/events/autofix/autofixChanges.tsx +++ b/static/app/components/events/autofix/autofixChanges.tsx @@ -15,6 +15,7 @@ import { type AutofixCodebaseChange, AutofixStatus, type AutofixUpdateEndpointResponse, + type CommentThread, } from 'sentry/components/events/autofix/types'; import { makeAutofixQueryKey, @@ -37,6 +38,7 @@ type AutofixChangesProps = { groupId: string; runId: string; step: AutofixChangesStep; + agentCommentThread?: CommentThread; previousDefaultStepIndex?: number; previousInsightCount?: number; }; @@ -423,9 +425,11 @@ export function AutofixChanges({ runId, previousDefaultStepIndex, previousInsightCount, + agentCommentThread, }: AutofixChangesProps) { const data = useAutofixData({groupId}); const [isBusy, setIsBusy] = useState(false); + const iconCodeRef = useRef(null); if (step.status === 'ERROR' || data?.status === 'ERROR') { return ( @@ -471,7 +475,9 @@ export function AutofixChanges({ - +
+ +
{t('Code Changes')}
{!prsMade && ( @@ -541,6 +547,23 @@ export function AutofixChanges({ ))}
+ + {agentCommentThread && iconCodeRef.current && ( + = 0 + ? previousInsightCount + : null + } + isAgentComment + /> + )} + {step.changes.map((change, i) => ( {i > 0 && } diff --git a/static/app/components/events/autofix/autofixHighlightPopup.tsx b/static/app/components/events/autofix/autofixHighlightPopup.tsx index 6c2e1b82a72a83..5f3dd4bcddf1b1 100644 --- a/static/app/components/events/autofix/autofixHighlightPopup.tsx +++ b/static/app/components/events/autofix/autofixHighlightPopup.tsx @@ -21,7 +21,7 @@ import { useAutofixData, } from 'sentry/components/events/autofix/useAutofix'; import LoadingIndicator from 'sentry/components/loadingIndicator'; -import {IconChevron} from 'sentry/icons'; +import {IconChevron, IconClose} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import testableTransition from 'sentry/utils/testableTransition'; @@ -37,6 +37,7 @@ interface Props { runId: string; selectedText: string; stepIndex: number; + isAgentComment?: boolean; } interface OptimisticMessage extends CommentThreadMessage { @@ -49,6 +50,7 @@ function useCommentThread({groupId, runId}: {groupId: string; runId: string}) { return useMutation({ mutationFn: (params: { + is_agent_comment: boolean; message: string; retain_insight_card_index: number | null; selected_text: string; @@ -66,6 +68,7 @@ function useCommentThread({groupId, runId}: {groupId: string; runId: string}) { selected_text: params.selected_text, step_index: params.step_index, retain_insight_card_index: params.retain_insight_card_index, + is_agent_comment: params.is_agent_comment, }, }, }); @@ -79,41 +82,118 @@ function useCommentThread({groupId, runId}: {groupId: string; runId: string}) { }); } +function useCloseCommentThread({groupId, runId}: {groupId: string; runId: string}) { + const api = useApi({persistInFlight: true}); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { + is_agent_comment: boolean; + step_index: number; + thread_id: string; + }) => { + return api.requestPromise(`/issues/${groupId}/autofix/update/`, { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'resolve_comment_thread', + thread_id: params.thread_id, + step_index: params.step_index, + is_agent_comment: params.is_agent_comment, + }, + }, + }); + }, + onSuccess: _ => { + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)}); + }, + onError: () => { + addErrorMessage(t('Something went wrong when resolving the thread.')); + }, + }); +} + function AutofixHighlightPopupContent({ selectedText, groupId, runId, stepIndex, retainInsightCardIndex, -}: Omit) { + isAgentComment, +}: Props) { const {mutate: submitComment} = useCommentThread({groupId, runId}); + const {mutate: closeCommentThread} = useCloseCommentThread({groupId, runId}); + const [comment, setComment] = useState(''); const [threadId] = useState(() => { const timestamp = Date.now(); const random = Math.floor(Math.random() * 10000); return `thread-${timestamp}-${random}`; }); - const [optimisticMessages, setOptimisticMessages] = useState([]); + const [pendingUserMessage, setPendingUserMessage] = useState( + null + ); + const [showLoadingAssistant, setShowLoadingAssistant] = useState(false); const messagesEndRef = useRef(null); // Fetch current autofix data to get comment thread const autofixData = useAutofixData({groupId}); - const currentStep = autofixData?.steps?.[stepIndex]; - const commentThread = - currentStep?.active_comment_thread?.id === threadId + const currentStep = !isAgentComment + ? autofixData?.steps?.[stepIndex] + : autofixData?.steps?.[stepIndex + 1]; + + const commentThread = isAgentComment + ? currentStep?.agent_comment_thread + : currentStep?.active_comment_thread?.id === threadId ? currentStep.active_comment_thread : null; - const messages = useMemo( + + const serverMessages = useMemo( () => commentThread?.messages ?? [], [commentThread?.messages] ); + // Effect to clear pending messages when server data updates + useEffect(() => { + if (serverMessages.length > 0) { + const lastServerMessage = serverMessages[serverMessages.length - 1]; + + // If the last server message is from the assistant, clear all pending messages + if (lastServerMessage && lastServerMessage.role === 'assistant') { + setPendingUserMessage(null); + setShowLoadingAssistant(false); + } + + // If the last server message is from the user, keep loading assistant state + // but clear the pending user message + if (lastServerMessage && lastServerMessage.role === 'user') { + setPendingUserMessage(null); + setShowLoadingAssistant(true); + } + } + }, [serverMessages]); + // Combine server messages with optimistic ones const allMessages = useMemo(() => { - const serverMessageCount = messages.length; - const relevantOptimisticMessages = optimisticMessages.slice(serverMessageCount); - return [...messages, ...relevantOptimisticMessages]; - }, [messages, optimisticMessages]); + const result = [...serverMessages]; + + // Add pending user message if it exists + if (pendingUserMessage) { + result.push(pendingUserMessage); + } + + // Add loading assistant message if needed + if (showLoadingAssistant) { + result.push({ + role: 'assistant' as const, + content: '', + isLoading: true, + }); + } + + return result; + }, [serverMessages, pendingUserMessage, showLoadingAssistant]); const truncatedText = selectedText.length > 70 @@ -135,12 +215,9 @@ function AutofixHighlightPopupContent({ return; } - // Add user message and loading assistant message immediately - setOptimisticMessages(prev => [ - ...prev, - {role: 'user', content: comment}, - {role: 'assistant', content: '', isLoading: true}, - ]); + // Add optimistic user message and show loading assistant + setPendingUserMessage({role: 'user', content: comment}); + setShowLoadingAssistant(true); submitComment({ message: comment, @@ -148,6 +225,7 @@ function AutofixHighlightPopupContent({ selected_text: selectedText, step_index: stepIndex, retain_insight_card_index: retainInsightCardIndex, + is_agent_comment: isAgentComment ?? false, }); setComment(''); }; @@ -165,12 +243,30 @@ function AutofixHighlightPopupContent({ scrollToBottom(); }, [allMessages]); + const handleResolve = (e: React.MouseEvent) => { + e.stopPropagation(); + closeCommentThread({ + thread_id: threadId, + step_index: stepIndex, + is_agent_comment: isAgentComment ?? false, + }); + }; + return (
"{truncatedText}" + {allMessages.length > 0 && ( + } + /> + )}
{allMessages.length > 0 && ( @@ -250,6 +346,7 @@ function AutofixHighlightPopup(props: Props) { left: 0, top: 0, }); + const [isFocused, setIsFocused] = useState(false); useLayoutEffect(() => { if (!referenceElement || !popupRef.current) { @@ -294,11 +391,19 @@ function AutofixHighlightPopup(props: Props) { }; }, [referenceElement]); + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + setIsFocused(false); + }; + return createPortal( @@ -319,8 +428,8 @@ function AutofixHighlightPopup(props: Props) { ); } -const Wrapper = styled(motion.div)` - z-index: ${p => p.theme.zIndex.tooltip}; +const Wrapper = styled(motion.div)<{isFocused?: boolean}>` + z-index: ${p => (p.isFocused ? p.theme.zIndex.tooltip + 1 : p.theme.zIndex.tooltip)}; display: flex; flex-direction: column; align-items: flex-start; @@ -398,8 +507,8 @@ const StyledButton = styled(Button)` const Header = styled('div')` display: flex; align-items: center; - gap: ${space(1)}; - padding: ${space(1)}; + justify-content: space-between; + padding: ${space(1)} ${space(1.5)}; background: ${p => p.theme.backgroundSecondary}; word-break: break-word; overflow-wrap: break-word; @@ -480,6 +589,10 @@ const LoadingWrapper = styled('div')` margin-top: ${space(0.25)}; `; +const ResolveButton = styled(Button)` + margin-left: ${space(1)}; +`; + function getScrollParents(element: HTMLElement): Element[] { const scrollParents: Element[] = []; let currentElement = element.parentElement; diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx index 2729f8fd1f3945..dc4b43f31d9a3e 100644 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ b/static/app/components/events/autofix/autofixRootCause.tsx @@ -14,6 +14,7 @@ import { type AutofixRootCauseSelection, AutofixStatus, AutofixStepType, + type CommentThread, } from 'sentry/components/events/autofix/types'; import { type AutofixResponse, @@ -37,6 +38,7 @@ type AutofixRootCauseProps = { repos: AutofixRepository[]; rootCauseSelection: AutofixRootCauseSelection; runId: string; + agentCommentThread?: CommentThread; previousDefaultStepIndex?: number; previousInsightCount?: number; terminationReason?: string; @@ -265,11 +267,13 @@ function AutofixRootCauseDisplay({ rootCauseSelection, previousDefaultStepIndex, previousInsightCount, + agentCommentThread, }: AutofixRootCauseProps) { const {mutate: handleContinue, isPending} = useSelectCause({groupId, runId}); const [isEditing, setIsEditing] = useState(false); const [customRootCause, setCustomRootCause] = useState(''); const cause = causes[0]; + const iconFocusRef = useRef(null); if (!cause) { return ( @@ -285,7 +289,9 @@ function AutofixRootCauseDisplay({ - +
+ +
{t('Custom Root Cause')}
- +
+ +
{t('Root Cause')}
@@ -343,6 +351,23 @@ function AutofixRootCauseDisplay({ )}
+ + {agentCommentThread && iconFocusRef.current && ( + = 0 + ? previousInsightCount + : null + } + isAgentComment + /> + )} + {isEditing ? (