1import { cloneDeep, extend, isString } from 'lodash';
2import {
3  dateMath,
4  dateTime,
5  getDefaultTimeRange,
6  isDateTime,
7  rangeUtil,
8  RawTimeRange,
9  TimeRange,
10  toUtc,
11} from '@grafana/data';
12import { DashboardModel } from '../state/DashboardModel';
13import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
14import { config } from 'app/core/config';
15import { getRefreshFromUrl } from '../utils/getRefreshFromUrl';
16import { locationService } from '@grafana/runtime';
17import { ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../types/events';
18import { contextSrv, ContextSrv } from 'app/core/services/context_srv';
19import appEvents from 'app/core/app_events';
20
21export class TimeSrv {
22  time: any;
23  refreshTimer: any;
24  refresh: any;
25  previousAutoRefresh: any;
26  oldRefresh: string | null | undefined;
27  dashboard?: DashboardModel;
28  timeAtLoad: any;
29  private autoRefreshBlocked?: boolean;
30
31  constructor(private contextSrv: ContextSrv) {
32    // default time
33    this.time = getDefaultTimeRange().raw;
34    this.refreshDashboard = this.refreshDashboard.bind(this);
35
36    appEvents.subscribe(ZoomOutEvent, (e) => {
37      this.zoomOut(e.payload);
38    });
39
40    appEvents.subscribe(ShiftTimeEvent, (e) => {
41      this.shiftTime(e.payload);
42    });
43
44    document.addEventListener('visibilitychange', () => {
45      if (this.autoRefreshBlocked && document.visibilityState === 'visible') {
46        this.autoRefreshBlocked = false;
47        this.refreshDashboard();
48      }
49    });
50  }
51
52  init(dashboard: DashboardModel) {
53    this.dashboard = dashboard;
54    this.time = dashboard.time;
55    this.refresh = dashboard.refresh;
56
57    this.initTimeFromUrl();
58    this.parseTime();
59
60    // remember time at load so we can go back to it
61    this.timeAtLoad = cloneDeep(this.time);
62
63    const range = rangeUtil.convertRawToRange(
64      this.time,
65      this.dashboard?.getTimezone(),
66      this.dashboard?.fiscalYearStartMonth
67    );
68
69    if (range.to.isBefore(range.from)) {
70      this.setTime(
71        {
72          from: range.raw.to,
73          to: range.raw.from,
74        },
75        false
76      );
77    }
78
79    if (this.refresh) {
80      this.setAutoRefresh(this.refresh);
81    }
82  }
83
84  getValidIntervals(intervals: string[]): string[] {
85    if (!this.contextSrv.minRefreshInterval) {
86      return intervals;
87    }
88
89    return intervals.filter((str) => str !== '').filter(this.contextSrv.isAllowedInterval);
90  }
91
92  private parseTime() {
93    // when absolute time is saved in json it is turned to a string
94    if (isString(this.time.from) && this.time.from.indexOf('Z') >= 0) {
95      this.time.from = dateTime(this.time.from).utc();
96    }
97    if (isString(this.time.to) && this.time.to.indexOf('Z') >= 0) {
98      this.time.to = dateTime(this.time.to).utc();
99    }
100  }
101
102  private parseUrlParam(value: any) {
103    if (value.indexOf('now') !== -1) {
104      return value;
105    }
106    if (value.length === 8) {
107      const utcValue = toUtc(value, 'YYYYMMDD');
108      if (utcValue.isValid()) {
109        return utcValue;
110      }
111    } else if (value.length === 15) {
112      const utcValue = toUtc(value, 'YYYYMMDDTHHmmss');
113      if (utcValue.isValid()) {
114        return utcValue;
115      }
116    }
117
118    if (!isNaN(value)) {
119      const epoch = parseInt(value, 10);
120      return toUtc(epoch);
121    }
122
123    return null;
124  }
125
126  private getTimeWindow(time: string, timeWindow: string) {
127    const valueTime = parseInt(time, 10);
128    let timeWindowMs;
129
130    if (timeWindow.match(/^\d+$/) && parseInt(timeWindow, 10)) {
131      // when time window specified in ms
132      timeWindowMs = parseInt(timeWindow, 10);
133    } else {
134      timeWindowMs = rangeUtil.intervalToMs(timeWindow);
135    }
136
137    return {
138      from: toUtc(valueTime - timeWindowMs / 2),
139      to: toUtc(valueTime + timeWindowMs / 2),
140    };
141  }
142
143  private initTimeFromUrl() {
144    const params = locationService.getSearch();
145
146    if (params.get('time') && params.get('time.window')) {
147      this.time = this.getTimeWindow(params.get('time')!, params.get('time.window')!);
148    }
149
150    if (params.get('from')) {
151      this.time.from = this.parseUrlParam(params.get('from')!) || this.time.from;
152    }
153
154    if (params.get('to')) {
155      this.time.to = this.parseUrlParam(params.get('to')!) || this.time.to;
156    }
157
158    // if absolute ignore refresh option saved to dashboard
159    if (params.get('to') && params.get('to')!.indexOf('now') === -1) {
160      this.refresh = false;
161      if (this.dashboard) {
162        this.dashboard.refresh = false;
163      }
164    }
165
166    let paramsJSON: Record<string, string> = {};
167    params.forEach(function (value, key) {
168      paramsJSON[key] = value;
169    });
170
171    // but if refresh explicitly set then use that
172    this.refresh = getRefreshFromUrl({
173      params: paramsJSON,
174      currentRefresh: this.refresh,
175      refreshIntervals: Array.isArray(this.dashboard?.timepicker?.refresh_intervals)
176        ? this.dashboard?.timepicker?.refresh_intervals
177        : undefined,
178      isAllowedIntervalFn: this.contextSrv.isAllowedInterval,
179      minRefreshInterval: config.minRefreshInterval,
180    });
181  }
182
183  updateTimeRangeFromUrl() {
184    const params = locationService.getSearch();
185
186    if (params.get('left')) {
187      return; // explore handles this;
188    }
189
190    const urlRange = this.timeRangeForUrl();
191    const from = params.get('from');
192    const to = params.get('to');
193
194    // check if url has time range
195    if (from && to) {
196      // is it different from what our current time range?
197      if (from !== urlRange.from || to !== urlRange.to) {
198        // issue update
199        this.initTimeFromUrl();
200        this.setTime(this.time, true);
201      }
202    } else if (this.timeHasChangedSinceLoad()) {
203      this.setTime(this.timeAtLoad, true);
204    }
205  }
206
207  private timeHasChangedSinceLoad() {
208    return this.timeAtLoad && (this.timeAtLoad.from !== this.time.from || this.timeAtLoad.to !== this.time.to);
209  }
210
211  setAutoRefresh(interval: any) {
212    if (this.dashboard) {
213      this.dashboard.refresh = interval;
214    }
215
216    this.stopAutoRefresh();
217
218    const currentUrlState = locationService.getSearchObject();
219
220    if (!interval) {
221      // Clear URL state
222      if (currentUrlState.refresh) {
223        locationService.partial({ refresh: null }, true);
224      }
225
226      return;
227    }
228
229    const validInterval = this.contextSrv.getValidInterval(interval);
230    const intervalMs = rangeUtil.intervalToMs(validInterval);
231
232    this.refreshTimer = setTimeout(() => {
233      this.startNextRefreshTimer(intervalMs);
234      this.refreshDashboard();
235    }, intervalMs);
236
237    const refresh = this.contextSrv.getValidInterval(interval);
238
239    if (currentUrlState.refresh !== refresh) {
240      locationService.partial({ refresh }, true);
241    }
242  }
243
244  refreshDashboard() {
245    this.dashboard?.timeRangeUpdated(this.timeRange());
246  }
247
248  private startNextRefreshTimer(afterMs: number) {
249    this.refreshTimer = setTimeout(() => {
250      this.startNextRefreshTimer(afterMs);
251      if (this.contextSrv.isGrafanaVisible()) {
252        this.refreshDashboard();
253      } else {
254        this.autoRefreshBlocked = true;
255      }
256    }, afterMs);
257  }
258
259  stopAutoRefresh() {
260    clearTimeout(this.refreshTimer);
261  }
262
263  // store dashboard refresh value and pause auto-refresh in some places
264  // i.e panel edit
265  pauseAutoRefresh() {
266    this.previousAutoRefresh = this.dashboard?.refresh;
267    this.setAutoRefresh('');
268  }
269
270  // resume auto-refresh based on old dashboard refresh property
271  resumeAutoRefresh() {
272    this.setAutoRefresh(this.previousAutoRefresh);
273  }
274
275  setTime(time: RawTimeRange, fromRouteUpdate?: boolean) {
276    extend(this.time, time);
277
278    // disable refresh if zoom in or zoom out
279    if (isDateTime(time.to)) {
280      this.oldRefresh = this.dashboard?.refresh || this.oldRefresh;
281      this.setAutoRefresh(false);
282    } else if (this.oldRefresh && this.oldRefresh !== this.dashboard?.refresh) {
283      this.setAutoRefresh(this.oldRefresh);
284      this.oldRefresh = null;
285    }
286
287    // update url
288    if (fromRouteUpdate !== true) {
289      const urlRange = this.timeRangeForUrl();
290      const urlParams = locationService.getSearchObject();
291
292      if (urlParams.from === urlRange.from.toString() && urlParams.to === urlRange.to.toString()) {
293        return;
294      }
295
296      urlParams.from = urlRange.from.toString();
297      urlParams.to = urlRange.to.toString();
298
299      locationService.partial(urlParams);
300    }
301
302    this.refreshDashboard();
303  }
304
305  timeRangeForUrl = () => {
306    const range = this.timeRange().raw;
307
308    if (isDateTime(range.from)) {
309      range.from = range.from.valueOf().toString();
310    }
311    if (isDateTime(range.to)) {
312      range.to = range.to.valueOf().toString();
313    }
314
315    return range;
316  };
317
318  timeRange(): TimeRange {
319    // make copies if they are moment  (do not want to return out internal moment, because they are mutable!)
320    const raw = {
321      from: isDateTime(this.time.from) ? dateTime(this.time.from) : this.time.from,
322      to: isDateTime(this.time.to) ? dateTime(this.time.to) : this.time.to,
323    };
324
325    const timezone = this.dashboard ? this.dashboard.getTimezone() : undefined;
326
327    return {
328      from: dateMath.parse(raw.from, false, timezone, this.dashboard?.fiscalYearStartMonth)!,
329      to: dateMath.parse(raw.to, true, timezone, this.dashboard?.fiscalYearStartMonth)!,
330      raw: raw,
331    };
332  }
333
334  zoomOut(factor: number) {
335    const range = this.timeRange();
336    const { from, to } = getZoomedTimeRange(range, factor);
337
338    this.setTime({ from: toUtc(from), to: toUtc(to) });
339  }
340
341  shiftTime(direction: ShiftTimeEventPayload) {
342    const range = this.timeRange();
343    const { from, to } = getShiftedTimeRange(direction, range);
344
345    this.setTime({
346      from: toUtc(from),
347      to: toUtc(to),
348    });
349  }
350
351  // isRefreshOutsideThreshold function calculates the difference between last refresh and now
352  // if the difference is outside 5% of the current set time range then the function will return true
353  // if the difference is within 5% of the current set time range then the function will return false
354  // if the current time range is absolute (i.e. not using relative strings like now-5m) then the function will return false
355  isRefreshOutsideThreshold(lastRefresh: number, threshold = 0.05) {
356    const timeRange = this.timeRange();
357
358    if (dateMath.isMathString(timeRange.raw.from)) {
359      const totalRange = timeRange.to.diff(timeRange.from);
360      const msSinceLastRefresh = Date.now() - lastRefresh;
361      const msThreshold = totalRange * threshold;
362      return msSinceLastRefresh >= msThreshold;
363    }
364
365    return false;
366  }
367}
368
369let singleton: TimeSrv | undefined;
370
371export function setTimeSrv(srv: TimeSrv) {
372  singleton = srv;
373}
374
375export function getTimeSrv(): TimeSrv {
376  if (!singleton) {
377    singleton = new TimeSrv(contextSrv);
378  }
379
380  return singleton;
381}
382