1// Libraries
2import { flatten, omit, uniq } from 'lodash';
3import { Unsubscribable } from 'rxjs';
4// Services & Utils
5import {
6  CoreApp,
7  DataQuery,
8  DataQueryRequest,
9  DataSourceApi,
10  dateMath,
11  DefaultTimeZone,
12  HistoryItem,
13  IntervalValues,
14  LogsDedupStrategy,
15  LogsSortOrder,
16  RawTimeRange,
17  TimeFragment,
18  TimeRange,
19  TimeZone,
20  toUtc,
21  urlUtil,
22  ExploreUrlState,
23  rangeUtil,
24  DateTime,
25  isDateTime,
26} from '@grafana/data';
27import store from 'app/core/store';
28import { v4 as uuidv4 } from 'uuid';
29import { getNextRefIdChar } from './query';
30// Types
31import { RefreshPicker } from '@grafana/ui';
32import { ExploreId, QueryOptions, QueryTransaction } from 'app/types/explore';
33import { config } from '../config';
34import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
35import { DataSourceSrv } from '@grafana/runtime';
36import { PanelModel } from 'app/features/dashboard/state';
37
38export const DEFAULT_RANGE = {
39  from: 'now-1h',
40  to: 'now',
41};
42
43export const DEFAULT_UI_STATE = {
44  dedupStrategy: LogsDedupStrategy.none,
45};
46
47const MAX_HISTORY_ITEMS = 100;
48
49export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
50export const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DATASOURCE_KEY}.${orgId}`;
51
52export interface GetExploreUrlArguments {
53  panel: PanelModel;
54  /** Datasource service to query other datasources in case the panel datasource is mixed */
55  datasourceSrv: DataSourceSrv;
56  /** Time service to get the current dashboard range from */
57  timeSrv: TimeSrv;
58}
59
60/**
61 * Returns an Explore-URL that contains a panel's queries and the dashboard time range.
62 */
63export async function getExploreUrl(args: GetExploreUrlArguments): Promise<string | undefined> {
64  const { panel, datasourceSrv, timeSrv } = args;
65  let exploreDatasource = await datasourceSrv.get(panel.datasource);
66
67  /** In Explore, we don't have legend formatter and we don't want to keep
68   * legend formatting as we can't change it
69   */
70  let exploreTargets: DataQuery[] = panel.targets.map((t) => omit(t, 'legendFormat'));
71  let url: string | undefined;
72
73  // Mixed datasources need to choose only one datasource
74  if (exploreDatasource.meta?.id === 'mixed' && exploreTargets) {
75    // Find first explore datasource among targets
76    for (const t of exploreTargets) {
77      const datasource = await datasourceSrv.get(t.datasource || undefined);
78      if (datasource) {
79        exploreDatasource = datasource;
80        exploreTargets = panel.targets.filter((t) => t.datasource === datasource.name);
81        break;
82      }
83    }
84  }
85
86  if (exploreDatasource) {
87    const range = timeSrv.timeRangeForUrl();
88    let state: Partial<ExploreUrlState> = { range };
89    if (exploreDatasource.interpolateVariablesInQueries) {
90      const scopedVars = panel.scopedVars || {};
91      state = {
92        ...state,
93        datasource: exploreDatasource.name,
94        context: 'explore',
95        queries: exploreDatasource.interpolateVariablesInQueries(exploreTargets, scopedVars),
96      };
97    } else {
98      state = {
99        ...state,
100        datasource: exploreDatasource.name,
101        context: 'explore',
102        queries: exploreTargets.map((t) => ({ ...t, datasource: exploreDatasource.getRef() })),
103      };
104    }
105
106    const exploreState = JSON.stringify({ ...state, originPanelId: panel.id });
107    url = urlUtil.renderUrl('/explore', { left: exploreState });
108  }
109
110  return url;
111}
112
113export function buildQueryTransaction(
114  exploreId: ExploreId,
115  queries: DataQuery[],
116  queryOptions: QueryOptions,
117  range: TimeRange,
118  scanning: boolean,
119  timeZone?: TimeZone
120): QueryTransaction {
121  const key = queries.reduce((combinedKey, query) => {
122    combinedKey += query.key;
123    return combinedKey;
124  }, '');
125
126  const { interval, intervalMs } = getIntervals(range, queryOptions.minInterval, queryOptions.maxDataPoints);
127
128  // Most datasource is using `panelId + query.refId` for cancellation logic.
129  // Using `format` here because it relates to the view panel that the request is for.
130  // However, some datasources don't use `panelId + query.refId`, but only `panelId`.
131  // Therefore panel id has to be unique.
132  const panelId = `${key}`;
133
134  const request: DataQueryRequest = {
135    app: CoreApp.Explore,
136    dashboardId: 0,
137    // TODO probably should be taken from preferences but does not seem to be used anyway.
138    timezone: timeZone || DefaultTimeZone,
139    startTime: Date.now(),
140    interval,
141    intervalMs,
142    // TODO: the query request expects number and we are using string here. Seems like it works so far but can create
143    // issues down the road.
144    panelId: panelId as any,
145    targets: queries, // Datasources rely on DataQueries being passed under the targets key.
146    range,
147    requestId: 'explore_' + exploreId,
148    rangeRaw: range.raw,
149    scopedVars: {
150      __interval: { text: interval, value: interval },
151      __interval_ms: { text: intervalMs, value: intervalMs },
152    },
153    maxDataPoints: queryOptions.maxDataPoints,
154    liveStreaming: queryOptions.liveStreaming,
155  };
156
157  return {
158    queries,
159    request,
160    scanning,
161    id: generateKey(), // reusing for unique ID
162    done: false,
163  };
164}
165
166export const clearQueryKeys: (query: DataQuery) => DataQuery = ({ key, ...rest }) => rest;
167
168const isSegment = (segment: { [key: string]: string }, ...props: string[]) =>
169  props.some((prop) => segment.hasOwnProperty(prop));
170
171enum ParseUrlStateIndex {
172  RangeFrom = 0,
173  RangeTo = 1,
174  Datasource = 2,
175  SegmentsStart = 3,
176}
177
178export const safeParseJson = (text?: string): any | undefined => {
179  if (!text) {
180    return;
181  }
182
183  try {
184    return JSON.parse(text);
185  } catch (error) {
186    console.error(error);
187  }
188};
189
190export const safeStringifyValue = (value: any, space?: number) => {
191  if (!value) {
192    return '';
193  }
194
195  try {
196    return JSON.stringify(value, null, space);
197  } catch (error) {
198    console.error(error);
199  }
200
201  return '';
202};
203
204export const EXPLORE_GRAPH_STYLES = ['lines', 'bars', 'points', 'stacked_lines', 'stacked_bars'] as const;
205
206export type ExploreGraphStyle = typeof EXPLORE_GRAPH_STYLES[number];
207
208const DEFAULT_GRAPH_STYLE: ExploreGraphStyle = 'lines';
209// we use this function to take any kind of data we loaded
210// from an external source (URL, localStorage, whatever),
211// and extract the graph-style from it, or return the default
212// graph-style if we are not able to do that.
213// it is important that this function is able to take any form of data,
214// (be it objects, or arrays, or booleans or whatever),
215// and produce a best-effort graphStyle.
216// note that typescript makes sure we make no mistake in this function.
217// we do not rely on ` as ` or ` any `.
218export const toGraphStyle = (data: unknown): ExploreGraphStyle => {
219  const found = EXPLORE_GRAPH_STYLES.find((v) => v === data);
220  return found ?? DEFAULT_GRAPH_STYLE;
221};
222
223export function parseUrlState(initial: string | undefined): ExploreUrlState {
224  const parsed = safeParseJson(initial);
225  const errorResult: any = {
226    datasource: null,
227    queries: [],
228    range: DEFAULT_RANGE,
229    mode: null,
230    originPanelId: null,
231  };
232
233  if (!parsed) {
234    return errorResult;
235  }
236
237  if (!Array.isArray(parsed)) {
238    return parsed;
239  }
240
241  if (parsed.length <= ParseUrlStateIndex.SegmentsStart) {
242    console.error('Error parsing compact URL state for Explore.');
243    return errorResult;
244  }
245
246  const range = {
247    from: parsed[ParseUrlStateIndex.RangeFrom],
248    to: parsed[ParseUrlStateIndex.RangeTo],
249  };
250  const datasource = parsed[ParseUrlStateIndex.Datasource];
251  const parsedSegments = parsed.slice(ParseUrlStateIndex.SegmentsStart);
252  const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'originPanelId', 'mode'));
253
254  const originPanelId = parsedSegments.filter((segment) => isSegment(segment, 'originPanelId'))[0];
255  return { datasource, queries, range, originPanelId };
256}
257
258export function generateKey(index = 0): string {
259  return `Q-${uuidv4()}-${index}`;
260}
261
262export function generateEmptyQuery(queries: DataQuery[], index = 0): DataQuery {
263  return { refId: getNextRefIdChar(queries), key: generateKey(index) };
264}
265
266export const generateNewKeyAndAddRefIdIfMissing = (target: DataQuery, queries: DataQuery[], index = 0): DataQuery => {
267  const key = generateKey(index);
268  const refId = target.refId || getNextRefIdChar(queries);
269
270  return { ...target, refId, key };
271};
272
273/**
274 * Ensure at least one target exists and that targets have the necessary keys
275 */
276export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
277  if (queries && typeof queries === 'object' && queries.length > 0) {
278    const allQueries = [];
279    for (let index = 0; index < queries.length; index++) {
280      const query = queries[index];
281      const key = generateKey(index);
282      let refId = query.refId;
283      if (!refId) {
284        refId = getNextRefIdChar(allQueries);
285      }
286
287      allQueries.push({
288        ...query,
289        refId,
290        key,
291      });
292    }
293    return allQueries;
294  }
295  return [{ ...generateEmptyQuery(queries ?? []) }];
296}
297
298/**
299 * A target is non-empty when it has keys (with non-empty values) other than refId, key, context and datasource.
300 * FIXME: While this is reasonable for practical use cases, a query without any propery might still be "non-empty"
301 * in its own scope, for instance when there's no user input needed. This might be the case for an hypothetic datasource in
302 * which query options are only set in its config and the query object itself, as generated from its query editor it's always "empty"
303 */
304const validKeys = ['refId', 'key', 'context', 'datasource'];
305export function hasNonEmptyQuery<TQuery extends DataQuery>(queries: TQuery[]): boolean {
306  return (
307    queries &&
308    queries.some((query: any) => {
309      const keys = Object.keys(query)
310        .filter((key) => validKeys.indexOf(key) === -1)
311        .map((k) => query[k])
312        .filter((v) => v);
313      return keys.length > 0;
314    })
315  );
316}
317
318/**
319 * Update the query history. Side-effect: store history in local storage
320 */
321export function updateHistory<T extends DataQuery>(
322  history: Array<HistoryItem<T>>,
323  datasourceId: string,
324  queries: T[]
325): Array<HistoryItem<T>> {
326  const ts = Date.now();
327  let updatedHistory = history;
328  queries.forEach((query) => {
329    updatedHistory = [{ query, ts }, ...updatedHistory];
330  });
331
332  if (updatedHistory.length > MAX_HISTORY_ITEMS) {
333    updatedHistory = updatedHistory.slice(0, MAX_HISTORY_ITEMS);
334  }
335
336  // Combine all queries of a datasource type into one history
337  const historyKey = `grafana.explore.history.${datasourceId}`;
338  try {
339    store.setObject(historyKey, updatedHistory);
340    return updatedHistory;
341  } catch (error) {
342    console.error(error);
343    return history;
344  }
345}
346
347export function clearHistory(datasourceId: string) {
348  const historyKey = `grafana.explore.history.${datasourceId}`;
349  store.delete(historyKey);
350}
351
352export const getQueryKeys = (queries: DataQuery[], datasourceInstance?: DataSourceApi | null): string[] => {
353  const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => {
354    const primaryKey = datasourceInstance && datasourceInstance.name ? datasourceInstance.name : query.key;
355    return newQueryKeys.concat(`${primaryKey}-${index}`);
356  }, []);
357
358  return queryKeys;
359};
360
361export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange, fiscalYearStartMonth: number): TimeRange => {
362  return {
363    from: dateMath.parse(rawRange.from, false, timeZone as any, fiscalYearStartMonth)!,
364    to: dateMath.parse(rawRange.to, true, timeZone as any, fiscalYearStartMonth)!,
365    raw: rawRange,
366  };
367};
368
369const parseRawTime = (value: string | DateTime): TimeFragment | null => {
370  if (value === null) {
371    return null;
372  }
373
374  if (isDateTime(value)) {
375    return value;
376  }
377
378  if (value.indexOf('now') !== -1) {
379    return value;
380  }
381  if (value.length === 8) {
382    return toUtc(value, 'YYYYMMDD');
383  }
384  if (value.length === 15) {
385    return toUtc(value, 'YYYYMMDDTHHmmss');
386  }
387  // Backward compatibility
388  if (value.length === 19) {
389    return toUtc(value, 'YYYY-MM-DD HH:mm:ss');
390  }
391
392  // This should handle cases where value is an epoch time as string
393  if (value.match(/^\d+$/)) {
394    const epoch = parseInt(value, 10);
395    return toUtc(epoch);
396  }
397
398  // This should handle ISO strings
399  const time = toUtc(value);
400  if (time.isValid()) {
401    return time;
402  }
403
404  return null;
405};
406
407export const getTimeRangeFromUrl = (
408  range: RawTimeRange,
409  timeZone: TimeZone,
410  fiscalYearStartMonth: number
411): TimeRange => {
412  const raw = {
413    from: parseRawTime(range.from)!,
414    to: parseRawTime(range.to)!,
415  };
416
417  return {
418    from: dateMath.parse(raw.from, false, timeZone as any)!,
419    to: dateMath.parse(raw.to, true, timeZone as any)!,
420    raw,
421  };
422};
423
424export const getValueWithRefId = (value?: any): any => {
425  if (!value || typeof value !== 'object') {
426    return undefined;
427  }
428
429  if (value.refId) {
430    return value;
431  }
432
433  const keys = Object.keys(value);
434  for (let index = 0; index < keys.length; index++) {
435    const key = keys[index];
436    const refId = getValueWithRefId(value[key]);
437    if (refId) {
438      return refId;
439    }
440  }
441
442  return undefined;
443};
444
445export const getRefIds = (value: any): string[] => {
446  if (!value) {
447    return [];
448  }
449
450  if (typeof value !== 'object') {
451    return [];
452  }
453
454  const keys = Object.keys(value);
455  const refIds = [];
456  for (let index = 0; index < keys.length; index++) {
457    const key = keys[index];
458    if (key === 'refId') {
459      refIds.push(value[key]);
460      continue;
461    }
462    refIds.push(getRefIds(value[key]));
463  }
464
465  return uniq(flatten(refIds));
466};
467
468export const refreshIntervalToSortOrder = (refreshInterval?: string) =>
469  RefreshPicker.isLive(refreshInterval) ? LogsSortOrder.Ascending : LogsSortOrder.Descending;
470
471export const convertToWebSocketUrl = (url: string) => {
472  const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
473  let backend = `${protocol}${window.location.host}${config.appSubUrl}`;
474  if (backend.endsWith('/')) {
475    backend = backend.slice(0, -1);
476  }
477  return `${backend}${url}`;
478};
479
480export const stopQueryState = (querySubscription: Unsubscribable | undefined) => {
481  if (querySubscription) {
482    querySubscription.unsubscribe();
483  }
484};
485
486export function getIntervals(range: TimeRange, lowLimit?: string, resolution?: number): IntervalValues {
487  if (!resolution) {
488    return { interval: '1s', intervalMs: 1000 };
489  }
490
491  return rangeUtil.calculateInterval(range, resolution, lowLimit);
492}
493
494export const copyStringToClipboard = (string: string) => {
495  const el = document.createElement('textarea');
496  el.value = string;
497  document.body.appendChild(el);
498  el.select();
499  document.execCommand('copy');
500  document.body.removeChild(el);
501};
502