utils.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import type {Location, LocationDescriptorObject} from 'history';
  2. import ExternalLink from 'sentry/components/links/externalLink';
  3. import {DEFAULT_QUERY} from 'sentry/constants';
  4. import {t, tct} from 'sentry/locale';
  5. import type {Event} from 'sentry/types/event';
  6. import type {Group, GroupTombstoneHelper} from 'sentry/types/group';
  7. import type {Organization} from 'sentry/types/organization';
  8. export enum Query {
  9. FOR_REVIEW = 'is:unresolved is:for_review assigned_or_suggested:[me, my_teams, none]',
  10. // biome-ignore lint/style/useLiteralEnumMembers: Disable for maintenance cost.
  11. PRIORITIZED = DEFAULT_QUERY,
  12. UNRESOLVED = 'is:unresolved',
  13. IGNORED = 'is:ignored',
  14. NEW = 'is:new',
  15. ARCHIVED = 'is:archived',
  16. ESCALATING = 'is:escalating',
  17. REGRESSED = 'is:regressed',
  18. REPROCESSING = 'is:reprocessing',
  19. }
  20. export const CUSTOM_TAB_VALUE = '__custom__';
  21. type OverviewTab = {
  22. /**
  23. * Emitted analytics event tab name
  24. */
  25. analyticsName: string;
  26. /**
  27. * Will fetch a count to display on this tab
  28. */
  29. count: boolean;
  30. /**
  31. * Tabs can be disabled via flag
  32. */
  33. enabled: boolean;
  34. name: string;
  35. hidden?: boolean;
  36. /**
  37. * Tooltip text to be hoverable when text has links
  38. */
  39. tooltipHoverable?: boolean;
  40. /**
  41. * Tooltip text for each tab
  42. */
  43. tooltipTitle?: React.ReactNode;
  44. };
  45. /**
  46. * Get a list of currently active tabs
  47. */
  48. export function getTabs() {
  49. const tabs: Array<[string, OverviewTab]> = [
  50. [
  51. Query.PRIORITIZED,
  52. {
  53. name: t('Prioritized'),
  54. analyticsName: 'prioritized',
  55. count: true,
  56. enabled: true,
  57. },
  58. ],
  59. [
  60. Query.FOR_REVIEW,
  61. {
  62. name: t('For Review'),
  63. analyticsName: 'needs_review',
  64. count: true,
  65. enabled: true,
  66. tooltipTitle: t(
  67. 'Issues are marked for review if they are new or escalating, and have not been resolved or archived. Issues are automatically marked reviewed in 7 days.'
  68. ),
  69. },
  70. ],
  71. [
  72. Query.REGRESSED,
  73. {
  74. name: t('Regressed'),
  75. analyticsName: 'regressed',
  76. count: true,
  77. enabled: true,
  78. },
  79. ],
  80. [
  81. Query.ESCALATING,
  82. {
  83. name: t('Escalating'),
  84. analyticsName: 'escalating',
  85. count: true,
  86. enabled: true,
  87. },
  88. ],
  89. [
  90. Query.ARCHIVED,
  91. {
  92. name: t('Archived'),
  93. analyticsName: 'archived',
  94. count: true,
  95. enabled: true,
  96. },
  97. ],
  98. [
  99. Query.IGNORED,
  100. {
  101. name: t('Ignored'),
  102. analyticsName: 'ignored',
  103. count: true,
  104. enabled: false,
  105. tooltipTitle: t(`Ignored issues don’t trigger alerts. When their ignore
  106. conditions are met they become Unresolved and are flagged for review.`),
  107. },
  108. ],
  109. [
  110. Query.REPROCESSING,
  111. {
  112. name: t('Reprocessing'),
  113. analyticsName: 'reprocessing',
  114. count: true,
  115. enabled: true,
  116. tooltipTitle: tct(
  117. `These [link:reprocessing issues] will take some time to complete.
  118. Any new issues that are created during reprocessing will be flagged for review.`,
  119. {
  120. link: (
  121. <ExternalLink href="https://docs.sentry.io/product/error-monitoring/reprocessing/" />
  122. ),
  123. }
  124. ),
  125. tooltipHoverable: true,
  126. },
  127. ],
  128. [
  129. // Hidden tab to account for custom queries that don't match any of the queries
  130. // above. It's necessary because if Tabs's value doesn't match that of any tab item
  131. // then Tabs will fall back to a default value, causing unexpected behaviors.
  132. CUSTOM_TAB_VALUE,
  133. {
  134. name: t('Custom'),
  135. analyticsName: 'custom',
  136. hidden: true,
  137. count: false,
  138. enabled: true,
  139. },
  140. ],
  141. ];
  142. return tabs.filter(([_query, tab]) => tab.enabled);
  143. }
  144. /**
  145. * @returns queries that should have counts fetched
  146. */
  147. export function getTabsWithCounts() {
  148. const tabs = getTabs();
  149. return tabs.filter(([_query, tab]) => tab.count).map(([query]) => query);
  150. }
  151. export function isForReviewQuery(query: string | undefined) {
  152. return !!query && /\bis:for_review\b/.test(query);
  153. }
  154. // the tab counts will look like 99+
  155. export const TAB_MAX_COUNT = 99;
  156. type QueryCount = {
  157. count: number;
  158. hasMore: boolean;
  159. };
  160. export type QueryCounts = Partial<Record<Query, QueryCount>>;
  161. export enum IssueSortOptions {
  162. DATE = 'date',
  163. NEW = 'new',
  164. TRENDS = 'trends',
  165. FREQ = 'freq',
  166. USER = 'user',
  167. INBOX = 'inbox',
  168. }
  169. export const DEFAULT_ISSUE_STREAM_SORT = IssueSortOptions.DATE;
  170. export function isDefaultIssueStreamSearch({query, sort}: {query: string; sort: string}) {
  171. return query === DEFAULT_QUERY && sort === DEFAULT_ISSUE_STREAM_SORT;
  172. }
  173. export function getSortLabel(key: string) {
  174. switch (key) {
  175. case IssueSortOptions.NEW:
  176. return t('First Seen');
  177. case IssueSortOptions.TRENDS:
  178. return t('Trends');
  179. case IssueSortOptions.FREQ:
  180. return t('Events');
  181. case IssueSortOptions.USER:
  182. return t('Users');
  183. case IssueSortOptions.INBOX:
  184. return t('Date Added');
  185. case IssueSortOptions.DATE:
  186. default:
  187. return t('Last Seen');
  188. }
  189. }
  190. export const DISCOVER_EXCLUSION_FIELDS: string[] = [
  191. 'query',
  192. 'status',
  193. 'bookmarked_by',
  194. 'assigned',
  195. 'assigned_to',
  196. 'unassigned',
  197. 'subscribed_by',
  198. 'active_at',
  199. 'first_release',
  200. 'first_seen',
  201. 'is',
  202. '__text',
  203. 'issue.priority',
  204. 'issue.category',
  205. 'issue.type',
  206. ];
  207. export const FOR_REVIEW_QUERIES: string[] = [Query.FOR_REVIEW];
  208. export const SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY =
  209. 'issue-stream-saved-searches-sidebar-open';
  210. export enum IssueGroup {
  211. ALL = 'all',
  212. ERROR_OUTAGE = 'error_outage',
  213. TREND = 'trend',
  214. CRAFTSMANSHIP = 'craftsmanship',
  215. SECURITY = 'security',
  216. }
  217. const IssueGroupFilter: Record<IssueGroup, string> = {
  218. [IssueGroup.ALL]: '',
  219. [IssueGroup.ERROR_OUTAGE]: 'issue.category:[error,cron,uptime]',
  220. [IssueGroup.TREND]:
  221. 'issue.type:[profile_function_regression,performance_p95_endpoint_regression,performance_n_plus_one_db_queries]',
  222. [IssueGroup.CRAFTSMANSHIP]:
  223. 'issue.category:replay issue.type:[performance_n_plus_one_db_queries,performance_n_plus_one_api_calls,performance_consecutive_db_queries,performance_render_blocking_asset_span,performance_uncompressed_assets,profile_file_io_main_thread,profile_image_decode_main_thread,profile_json_decode_main_thread,profile_regex_main_thread]',
  224. [IssueGroup.SECURITY]: 'event.type:[nel,csp]',
  225. };
  226. function getIssueGroupFilter(group: IssueGroup): string {
  227. if (!Object.hasOwn(IssueGroupFilter, group)) {
  228. throw new Error(`Unknown issue group "${group}"`);
  229. }
  230. return IssueGroupFilter[group];
  231. }
  232. /** Generate a properly encoded `?query=` string for a given issue group */
  233. export function getSearchForIssueGroup(group: IssueGroup): string {
  234. return `?${new URLSearchParams(`query=is:unresolved+${getIssueGroupFilter(group)}`)}`;
  235. }
  236. export function createIssueLink({
  237. organization,
  238. data,
  239. eventId,
  240. referrer,
  241. streamIndex,
  242. location,
  243. query,
  244. }: {
  245. data: Event | Group | GroupTombstoneHelper;
  246. location: Location;
  247. organization: Organization;
  248. eventId?: string;
  249. query?: string;
  250. referrer?: string;
  251. streamIndex?: number;
  252. }): LocationDescriptorObject {
  253. const {id} = data as Group;
  254. const {eventID: latestEventId, groupID} = data as Event;
  255. // If we have passed in a custom event ID, use it; otherwise use default
  256. const finalEventId = eventId ?? latestEventId;
  257. return {
  258. pathname: `/organizations/${organization.slug}/issues/${
  259. latestEventId ? groupID : id
  260. }/${finalEventId ? `events/${finalEventId}/` : ''}`,
  261. query: {
  262. referrer: referrer || 'event-or-group-header',
  263. stream_index: streamIndex,
  264. query,
  265. // This adds sort to the query if one was selected from the
  266. // issues list page
  267. ...(location.query.sort !== undefined ? {sort: location.query.sort} : {}),
  268. // This appends _allp to the URL parameters if they have no
  269. // project selected ("all" projects included in results). This is
  270. // so that when we enter the issue details page and lock them to
  271. // a project, we can properly take them back to the issue list
  272. // page with no project selected (and not the locked project
  273. // selected)
  274. ...(location.query.project !== undefined ? {} : {_allp: 1}),
  275. },
  276. };
  277. }