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