eventView.tsx 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283
  1. import {Location, Query} from 'history';
  2. import cloneDeep from 'lodash/cloneDeep';
  3. import isEqual from 'lodash/isEqual';
  4. import isString from 'lodash/isString';
  5. import omit from 'lodash/omit';
  6. import pick from 'lodash/pick';
  7. import uniqBy from 'lodash/uniqBy';
  8. import moment from 'moment';
  9. import {EventQuery} from 'app/actionCreators/events';
  10. import {COL_WIDTH_UNDEFINED} from 'app/components/gridEditable';
  11. import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
  12. import {DEFAULT_PER_PAGE} from 'app/constants';
  13. import {URL_PARAM} from 'app/constants/globalSelectionHeader';
  14. import {t} from 'app/locale';
  15. import {GlobalSelection, NewQuery, SavedQuery, SelectValue, User} from 'app/types';
  16. import {decodeList, decodeScalar} from 'app/utils/queryString';
  17. import {
  18. FieldValueKind,
  19. TableColumn,
  20. TableColumnSort,
  21. } from 'app/views/eventsV2/table/types';
  22. import {decodeColumnOrder} from 'app/views/eventsV2/utils';
  23. import {statsPeriodToDays} from '../dates';
  24. import {QueryResults, stringifyQueryObject, tokenizeSearch} from '../tokenizeSearch';
  25. import {getSortField} from './fieldRenderers';
  26. import {
  27. aggregateOutputType,
  28. Column,
  29. ColumnType,
  30. Field,
  31. generateFieldAsString,
  32. getAggregateAlias,
  33. getEquation,
  34. isAggregateField,
  35. isEquation,
  36. isLegalYAxisType,
  37. Sort,
  38. } from './fields';
  39. import {
  40. CHART_AXIS_OPTIONS,
  41. DISPLAY_MODE_FALLBACK_OPTIONS,
  42. DISPLAY_MODE_OPTIONS,
  43. DisplayModes,
  44. } from './types';
  45. // Metadata mapping for discover results.
  46. export type MetaType = Record<string, ColumnType>;
  47. // Data in discover results.
  48. export type EventData = Record<string, any>;
  49. export type LocationQuery = {
  50. start?: string | string[];
  51. end?: string | string[];
  52. utc?: string | string[];
  53. statsPeriod?: string | string[];
  54. cursor?: string | string[];
  55. };
  56. const DATETIME_QUERY_STRING_KEYS = ['start', 'end', 'utc', 'statsPeriod'] as const;
  57. const EXTERNAL_QUERY_STRING_KEYS: Readonly<Array<keyof LocationQuery>> = [
  58. ...DATETIME_QUERY_STRING_KEYS,
  59. 'cursor',
  60. ];
  61. const setSortOrder = (sort: Sort, kind: 'desc' | 'asc'): Sort => ({
  62. kind,
  63. field: sort.field,
  64. });
  65. const reverseSort = (sort: Sort): Sort => ({
  66. kind: sort.kind === 'desc' ? 'asc' : 'desc',
  67. field: sort.field,
  68. });
  69. const isSortEqualToField = (
  70. sort: Sort,
  71. field: Field,
  72. tableMeta: MetaType | undefined
  73. ): boolean => {
  74. const sortKey = getSortKeyFromField(field, tableMeta);
  75. return sort.field === sortKey;
  76. };
  77. const fieldToSort = (
  78. field: Field,
  79. tableMeta: MetaType | undefined,
  80. kind?: 'desc' | 'asc'
  81. ): Sort | undefined => {
  82. const sortKey = getSortKeyFromField(field, tableMeta);
  83. if (!sortKey) {
  84. return void 0;
  85. }
  86. return {
  87. kind: kind || 'desc',
  88. field: sortKey,
  89. };
  90. };
  91. function getSortKeyFromField(field: Field, tableMeta?: MetaType): string | null {
  92. const alias = getAggregateAlias(field.field);
  93. return getSortField(alias, tableMeta);
  94. }
  95. export function isFieldSortable(field: Field, tableMeta?: MetaType): boolean {
  96. return !!getSortKeyFromField(field, tableMeta);
  97. }
  98. const decodeFields = (location: Location): Array<Field> => {
  99. const {query} = location;
  100. if (!query || !query.field) {
  101. return [];
  102. }
  103. const fields = decodeList(query.field);
  104. const widths = decodeList(query.widths);
  105. const parsed: Field[] = [];
  106. fields.forEach((field, i) => {
  107. const w = Number(widths[i]);
  108. const width = !isNaN(w) ? w : COL_WIDTH_UNDEFINED;
  109. parsed.push({field, width});
  110. });
  111. return parsed;
  112. };
  113. const parseSort = (sort: string): Sort => {
  114. sort = sort.trim();
  115. if (sort.startsWith('-')) {
  116. return {
  117. kind: 'desc',
  118. field: sort.substring(1),
  119. };
  120. }
  121. return {
  122. kind: 'asc',
  123. field: sort,
  124. };
  125. };
  126. export const fromSorts = (sorts: string | string[] | undefined): Array<Sort> => {
  127. if (sorts === undefined) {
  128. return [];
  129. }
  130. sorts = isString(sorts) ? [sorts] : sorts;
  131. // NOTE: sets are iterated in insertion order
  132. const uniqueSorts = [...new Set(sorts)];
  133. return uniqueSorts.reduce((acc: Array<Sort>, sort: string) => {
  134. acc.push(parseSort(sort));
  135. return acc;
  136. }, []);
  137. };
  138. const decodeSorts = (location: Location): Array<Sort> => {
  139. const {query} = location;
  140. if (!query || !query.sort) {
  141. return [];
  142. }
  143. const sorts = decodeList(query.sort);
  144. return fromSorts(sorts);
  145. };
  146. const encodeSort = (sort: Sort): string => {
  147. switch (sort.kind) {
  148. case 'desc': {
  149. return `-${sort.field}`;
  150. }
  151. case 'asc': {
  152. return String(sort.field);
  153. }
  154. default: {
  155. throw new Error('Unexpected sort type');
  156. }
  157. }
  158. };
  159. const encodeSorts = (sorts: Readonly<Array<Sort>>): Array<string> =>
  160. sorts.map(encodeSort);
  161. const collectQueryStringByKey = (query: Query, key: string): Array<string> => {
  162. const needle = query[key];
  163. const collection = decodeList(needle);
  164. return collection.reduce((acc: Array<string>, item: string) => {
  165. item = item.trim();
  166. if (item.length > 0) {
  167. acc.push(item);
  168. }
  169. return acc;
  170. }, []);
  171. };
  172. const decodeQuery = (location: Location): string => {
  173. if (!location.query || !location.query.query) {
  174. return '';
  175. }
  176. const queryParameter = location.query.query;
  177. return decodeScalar(queryParameter, '').trim();
  178. };
  179. const decodeTeam = (value: string): 'myteams' | number => {
  180. if (value === 'myteams') {
  181. return value;
  182. }
  183. return parseInt(value, 10);
  184. };
  185. const decodeTeams = (location: Location): ('myteams' | number)[] => {
  186. if (!location.query || !location.query.team) {
  187. return [];
  188. }
  189. const value = location.query.team;
  190. return Array.isArray(value) ? value.map(decodeTeam) : [decodeTeam(value)];
  191. };
  192. const decodeProjects = (location: Location): number[] => {
  193. if (!location.query || !location.query.project) {
  194. return [];
  195. }
  196. const value = location.query.project;
  197. return Array.isArray(value) ? value.map(i => parseInt(i, 10)) : [parseInt(value, 10)];
  198. };
  199. const queryStringFromSavedQuery = (saved: NewQuery | SavedQuery): string => {
  200. if (saved.query) {
  201. return saved.query || '';
  202. }
  203. return '';
  204. };
  205. function validateTableMeta(tableMeta: MetaType | undefined): MetaType | undefined {
  206. return tableMeta && Object.keys(tableMeta).length > 0 ? tableMeta : undefined;
  207. }
  208. class EventView {
  209. id: string | undefined;
  210. name: string | undefined;
  211. fields: Readonly<Field[]>;
  212. sorts: Readonly<Sort[]>;
  213. query: string;
  214. team: Readonly<('myteams' | number)[]>;
  215. project: Readonly<number[]>;
  216. start: string | undefined;
  217. end: string | undefined;
  218. statsPeriod: string | undefined;
  219. environment: Readonly<string[]>;
  220. yAxis: string | undefined;
  221. display: string | undefined;
  222. interval: string | undefined;
  223. expired?: boolean;
  224. createdBy: User | undefined;
  225. additionalConditions: QueryResults; // This allows views to always add additional conditins to the query to get specific data. It should not show up in the UI unless explicitly called.
  226. constructor(props: {
  227. id: string | undefined;
  228. name: string | undefined;
  229. fields: Readonly<Field[]>;
  230. sorts: Readonly<Sort[]>;
  231. query: string;
  232. team: Readonly<('myteams' | number)[]>;
  233. project: Readonly<number[]>;
  234. start: string | undefined;
  235. end: string | undefined;
  236. statsPeriod: string | undefined;
  237. environment: Readonly<string[]>;
  238. yAxis: string | undefined;
  239. display: string | undefined;
  240. interval?: string;
  241. expired?: boolean;
  242. createdBy: User | undefined;
  243. additionalConditions: QueryResults;
  244. }) {
  245. const fields: Field[] = Array.isArray(props.fields) ? props.fields : [];
  246. let sorts: Sort[] = Array.isArray(props.sorts) ? props.sorts : [];
  247. const team = Array.isArray(props.team) ? props.team : [];
  248. const project = Array.isArray(props.project) ? props.project : [];
  249. const environment = Array.isArray(props.environment) ? props.environment : [];
  250. // only include sort keys that are included in the fields
  251. const sortKeys = fields
  252. .map(field => getSortKeyFromField(field, undefined))
  253. .filter((sortKey): sortKey is string => !!sortKey);
  254. const sort = sorts.find(currentSort => sortKeys.includes(currentSort.field));
  255. sorts = sort ? [sort] : [];
  256. const id = props.id !== null && props.id !== void 0 ? String(props.id) : void 0;
  257. this.id = id;
  258. this.name = props.name;
  259. this.fields = fields;
  260. this.sorts = sorts;
  261. this.query = typeof props.query === 'string' ? props.query : '';
  262. this.team = team;
  263. this.project = project;
  264. this.start = props.start;
  265. this.end = props.end;
  266. this.statsPeriod = props.statsPeriod;
  267. this.environment = environment;
  268. this.yAxis = props.yAxis;
  269. this.display = props.display;
  270. this.interval = props.interval;
  271. this.createdBy = props.createdBy;
  272. this.expired = props.expired;
  273. this.additionalConditions = props.additionalConditions ?? new QueryResults([]);
  274. }
  275. static fromLocation(location: Location): EventView {
  276. const {start, end, statsPeriod} = getParams(location.query);
  277. return new EventView({
  278. id: decodeScalar(location.query.id),
  279. name: decodeScalar(location.query.name),
  280. fields: decodeFields(location),
  281. sorts: decodeSorts(location),
  282. query: decodeQuery(location),
  283. team: decodeTeams(location),
  284. project: decodeProjects(location),
  285. start: decodeScalar(start),
  286. end: decodeScalar(end),
  287. statsPeriod: decodeScalar(statsPeriod),
  288. environment: collectQueryStringByKey(location.query, 'environment'),
  289. yAxis: decodeScalar(location.query.yAxis),
  290. display: decodeScalar(location.query.display),
  291. interval: decodeScalar(location.query.interval),
  292. createdBy: undefined,
  293. additionalConditions: new QueryResults([]),
  294. });
  295. }
  296. static fromNewQueryWithLocation(newQuery: NewQuery, location: Location): EventView {
  297. const query = location.query;
  298. // apply global selection header values from location whenever possible
  299. const environment: string[] =
  300. Array.isArray(newQuery.environment) && newQuery.environment.length > 0
  301. ? newQuery.environment
  302. : collectQueryStringByKey(query, 'environment');
  303. const project: number[] =
  304. Array.isArray(newQuery.projects) && newQuery.projects.length > 0
  305. ? newQuery.projects
  306. : decodeProjects(location);
  307. const saved: NewQuery = {
  308. ...newQuery,
  309. environment,
  310. projects: project,
  311. // datetime selection
  312. start: newQuery.start || decodeScalar(query.start),
  313. end: newQuery.end || decodeScalar(query.end),
  314. range: newQuery.range || decodeScalar(query.statsPeriod),
  315. };
  316. return EventView.fromSavedQuery(saved);
  317. }
  318. static getFields(saved: NewQuery | SavedQuery) {
  319. return saved.fields.map((field, i) => {
  320. const width =
  321. saved.widths && saved.widths[i] ? Number(saved.widths[i]) : COL_WIDTH_UNDEFINED;
  322. return {field, width};
  323. });
  324. }
  325. static fromSavedQuery(saved: NewQuery | SavedQuery): EventView {
  326. const fields = EventView.getFields(saved);
  327. // normalize datetime selection
  328. const {start, end, statsPeriod} = getParams({
  329. start: saved.start,
  330. end: saved.end,
  331. statsPeriod: saved.range,
  332. });
  333. return new EventView({
  334. id: saved.id,
  335. name: saved.name,
  336. fields,
  337. query: queryStringFromSavedQuery(saved),
  338. team: saved.teams ?? [],
  339. project: saved.projects,
  340. start: decodeScalar(start),
  341. end: decodeScalar(end),
  342. statsPeriod: decodeScalar(statsPeriod),
  343. sorts: fromSorts(saved.orderby),
  344. environment: collectQueryStringByKey(
  345. {
  346. environment: saved.environment as string[],
  347. },
  348. 'environment'
  349. ),
  350. yAxis: saved.yAxis,
  351. display: saved.display,
  352. createdBy: saved.createdBy,
  353. expired: saved.expired,
  354. additionalConditions: new QueryResults([]),
  355. });
  356. }
  357. static fromSavedQueryOrLocation(
  358. saved: SavedQuery | undefined,
  359. location: Location
  360. ): EventView {
  361. let fields = decodeFields(location);
  362. const {start, end, statsPeriod} = getParams(location.query);
  363. const id = decodeScalar(location.query.id);
  364. const teams = decodeTeams(location);
  365. const projects = decodeProjects(location);
  366. const sorts = decodeSorts(location);
  367. const environments = collectQueryStringByKey(location.query, 'environment');
  368. if (saved) {
  369. if (fields.length === 0) {
  370. fields = EventView.getFields(saved);
  371. }
  372. return new EventView({
  373. id: id || saved.id,
  374. name: decodeScalar(location.query.name) || saved.name,
  375. fields,
  376. query:
  377. 'query' in location.query
  378. ? decodeQuery(location)
  379. : queryStringFromSavedQuery(saved),
  380. sorts: sorts.length === 0 ? fromSorts(saved.orderby) : sorts,
  381. yAxis: decodeScalar(location.query.yAxis) || saved.yAxis,
  382. display: decodeScalar(location.query.display) || saved.display,
  383. interval: decodeScalar(location.query.interval),
  384. createdBy: saved.createdBy,
  385. expired: saved.expired,
  386. additionalConditions: new QueryResults([]),
  387. // Always read team from location since they can be set by other parts
  388. // of the UI
  389. team: teams,
  390. // Always read project and environment from location since they can
  391. // be set by the GlobalSelectionHeaders.
  392. project: projects,
  393. environment: environments,
  394. start: decodeScalar(start),
  395. end: decodeScalar(end),
  396. statsPeriod: decodeScalar(statsPeriod),
  397. });
  398. }
  399. return EventView.fromLocation(location);
  400. }
  401. isEqualTo(other: EventView): boolean {
  402. const keys = [
  403. 'id',
  404. 'name',
  405. 'query',
  406. 'statsPeriod',
  407. 'fields',
  408. 'sorts',
  409. 'project',
  410. 'environment',
  411. 'yAxis',
  412. 'display',
  413. ];
  414. for (const key of keys) {
  415. const currentValue = this[key];
  416. const otherValue = other[key];
  417. if (!isEqual(currentValue, otherValue)) {
  418. return false;
  419. }
  420. }
  421. // compare datetime selections using moment
  422. const dateTimeKeys = ['start', 'end'];
  423. for (const key of dateTimeKeys) {
  424. const currentValue = this[key];
  425. const otherValue = other[key];
  426. if (currentValue && otherValue) {
  427. const currentDateTime = moment.utc(currentValue);
  428. const othereDateTime = moment.utc(otherValue);
  429. if (!currentDateTime.isSame(othereDateTime)) {
  430. return false;
  431. }
  432. }
  433. }
  434. return true;
  435. }
  436. toNewQuery(): NewQuery {
  437. const orderby = this.sorts.length > 0 ? encodeSorts(this.sorts)[0] : undefined;
  438. const newQuery: NewQuery = {
  439. version: 2,
  440. id: this.id,
  441. name: this.name || '',
  442. fields: this.getFields(),
  443. widths: this.getWidths().map(w => String(w)),
  444. orderby,
  445. query: this.query || '',
  446. projects: this.project,
  447. start: this.start,
  448. end: this.end,
  449. range: this.statsPeriod,
  450. environment: this.environment,
  451. yAxis: this.yAxis,
  452. display: this.display,
  453. };
  454. if (!newQuery.query) {
  455. // if query is an empty string, then it cannot be saved, so we omit it
  456. // from the payload
  457. delete newQuery.query;
  458. }
  459. return newQuery;
  460. }
  461. getGlobalSelection(): GlobalSelection {
  462. return {
  463. projects: this.project as number[],
  464. environments: this.environment as string[],
  465. datetime: {
  466. start: this.start ?? null,
  467. end: this.end ?? null,
  468. period: this.statsPeriod ?? '',
  469. // TODO(tony) Add support for the Use UTC option from
  470. // the global headers, currently, that option is not
  471. // supported and all times are assumed to be UTC
  472. utc: true,
  473. },
  474. };
  475. }
  476. getGlobalSelectionQuery(): Query {
  477. const {
  478. environments: environment,
  479. projects,
  480. datetime: {start, end, period, utc},
  481. } = this.getGlobalSelection();
  482. return {
  483. project: projects.map(proj => proj.toString()),
  484. environment,
  485. utc: utc ? 'true' : 'false',
  486. // since these values are from `getGlobalSelection`
  487. // we know they have type `string | null`
  488. start: (start ?? undefined) as string | undefined,
  489. end: (end ?? undefined) as string | undefined,
  490. // we can't use the ?? operator here as we want to
  491. // convert the empty string to undefined
  492. statsPeriod: period ? period : undefined,
  493. };
  494. }
  495. generateBlankQueryStringObject(): Query {
  496. const output = {
  497. id: undefined,
  498. name: undefined,
  499. field: undefined,
  500. widths: undefined,
  501. sort: undefined,
  502. tag: undefined,
  503. query: undefined,
  504. yAxis: undefined,
  505. display: undefined,
  506. interval: undefined,
  507. };
  508. for (const field of EXTERNAL_QUERY_STRING_KEYS) {
  509. output[field] = undefined;
  510. }
  511. return output;
  512. }
  513. generateQueryStringObject(): Query {
  514. const output = {
  515. id: this.id,
  516. name: this.name,
  517. field: this.getFields(),
  518. widths: this.getWidths(),
  519. sort: encodeSorts(this.sorts),
  520. environment: this.environment,
  521. project: this.project,
  522. query: this.query,
  523. yAxis: this.yAxis,
  524. display: this.display,
  525. interval: this.interval,
  526. };
  527. for (const field of EXTERNAL_QUERY_STRING_KEYS) {
  528. if (this[field] && this[field].length) {
  529. output[field] = this[field];
  530. }
  531. }
  532. return cloneDeep(output as any);
  533. }
  534. isValid(): boolean {
  535. return this.fields.length > 0;
  536. }
  537. getWidths(): number[] {
  538. return this.fields.map(field => (field.width ? field.width : COL_WIDTH_UNDEFINED));
  539. }
  540. getFields(): string[] {
  541. return this.fields.map(field => field.field);
  542. }
  543. getEquations(): string[] {
  544. return this.fields
  545. .filter(field => isEquation(field.field))
  546. .map(field => getEquation(field.field));
  547. }
  548. getAggregateFields(): Field[] {
  549. return this.fields.filter(field => isAggregateField(field.field));
  550. }
  551. hasAggregateField() {
  552. return this.fields.some(field => isAggregateField(field.field));
  553. }
  554. hasIdField() {
  555. return this.fields.some(field => field.field === 'id');
  556. }
  557. numOfColumns(): number {
  558. return this.fields.length;
  559. }
  560. getColumns(): TableColumn<React.ReactText>[] {
  561. return decodeColumnOrder(this.fields);
  562. }
  563. getDays(): number {
  564. const statsPeriod = decodeScalar(this.statsPeriod);
  565. return statsPeriodToDays(statsPeriod, this.start, this.end);
  566. }
  567. clone(): EventView {
  568. // NOTE: We rely on usage of Readonly from TypeScript to ensure we do not mutate
  569. // the attributes of EventView directly. This enables us to quickly
  570. // clone new instances of EventView.
  571. return new EventView({
  572. id: this.id,
  573. name: this.name,
  574. fields: this.fields,
  575. sorts: this.sorts,
  576. query: this.query,
  577. team: this.team,
  578. project: this.project,
  579. start: this.start,
  580. end: this.end,
  581. statsPeriod: this.statsPeriod,
  582. environment: this.environment,
  583. yAxis: this.yAxis,
  584. display: this.display,
  585. interval: this.interval,
  586. expired: this.expired,
  587. createdBy: this.createdBy,
  588. additionalConditions: this.additionalConditions,
  589. });
  590. }
  591. withSorts(sorts: Sort[]): EventView {
  592. const newEventView = this.clone();
  593. const fields = newEventView.fields.map(field => getAggregateAlias(field.field));
  594. newEventView.sorts = sorts.filter(sort => fields.includes(sort.field));
  595. return newEventView;
  596. }
  597. withColumns(columns: Column[]): EventView {
  598. const newEventView = this.clone();
  599. const fields: Field[] = columns
  600. .filter(
  601. col =>
  602. ((col.kind === 'field' || col.kind === FieldValueKind.EQUATION) && col.field) ||
  603. (col.kind === 'function' && col.function[0])
  604. )
  605. .map(col => generateFieldAsString(col))
  606. .map((field, i) => {
  607. // newly added field
  608. if (!newEventView.fields[i]) {
  609. return {field, width: COL_WIDTH_UNDEFINED};
  610. }
  611. // Existing columns that were not re ordered should retain
  612. // their old widths.
  613. const existing = newEventView.fields[i];
  614. const width =
  615. existing.field === field && existing.width !== undefined
  616. ? existing.width
  617. : COL_WIDTH_UNDEFINED;
  618. return {field, width};
  619. });
  620. newEventView.fields = fields;
  621. // Update sorts as sorted fields may have been removed.
  622. if (newEventView.sorts) {
  623. // Filter the sort fields down to those that are still selected.
  624. const sortKeys = fields.map(field => fieldToSort(field, undefined)?.field);
  625. const newSort = newEventView.sorts.filter(
  626. sort => sort && sortKeys.includes(sort.field)
  627. );
  628. // If the sort field was removed, try and find a new sortable column.
  629. if (newSort.length === 0) {
  630. const sortField = fields.find(field => isFieldSortable(field, undefined));
  631. if (sortField) {
  632. newSort.push({field: sortField.field, kind: 'desc'});
  633. }
  634. }
  635. newEventView.sorts = newSort;
  636. }
  637. return newEventView;
  638. }
  639. withNewColumn(newColumn: Column): EventView {
  640. const fieldAsString = generateFieldAsString(newColumn);
  641. const newField: Field = {
  642. field: fieldAsString,
  643. width: COL_WIDTH_UNDEFINED,
  644. };
  645. const newEventView = this.clone();
  646. newEventView.fields = [...newEventView.fields, newField];
  647. return newEventView;
  648. }
  649. withResizedColumn(columnIndex: number, newWidth: number) {
  650. const field = this.fields[columnIndex];
  651. const newEventView = this.clone();
  652. if (!field) {
  653. return newEventView;
  654. }
  655. const updateWidth = field.width !== newWidth;
  656. if (updateWidth) {
  657. const fields = [...newEventView.fields];
  658. fields[columnIndex] = {
  659. ...field,
  660. width: newWidth,
  661. };
  662. newEventView.fields = fields;
  663. }
  664. return newEventView;
  665. }
  666. withUpdatedColumn(
  667. columnIndex: number,
  668. updatedColumn: Column,
  669. tableMeta: MetaType | undefined
  670. ): EventView {
  671. const columnToBeUpdated = this.fields[columnIndex];
  672. const fieldAsString = generateFieldAsString(updatedColumn);
  673. const updateField = columnToBeUpdated.field !== fieldAsString;
  674. if (!updateField) {
  675. return this;
  676. }
  677. // ensure tableMeta is non-empty
  678. tableMeta = validateTableMeta(tableMeta);
  679. const newEventView = this.clone();
  680. const updatedField: Field = {
  681. field: fieldAsString,
  682. width: COL_WIDTH_UNDEFINED,
  683. };
  684. const fields = [...newEventView.fields];
  685. fields[columnIndex] = updatedField;
  686. newEventView.fields = fields;
  687. // if the updated column is one of the sorted columns, we may need to remove
  688. // it from the list of sorts
  689. const needleSortIndex = this.sorts.findIndex(sort =>
  690. isSortEqualToField(sort, columnToBeUpdated, tableMeta)
  691. );
  692. if (needleSortIndex >= 0) {
  693. const needleSort = this.sorts[needleSortIndex];
  694. const numOfColumns = this.fields.reduce((sum, currentField) => {
  695. if (isSortEqualToField(needleSort, currentField, tableMeta)) {
  696. return sum + 1;
  697. }
  698. return sum;
  699. }, 0);
  700. // do not bother deleting the sort key if there are more than one columns
  701. // of it in the table.
  702. if (numOfColumns <= 1) {
  703. if (isFieldSortable(updatedField, tableMeta)) {
  704. // use the current updated field as the sort key
  705. const sort = fieldToSort(updatedField, tableMeta)!;
  706. // preserve the sort kind
  707. sort.kind = needleSort.kind;
  708. const sorts = [...newEventView.sorts];
  709. sorts[needleSortIndex] = sort;
  710. newEventView.sorts = sorts;
  711. } else {
  712. const sorts = [...newEventView.sorts];
  713. sorts.splice(needleSortIndex, 1);
  714. newEventView.sorts = [...new Set(sorts)];
  715. }
  716. }
  717. if (newEventView.sorts.length <= 0 && newEventView.fields.length > 0) {
  718. // establish a default sort by finding the first sortable field
  719. if (isFieldSortable(updatedField, tableMeta)) {
  720. // use the current updated field as the sort key
  721. const sort = fieldToSort(updatedField, tableMeta)!;
  722. // preserve the sort kind
  723. sort.kind = needleSort.kind;
  724. newEventView.sorts = [sort];
  725. } else {
  726. const sortableFieldIndex = newEventView.fields.findIndex(currentField =>
  727. isFieldSortable(currentField, tableMeta)
  728. );
  729. if (sortableFieldIndex >= 0) {
  730. const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
  731. const sort = fieldToSort(fieldToBeSorted, tableMeta)!;
  732. newEventView.sorts = [sort];
  733. }
  734. }
  735. }
  736. }
  737. return newEventView;
  738. }
  739. withDeletedColumn(columnIndex: number, tableMeta: MetaType | undefined): EventView {
  740. // Disallow removal of the orphan column, and check for out-of-bounds
  741. if (this.fields.length <= 1 || this.fields.length <= columnIndex || columnIndex < 0) {
  742. return this;
  743. }
  744. // ensure tableMeta is non-empty
  745. tableMeta = validateTableMeta(tableMeta);
  746. // delete the column
  747. const newEventView = this.clone();
  748. const fields = [...newEventView.fields];
  749. fields.splice(columnIndex, 1);
  750. newEventView.fields = fields;
  751. // Ensure there is at least one auto width column
  752. // To ensure a well formed table results.
  753. const hasAutoIndex = fields.find(field => field.width === COL_WIDTH_UNDEFINED);
  754. if (!hasAutoIndex) {
  755. newEventView.fields[0].width = COL_WIDTH_UNDEFINED;
  756. }
  757. // if the deleted column is one of the sorted columns, we need to remove
  758. // it from the list of sorts
  759. const columnToBeDeleted = this.fields[columnIndex];
  760. const needleSortIndex = this.sorts.findIndex(sort =>
  761. isSortEqualToField(sort, columnToBeDeleted, tableMeta)
  762. );
  763. if (needleSortIndex >= 0) {
  764. const needleSort = this.sorts[needleSortIndex];
  765. const numOfColumns = this.fields.reduce((sum, field) => {
  766. if (isSortEqualToField(needleSort, field, tableMeta)) {
  767. return sum + 1;
  768. }
  769. return sum;
  770. }, 0);
  771. // do not bother deleting the sort key if there are more than one columns
  772. // of it in the table.
  773. if (numOfColumns <= 1) {
  774. const sorts = [...newEventView.sorts];
  775. sorts.splice(needleSortIndex, 1);
  776. newEventView.sorts = [...new Set(sorts)];
  777. if (newEventView.sorts.length <= 0 && newEventView.fields.length > 0) {
  778. // establish a default sort by finding the first sortable field
  779. const sortableFieldIndex = newEventView.fields.findIndex(field =>
  780. isFieldSortable(field, tableMeta)
  781. );
  782. if (sortableFieldIndex >= 0) {
  783. const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
  784. const sort = fieldToSort(fieldToBeSorted, tableMeta)!;
  785. newEventView.sorts = [sort];
  786. }
  787. }
  788. }
  789. }
  790. return newEventView;
  791. }
  792. withTeams(teams: ('myteams' | number)[]): EventView {
  793. const newEventView = this.clone();
  794. newEventView.team = teams;
  795. return newEventView;
  796. }
  797. getSorts(): TableColumnSort<React.ReactText>[] {
  798. return this.sorts.map(
  799. sort =>
  800. ({
  801. key: sort.field,
  802. order: sort.kind,
  803. } as TableColumnSort<string>)
  804. );
  805. }
  806. // returns query input for the search
  807. getQuery(inputQuery: string | string[] | null | undefined): string {
  808. const queryParts: string[] = [];
  809. if (this.query) {
  810. if (this.additionalConditions) {
  811. queryParts.push(this.getQueryWithAdditionalConditions());
  812. } else {
  813. queryParts.push(this.query);
  814. }
  815. }
  816. if (inputQuery) {
  817. // there may be duplicate query in the query string
  818. // e.g. query=hello&query=world
  819. if (Array.isArray(inputQuery)) {
  820. inputQuery.forEach(query => {
  821. if (typeof query === 'string' && !queryParts.includes(query)) {
  822. queryParts.push(query);
  823. }
  824. });
  825. }
  826. if (typeof inputQuery === 'string' && !queryParts.includes(inputQuery)) {
  827. queryParts.push(inputQuery);
  828. }
  829. }
  830. return queryParts.join(' ');
  831. }
  832. getFacetsAPIPayload(
  833. location: Location
  834. ): Exclude<EventQuery & LocationQuery, 'sort' | 'cursor'> {
  835. const payload = this.getEventsAPIPayload(location);
  836. const remove = [
  837. 'id',
  838. 'name',
  839. 'per_page',
  840. 'sort',
  841. 'cursor',
  842. 'field',
  843. 'equation',
  844. 'interval',
  845. ];
  846. for (const key of remove) {
  847. delete payload[key];
  848. }
  849. return payload;
  850. }
  851. normalizeDateSelection(location: Location) {
  852. const query = (location && location.query) || {};
  853. // pick only the query strings that we care about
  854. const picked = pickRelevantLocationQueryStrings(location);
  855. const hasDateSelection = this.statsPeriod || (this.start && this.end);
  856. // an eventview's date selection has higher precedence than the date selection in the query string
  857. const dateSelection = hasDateSelection
  858. ? {
  859. start: this.start,
  860. end: this.end,
  861. statsPeriod: this.statsPeriod,
  862. }
  863. : {
  864. start: picked.start,
  865. end: picked.end,
  866. period: decodeScalar(query.period),
  867. statsPeriod: picked.statsPeriod,
  868. };
  869. // normalize datetime selection
  870. return getParams({
  871. ...dateSelection,
  872. utc: decodeScalar(query.utc),
  873. });
  874. }
  875. // Takes an EventView instance and converts it into the format required for the events API
  876. getEventsAPIPayload(location: Location): EventQuery & LocationQuery {
  877. // pick only the query strings that we care about
  878. const picked = pickRelevantLocationQueryStrings(location);
  879. // normalize datetime selection
  880. const normalizedTimeWindowParams = this.normalizeDateSelection(location);
  881. const sort =
  882. this.sorts.length <= 0
  883. ? undefined
  884. : this.sorts.length > 1
  885. ? encodeSorts(this.sorts)
  886. : encodeSort(this.sorts[0]);
  887. const fields = this.getFields().filter(field => !isEquation(field));
  888. const equations = this.getEquations();
  889. const team = this.team.map(proj => String(proj));
  890. const project = this.project.map(proj => String(proj));
  891. const environment = this.environment as string[];
  892. // generate event query
  893. const eventQuery = Object.assign(
  894. omit(picked, DATETIME_QUERY_STRING_KEYS),
  895. normalizedTimeWindowParams,
  896. {
  897. team,
  898. project,
  899. environment,
  900. field: [...new Set(fields)],
  901. equation: [...new Set(equations)],
  902. sort,
  903. per_page: DEFAULT_PER_PAGE,
  904. query: this.getQueryWithAdditionalConditions(),
  905. }
  906. ) as EventQuery & LocationQuery;
  907. if (eventQuery.team && !eventQuery.team.length) {
  908. delete eventQuery.team;
  909. }
  910. if (!eventQuery.sort) {
  911. delete eventQuery.sort;
  912. }
  913. return eventQuery;
  914. }
  915. getResultsViewUrlTarget(slug: string): {pathname: string; query: Query} {
  916. return {
  917. pathname: `/organizations/${slug}/discover/results/`,
  918. query: this.generateQueryStringObject(),
  919. };
  920. }
  921. getResultsViewShortUrlTarget(slug: string): {pathname: string; query: Query} {
  922. const output = {id: this.id};
  923. for (const field of [...Object.values(URL_PARAM), 'cursor']) {
  924. if (this[field] && this[field].length) {
  925. output[field] = this[field];
  926. }
  927. }
  928. return {
  929. pathname: `/organizations/${slug}/discover/results/`,
  930. query: cloneDeep(output as any),
  931. };
  932. }
  933. sortForField(field: Field, tableMeta: MetaType | undefined): Sort | undefined {
  934. if (!tableMeta) {
  935. return undefined;
  936. }
  937. return this.sorts.find(sort => isSortEqualToField(sort, field, tableMeta));
  938. }
  939. sortOnField(field: Field, tableMeta: MetaType, kind?: 'desc' | 'asc'): EventView {
  940. // check if field can be sorted
  941. if (!isFieldSortable(field, tableMeta)) {
  942. return this;
  943. }
  944. const needleIndex = this.sorts.findIndex(sort =>
  945. isSortEqualToField(sort, field, tableMeta)
  946. );
  947. if (needleIndex >= 0) {
  948. const newEventView = this.clone();
  949. const currentSort = this.sorts[needleIndex];
  950. const sorts = [...newEventView.sorts];
  951. sorts[needleIndex] = kind
  952. ? setSortOrder(currentSort, kind)
  953. : reverseSort(currentSort);
  954. newEventView.sorts = sorts;
  955. return newEventView;
  956. }
  957. // field is currently not sorted; so, we sort on it
  958. const newEventView = this.clone();
  959. // invariant: this is not falsey, since sortKey exists
  960. const sort = fieldToSort(field, tableMeta, kind)!;
  961. newEventView.sorts = [sort];
  962. return newEventView;
  963. }
  964. getYAxisOptions(): SelectValue<string>[] {
  965. // Make option set and add the default options in.
  966. return uniqBy(
  967. this.getAggregateFields()
  968. // Only include aggregates that make sense to be graphable (eg. not string or date)
  969. .filter((field: Field) => isLegalYAxisType(aggregateOutputType(field.field)))
  970. .map((field: Field) => ({label: field.field, value: field.field}))
  971. .concat(CHART_AXIS_OPTIONS),
  972. 'value'
  973. );
  974. }
  975. getYAxis(): string {
  976. const yAxisOptions = this.getYAxisOptions();
  977. const yAxis = this.yAxis;
  978. const defaultOption = yAxisOptions[0].value;
  979. if (!yAxis) {
  980. return defaultOption;
  981. }
  982. // ensure current selected yAxis is one of the items in yAxisOptions
  983. const result = yAxisOptions.findIndex(
  984. (option: SelectValue<string>) => option.value === yAxis
  985. );
  986. if (result >= 0) {
  987. return yAxis;
  988. }
  989. return defaultOption;
  990. }
  991. getDisplayOptions(): SelectValue<string>[] {
  992. return DISPLAY_MODE_OPTIONS.map(item => {
  993. if (item.value === DisplayModes.PREVIOUS) {
  994. if (this.start || this.end) {
  995. return {...item, disabled: true};
  996. }
  997. }
  998. if (item.value === DisplayModes.TOP5 || item.value === DisplayModes.DAILYTOP5) {
  999. if (this.getAggregateFields().length === 0) {
  1000. return {
  1001. ...item,
  1002. disabled: true,
  1003. tooltip: t('Add a function that groups events to use this view.'),
  1004. };
  1005. }
  1006. }
  1007. if (item.value === DisplayModes.DAILY || item.value === DisplayModes.DAILYTOP5) {
  1008. if (this.getDays() < 1) {
  1009. return {
  1010. ...item,
  1011. disabled: true,
  1012. tooltip: t('Change the date rage to at least 1 day to use this view.'),
  1013. };
  1014. }
  1015. }
  1016. return item;
  1017. });
  1018. }
  1019. getDisplayMode() {
  1020. const mode = this.display ?? DisplayModes.DEFAULT;
  1021. const displayOptions = this.getDisplayOptions();
  1022. let display = (Object.values(DisplayModes) as string[]).includes(mode)
  1023. ? mode
  1024. : DisplayModes.DEFAULT;
  1025. const cond = option => option.value === display;
  1026. // Just in case we define a fallback chain that results in an infinite loop.
  1027. // The number 5 isn't anything special, its just larger than the longest fallback
  1028. // chain that exists and isn't too big.
  1029. for (let i = 0; i < 5; i++) {
  1030. const selectedOption = displayOptions.find(cond);
  1031. if (selectedOption && !selectedOption.disabled) {
  1032. return display;
  1033. }
  1034. display = DISPLAY_MODE_FALLBACK_OPTIONS[display];
  1035. }
  1036. // after trying to find an enabled display mode and failing to find one,
  1037. // we just use the default display mode
  1038. return DisplayModes.DEFAULT;
  1039. }
  1040. getQueryWithAdditionalConditions() {
  1041. const {query} = this;
  1042. if (this.additionalConditions.isEmpty()) {
  1043. return query;
  1044. }
  1045. const conditions = tokenizeSearch(query);
  1046. Object.entries(this.additionalConditions.tagValues).forEach(([tag, tagValues]) => {
  1047. conditions.addTagValues(tag, tagValues);
  1048. });
  1049. return stringifyQueryObject(conditions);
  1050. }
  1051. }
  1052. export const isAPIPayloadSimilar = (
  1053. current: EventQuery & LocationQuery,
  1054. other: EventQuery & LocationQuery
  1055. ): boolean => {
  1056. const currentKeys = new Set(Object.keys(current));
  1057. const otherKeys = new Set(Object.keys(other));
  1058. if (!isEqual(currentKeys, otherKeys)) {
  1059. return false;
  1060. }
  1061. for (const key of currentKeys) {
  1062. const currentValue = current[key];
  1063. const currentTarget = Array.isArray(currentValue)
  1064. ? new Set(currentValue)
  1065. : currentValue;
  1066. const otherValue = other[key];
  1067. const otherTarget = Array.isArray(otherValue) ? new Set(otherValue) : otherValue;
  1068. if (!isEqual(currentTarget, otherTarget)) {
  1069. return false;
  1070. }
  1071. }
  1072. return true;
  1073. };
  1074. export function pickRelevantLocationQueryStrings(location: Location) {
  1075. const query = location.query || {};
  1076. const picked = pick(query || {}, EXTERNAL_QUERY_STRING_KEYS);
  1077. return picked;
  1078. }
  1079. export default EventView;