utils.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  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, // eslint-disable-line @typescript-eslint/prefer-literal-enum-member
  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. export 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, organization: Organization) {
  174. switch (key) {
  175. case IssueSortOptions.NEW:
  176. return organization.features.includes('issue-stream-table-layout')
  177. ? t('Age')
  178. : t('First Seen');
  179. case IssueSortOptions.TRENDS:
  180. return t('Trends');
  181. case IssueSortOptions.FREQ:
  182. return t('Events');
  183. case IssueSortOptions.USER:
  184. return t('Users');
  185. case IssueSortOptions.INBOX:
  186. return t('Date Added');
  187. case IssueSortOptions.DATE:
  188. default:
  189. return t('Last Seen');
  190. }
  191. }
  192. export const DISCOVER_EXCLUSION_FIELDS: string[] = [
  193. 'query',
  194. 'status',
  195. 'bookmarked_by',
  196. 'assigned',
  197. 'assigned_to',
  198. 'unassigned',
  199. 'subscribed_by',
  200. 'active_at',
  201. 'first_release',
  202. 'first_seen',
  203. 'is',
  204. '__text',
  205. 'issue.priority',
  206. 'issue.category',
  207. 'issue.type',
  208. ];
  209. export const FOR_REVIEW_QUERIES: string[] = [Query.FOR_REVIEW];
  210. export const SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY =
  211. 'issue-stream-saved-searches-sidebar-open';
  212. export enum IssueGroup {
  213. ALL = 'all',
  214. ERROR_OUTAGE = 'error_outage',
  215. TREND = 'trend',
  216. CRAFTSMANSHIP = 'craftsmanship',
  217. SECURITY = 'security',
  218. }
  219. const IssueGroupFilter: Record<IssueGroup, string> = {
  220. [IssueGroup.ALL]: '',
  221. [IssueGroup.ERROR_OUTAGE]: 'issue.category:[error,cron,uptime]',
  222. [IssueGroup.TREND]:
  223. 'issue.type:[profile_function_regression,performance_p95_endpoint_regression,performance_n_plus_one_db_queries]',
  224. [IssueGroup.CRAFTSMANSHIP]:
  225. '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]',
  226. [IssueGroup.SECURITY]: 'event.type:[nel,csp]',
  227. };
  228. function getIssueGroupFilter(group: IssueGroup): string {
  229. if (!Object.hasOwn(IssueGroupFilter, group)) {
  230. throw new Error(`Unknown issue group "${group}"`);
  231. }
  232. return IssueGroupFilter[group];
  233. }
  234. /** Generate a properly encoded `?query=` string for a given issue group */
  235. export function getSearchForIssueGroup(group: IssueGroup): string {
  236. return `?${new URLSearchParams(`query=is:unresolved+${getIssueGroupFilter(group)}`)}`;
  237. }
  238. export function createIssueLink({
  239. organization,
  240. data,
  241. eventId,
  242. referrer,
  243. streamIndex,
  244. location,
  245. query,
  246. }: {
  247. data: Event | Group | GroupTombstoneHelper;
  248. location: Location;
  249. organization: Organization;
  250. eventId?: string;
  251. query?: string;
  252. referrer?: string;
  253. streamIndex?: number;
  254. }): LocationDescriptorObject {
  255. const {id} = data as Group;
  256. const {eventID: latestEventId, groupID} = data as Event;
  257. // If we have passed in a custom event ID, use it; otherwise use default
  258. const finalEventId = eventId ?? latestEventId;
  259. return {
  260. pathname: `/organizations/${organization.slug}/issues/${
  261. latestEventId ? groupID : id
  262. }/${finalEventId ? `events/${finalEventId}/` : ''}`,
  263. query: {
  264. referrer: referrer || 'event-or-group-header',
  265. stream_index: streamIndex,
  266. query,
  267. // This adds sort to the query if one was selected from the
  268. // issues list page
  269. ...(location.query.sort !== undefined ? {sort: location.query.sort} : {}),
  270. // This appends _allp to the URL parameters if they have no
  271. // project selected ("all" projects included in results). This is
  272. // so that when we enter the issue details page and lock them to
  273. // a project, we can properly take them back to the issue list
  274. // page with no project selected (and not the locked project
  275. // selected)
  276. ...(location.query.project !== undefined ? {} : {_allp: 1}),
  277. },
  278. };
  279. }