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