Skip to content

Commit 0c7eb16

Browse files
authored
feat(dynamic-groups): Move issue cluster details into drawer (#106028)
pulls the cluster details page into a drawer and adds a preview of the issues in the drawer <img width="1308" height="510" alt="image" src="https://github.com/user-attachments/assets/9e1353e9-1d82-4c72-8767-12b1df4eae0a" />
1 parent 7e3a022 commit 0c7eb16

File tree

4 files changed

+1023
-1704
lines changed

4 files changed

+1023
-1704
lines changed

static/app/router/routes.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2649,10 +2649,6 @@ function buildRoutes(): RouteObject[] {
26492649
path: 'dynamic-groups/',
26502650
component: make(() => import('sentry/views/issueList/pages/dynamicGrouping')),
26512651
},
2652-
{
2653-
path: 'top-issues/',
2654-
component: make(() => import('sentry/views/issueList/pages/topIssues')),
2655-
},
26562652
{
26572653
path: 'views/:viewId/',
26582654
component: errorHandler(OverviewWrapper),

static/app/views/issueList/pages/dynamicGrouping.tsx

Lines changed: 52 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
1+
import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
22
import styled from '@emotion/styled';
3+
import * as qs from 'query-string';
34

45
import {Container, Flex} from '@sentry/scraps/layout';
56
import {Heading, Text} from '@sentry/scraps/text';
@@ -9,7 +10,6 @@ import {openConfirmModal} from 'sentry/components/confirm';
910
import {Button} from 'sentry/components/core/button';
1011
import {ButtonBar} from 'sentry/components/core/button/buttonBar';
1112
import {Checkbox} from 'sentry/components/core/checkbox';
12-
import {InlineCode} from 'sentry/components/core/code/inlineCode';
1313
import {Disclosure} from 'sentry/components/core/disclosure';
1414
import {Link} from 'sentry/components/core/link';
1515
import {TextArea} from 'sentry/components/core/textarea';
@@ -18,6 +18,7 @@ import {DropdownMenu} from 'sentry/components/dropdownMenu';
1818
import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
1919
import EventMessage from 'sentry/components/events/eventMessage';
2020
import FeedbackButton from 'sentry/components/feedbackButton/feedbackButton';
21+
import useDrawer from 'sentry/components/globalDrawer';
2122
import TimesTag from 'sentry/components/group/inboxBadges/timesTag';
2223
import UnhandledTag from 'sentry/components/group/inboxBadges/unhandledTag';
2324
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
@@ -50,46 +51,26 @@ import type {Group} from 'sentry/types/group';
5051
import {GroupStatus, GroupSubstatus} from 'sentry/types/group';
5152
import {getMessage, getTitle} from 'sentry/utils/events';
5253
import {useApiQuery} from 'sentry/utils/queryClient';
54+
import {decodeInteger} from 'sentry/utils/queryString';
5355
import useApi from 'sentry/utils/useApi';
5456
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
5557
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
58+
import {useLocation} from 'sentry/utils/useLocation';
59+
import {useNavigate} from 'sentry/utils/useNavigate';
5660
import useOrganization from 'sentry/utils/useOrganization';
5761
import usePageFilters from 'sentry/utils/usePageFilters';
5862
import {useUser} from 'sentry/utils/useUser';
5963
import {useUserTeams} from 'sentry/utils/useUserTeams';
64+
import {
65+
ClusterDetailDrawer,
66+
renderWithInlineCode,
67+
useClusterStats,
68+
type ClusterSummary,
69+
} from 'sentry/views/issueList/pages/topIssuesDrawer';
6070
import {openSeerExplorer} from 'sentry/views/seerExplorer/openSeerExplorer';
6171

6272
const CLUSTERS_PER_PAGE = 20;
6373

64-
interface AssignedEntity {
65-
email: string | null;
66-
id: string;
67-
name: string;
68-
type: string;
69-
}
70-
71-
interface ClusterSummary {
72-
assignedTo: AssignedEntity[];
73-
cluster_avg_similarity: number | null;
74-
cluster_id: number;
75-
cluster_min_similarity: number | null;
76-
cluster_size: number | null;
77-
description: string;
78-
fixability_score: number | null;
79-
group_ids: number[];
80-
issue_titles: string[];
81-
project_ids: number[];
82-
summary: string | null;
83-
tags: string[];
84-
title: string;
85-
code_area_tags?: string[];
86-
error_type?: string;
87-
error_type_tags?: string[];
88-
impact?: string;
89-
location?: string;
90-
service_tags?: string[];
91-
}
92-
9374
function formatClusterInfoForClipboard(cluster: ClusterSummary): string {
9475
const lines: string[] = [];
9576

@@ -113,19 +94,6 @@ function formatClusterPromptForSeer(cluster: ClusterSummary): string {
11394
return `I'd like to investigate this cluster of issues:\n\n${message}\n\nPlease help me understand the root cause and potential fixes for these related issues.`;
11495
}
11596

116-
function renderWithInlineCode(text: string): React.ReactNode {
117-
const parts = text.split(/(`[^`]+`)/g);
118-
if (parts.length === 1) {
119-
return text;
120-
}
121-
return parts.map((part, index) => {
122-
if (part.startsWith('`') && part.endsWith('`')) {
123-
return <InlineCode key={index}>{part.slice(1, -1)}</InlineCode>;
124-
}
125-
return part;
126-
});
127-
}
128-
12997
interface TopIssuesResponse {
13098
data: ClusterSummary[];
13199
last_updated?: string;
@@ -179,127 +147,6 @@ function CompactIssuePreview({group}: {group: Group}) {
179147
);
180148
}
181149

182-
interface ClusterStats {
183-
firstSeen: string | null;
184-
hasRegressedIssues: boolean;
185-
isEscalating: boolean;
186-
isPending: boolean;
187-
lastSeen: string | null;
188-
newIssuesCount: number;
189-
totalEvents: number;
190-
totalUsers: number;
191-
}
192-
193-
function useClusterStats(groupIds: number[]): ClusterStats {
194-
const organization = useOrganization();
195-
196-
const {data: groups, isPending} = useApiQuery<Group[]>(
197-
[
198-
`/organizations/${organization.slug}/issues/`,
199-
{
200-
query: {
201-
group: groupIds,
202-
query: `issue.id:[${groupIds.join(',')}]`,
203-
},
204-
},
205-
],
206-
{
207-
staleTime: 60000,
208-
enabled: groupIds.length > 0,
209-
}
210-
);
211-
212-
return useMemo(() => {
213-
if (isPending || !groups || groups.length === 0) {
214-
return {
215-
totalEvents: 0,
216-
totalUsers: 0,
217-
firstSeen: null,
218-
lastSeen: null,
219-
newIssuesCount: 0,
220-
hasRegressedIssues: false,
221-
isEscalating: false,
222-
isPending,
223-
};
224-
}
225-
226-
let totalEvents = 0;
227-
let totalUsers = 0;
228-
let earliestFirstSeen: Date | null = null;
229-
let latestLastSeen: Date | null = null;
230-
231-
// Calculate new issues (first seen within last week)
232-
const oneWeekAgo = new Date();
233-
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
234-
let newIssuesCount = 0;
235-
236-
// Check for regressed issues
237-
let hasRegressedIssues = false;
238-
239-
// Calculate escalation by summing event stats across all issues
240-
// We'll compare the first half of the 24h stats to the second half
241-
let firstHalfEvents = 0;
242-
let secondHalfEvents = 0;
243-
244-
for (const group of groups) {
245-
totalEvents += parseInt(group.count, 10) || 0;
246-
totalUsers += group.userCount || 0;
247-
248-
if (group.firstSeen) {
249-
const firstSeenDate = new Date(group.firstSeen);
250-
if (!earliestFirstSeen || firstSeenDate < earliestFirstSeen) {
251-
earliestFirstSeen = firstSeenDate;
252-
}
253-
// Check if this issue is new (first seen within last week)
254-
if (firstSeenDate >= oneWeekAgo) {
255-
newIssuesCount++;
256-
}
257-
}
258-
259-
if (group.lastSeen) {
260-
const lastSeenDate = new Date(group.lastSeen);
261-
if (!latestLastSeen || lastSeenDate > latestLastSeen) {
262-
latestLastSeen = lastSeenDate;
263-
}
264-
}
265-
266-
// Check for regressed substatus
267-
if (group.substatus === GroupSubstatus.REGRESSED) {
268-
hasRegressedIssues = true;
269-
}
270-
271-
// Aggregate 24h stats for escalation detection
272-
const stats24h = group.stats?.['24h'];
273-
if (stats24h && stats24h.length > 0) {
274-
const midpoint = Math.floor(stats24h.length / 2);
275-
for (let i = 0; i < stats24h.length; i++) {
276-
const eventCount = stats24h[i]?.[1] ?? 0;
277-
if (i < midpoint) {
278-
firstHalfEvents += eventCount;
279-
} else {
280-
secondHalfEvents += eventCount;
281-
}
282-
}
283-
}
284-
}
285-
286-
// Determine if escalating: second half has >1.5x events compared to first half
287-
// Only consider escalating if there were events in the first half (avoid division by zero)
288-
const isEscalating = firstHalfEvents > 0 && secondHalfEvents > firstHalfEvents * 1.5;
289-
290-
return {
291-
totalEvents,
292-
totalUsers,
293-
firstSeen: earliestFirstSeen?.toISOString() ?? null,
294-
lastSeen: latestLastSeen?.toISOString() ?? null,
295-
newIssuesCount,
296-
hasRegressedIssues,
297-
isEscalating,
298-
isPending,
299-
};
300-
}, [groups, isPending]);
301-
}
302-
303150
function ClusterIssues({groupIds}: {groupIds: number[]}) {
304151
const organization = useOrganization();
305152
const previewGroupIds = groupIds.slice(0, 3);
@@ -352,6 +199,7 @@ function ClusterCard({
352199
}: ClusterCardProps) {
353200
const api = useApi();
354201
const organization = useOrganization();
202+
const location = useLocation();
355203
const {selection} = usePageFilters();
356204
const [activeTab, setActiveTab] = useState<'summary' | 'root-cause' | 'issues'>(
357205
'summary'
@@ -480,7 +328,10 @@ function ClusterCard({
480328
<CardHeader>
481329
{cluster.impact && (
482330
<ClusterTitleLink
483-
to={`/organizations/${organization.slug}/issues/top-issues/?cluster=${cluster.cluster_id}`}
331+
to={{
332+
pathname: location.pathname,
333+
query: {...location.query, cluster: String(cluster.cluster_id)},
334+
}}
484335
>
485336
{cluster.impact}
486337
<Text
@@ -751,6 +602,9 @@ function ClusterCard({
751602

752603
function DynamicGrouping() {
753604
const organization = useOrganization();
605+
const location = useLocation();
606+
const navigate = useNavigate();
607+
const {openDrawer, isDrawerOpen} = useDrawer();
754608
const user = useUser();
755609
const {teams: userTeams} = useUserTeams();
756610
const {selection} = usePageFilters();
@@ -809,20 +663,47 @@ function DynamicGrouping() {
809663
};
810664

811665
const isUsingCustomData = customClusterData !== null;
666+
const clusterData = useMemo(
667+
() => customClusterData ?? topIssuesResponse?.data ?? [],
668+
[customClusterData, topIssuesResponse?.data]
669+
);
670+
671+
const selectedClusterId = decodeInteger(location.query.cluster);
672+
useEffect(() => {
673+
const selectedCluster = clusterData.find(
674+
cluster => cluster.cluster_id === selectedClusterId
675+
);
676+
if (selectedClusterId === undefined || !selectedCluster) {
677+
return;
678+
}
679+
680+
openDrawer(() => <ClusterDetailDrawer cluster={selectedCluster} />, {
681+
ariaLabel: t('Top issue details'),
682+
drawerKey: 'top-issues-cluster-drawer',
683+
onClose: () => {
684+
navigate(
685+
{
686+
query: {...qs.parse(window.location.search), cluster: undefined},
687+
},
688+
{replace: true, preventScrollReset: true}
689+
);
690+
},
691+
shouldCloseOnLocationChange: nextLocation => !nextLocation.query.cluster,
692+
});
693+
}, [clusterData, openDrawer, navigate, isDrawerOpen, selectedClusterId]);
812694

813695
// Extract all unique teams from the cluster data (for dev tools filter UI)
814696
const teamsInData = useMemo(() => {
815-
const data = topIssuesResponse?.data ?? [];
816697
const teamMap = new Map<string, {id: string; name: string}>();
817-
for (const cluster of data) {
698+
for (const cluster of clusterData) {
818699
for (const entity of cluster.assignedTo ?? []) {
819700
if (entity.type === 'team' && !teamMap.has(entity.id)) {
820701
teamMap.set(entity.id, {id: entity.id, name: entity.name});
821702
}
822703
}
823704
}
824705
return Array.from(teamMap.values()).sort((a, b) => a.name.localeCompare(b.name));
825-
}, [topIssuesResponse?.data]);
706+
}, [clusterData]);
826707

827708
const isTeamFilterActive = selectedTeamIds.size > 0;
828709

@@ -844,8 +725,6 @@ function DynamicGrouping() {
844725
};
845726

846727
const filteredAndSortedClusters = useMemo(() => {
847-
const clusterData = customClusterData ?? topIssuesResponse?.data ?? [];
848-
849728
if (isUsingCustomData && disableFilters) {
850729
return clusterData;
851730
}
@@ -900,8 +779,7 @@ function DynamicGrouping() {
900779

901780
return result.sort((a, b) => (b.fixability_score ?? 0) - (a.fixability_score ?? 0));
902781
}, [
903-
customClusterData,
904-
topIssuesResponse?.data,
782+
clusterData,
905783
isUsingCustomData,
906784
disableFilters,
907785
selection.projects,
@@ -960,9 +838,6 @@ function DynamicGrouping() {
960838
</CustomDataBadge>
961839
)}
962840
</Flex>
963-
<Link to={`/organizations/${organization.slug}/issues/top-issues/`}>
964-
<Button size="sm">{t('View Single Card Layout')}</Button>
965-
</Link>
966841
</Flex>
967842

968843
<Flex gap="sm" align="center" style={{marginBottom: space(2)}}>
@@ -979,7 +854,7 @@ function DynamicGrouping() {
979854
<FeedbackButton
980855
size="sm"
981856
feedbackOptions={{
982-
messagePlaceholder: t('What do you think about the new Top Issues page?'),
857+
messagePlaceholder: t('What do you think about the Top Issues drawer?'),
983858
tags: {
984859
['feedback.source']: 'top-issues',
985860
['feedback.owner']: 'issues',

0 commit comments

Comments
 (0)