diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index c97bb0f1805..5ae2fa1f638 100755 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -16,6 +16,12 @@ function sameConfig(prevProps: PlotProps, nextProps: PlotProps) { return nextProps.config === prevProps.config; } +function sameTimeRange(prevProps: PlotProps, nextProps: PlotProps) { + let prevTime = prevProps.timeRange; + let nextTime = nextProps.timeRange; + return nextTime.from.valueOf() === prevTime.from.valueOf() && nextTime.to.valueOf() === prevTime.to.valueOf(); +} + type UPlotChartState = { ctx: PlotContextType; }; @@ -109,6 +115,11 @@ export class UPlotChart extends React.Component { this.reinitPlot(); } else if (!sameData(prevProps, this.props)) { ctx.plot?.setData(this.props.data); + } else if (!sameTimeRange(prevProps, this.props)) { + ctx.plot?.setScale('x', { + min: this.props.timeRange.from.valueOf(), + max: this.props.timeRange.to.valueOf(), + }); } } diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index 2e17b425d6f..d2a604c094d 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -53,6 +53,11 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone }: Props) setRenderCounter(renderCounter + 1); }; + const onLiveNowChange = (v: boolean) => { + dashboard.liveNow = v; + setRenderCounter(renderCounter + 1); + }; + const onTimeZoneChange = (timeZone: TimeZone) => { dashboard.timezone = timeZone; setRenderCounter(renderCounter + 1); @@ -113,10 +118,12 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone }: Props) onRefreshIntervalChange={onRefreshIntervalChange} onNowDelayChange={onNowDelayChange} onHideTimePickerChange={onHideTimePickerChange} + onLiveNowChange={onLiveNowChange} refreshIntervals={dashboard.timepicker.refresh_intervals} timePickerHidden={dashboard.timepicker.hidden} nowDelay={dashboard.timepicker.nowDelay} timezone={dashboard.timezone} + liveNow={dashboard.liveNow} /> diff --git a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx index 4862114619f..65a0b5a8fa1 100644 --- a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx @@ -10,10 +10,12 @@ interface Props { onRefreshIntervalChange: (interval: string[]) => void; onNowDelayChange: (nowDelay: string) => void; onHideTimePickerChange: (hide: boolean) => void; + onLiveNowChange: (liveNow: boolean) => void; refreshIntervals: string[]; timePickerHidden: boolean; nowDelay: string; timezone: TimeZone; + liveNow: boolean; } interface State { @@ -43,6 +45,10 @@ export class TimePickerSettings extends PureComponent { this.props.onHideTimePickerChange(!this.props.timePickerHidden); }; + onLiveNowChange = () => { + this.props.onLiveNowChange(!this.props.liveNow); + }; + onTimeZoneChange = (timeZone?: string) => { if (typeof timeZone !== 'string') { return; @@ -79,6 +85,12 @@ export class TimePickerSettings extends PureComponent { + + + ); } diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 40d0389617e..eb997f7c3cc 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -25,11 +25,12 @@ import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getTimeSrv } from '../services/TimeSrv'; import { getKioskMode } from 'app/core/navigation/kiosk'; -import { GrafanaTheme2, UrlQueryValue } from '@grafana/data'; +import { GrafanaTheme2, TimeRange, UrlQueryValue } from '@grafana/data'; import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading'; import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed'; import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt'; import classnames from 'classnames'; +import { liveTimer } from '../dashgrid/liveTimer'; export interface DashboardPageRouteParams { uid?: string; @@ -132,6 +133,9 @@ export class UnthemedDashboardPage extends PureComponent { routeName: this.props.route.routeName, fixUrl: true, }); + + // small delay to start live updates + setTimeout(this.updateLiveTimer, 250); } componentDidUpdate(prevProps: Props, prevState: State) { @@ -162,6 +166,7 @@ export class UnthemedDashboardPage extends PureComponent { if (urlParams?.from !== prevUrlParams?.from || urlParams?.to !== prevUrlParams?.to) { getTimeSrv().updateTimeRangeFromUrl(); + this.updateLiveTimer(); } if (!prevUrlParams?.refresh && urlParams?.refresh) { @@ -196,6 +201,14 @@ export class UnthemedDashboardPage extends PureComponent { } } + updateLiveTimer = () => { + let tr: TimeRange | undefined = undefined; + if (this.props.dashboard?.liveNow) { + tr = getTimeSrv().timeRange(); + } + liveTimer.setLiveTimeRange(tr); + }; + static getDerivedStateFromProps(props: Props, state: State) { const { dashboard, queryParams } = props; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 8db583dba45..8af04bd0454 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -14,6 +14,7 @@ import { PanelData, PanelPlugin, PanelPluginMeta, + TimeRange, toDataFrameDTO, toUtc, } from '@grafana/data'; @@ -33,6 +34,7 @@ import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/ove import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../annotations/api'; import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; +import { liveTimer } from './liveTimer'; import { isSoloRoute } from '../../../routes/utils'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; @@ -55,6 +57,7 @@ export interface State { refreshWhenInView: boolean; context: PanelContext; data: PanelData; + liveTime?: TimeRange; } export class PanelChrome extends Component { @@ -134,14 +137,31 @@ export class PanelChrome extends Component { next: (data) => this.onDataUpdate(data), }) ); + + // Listen for live timer events + liveTimer.listen(this); } componentWillUnmount() { this.subs.unsubscribe(); + liveTimer.remove(this); + } + + liveTimeChanged(liveTime: TimeRange) { + const { data } = this.state; + if (data.timeRange) { + const delta = liveTime.to.valueOf() - data.timeRange.to.valueOf(); + if (delta < 100) { + // 10hz + console.log('Skip tick render', this.props.panel.title, delta); + return; + } + } + this.setState({ liveTime }); } componentDidUpdate(prevProps: Props) { - const { isInView, isEditing } = this.props; + const { isInView, isEditing, width } = this.props; if (prevProps.dashboard.graphTooltip !== this.props.dashboard.graphTooltip) { this.setState((s) => { @@ -168,6 +188,11 @@ export class PanelChrome extends Component { } } } + + // The timer depends on panel width + if (width !== prevProps.width) { + liveTimer.updateInterval(this); + } } shouldComponentUpdate(prevProps: Props, prevState: State) { @@ -225,7 +250,7 @@ export class PanelChrome extends Component { break; } - this.setState({ isFirstLoad, errorMessage, data }); + this.setState({ isFirstLoad, errorMessage, data, liveTime: undefined }); } onRefresh = () => { @@ -253,6 +278,7 @@ export class PanelChrome extends Component { this.setState({ data: { ...this.state.data, timeRange: this.timeSrv.timeRange() }, renderCounter: this.state.renderCounter + 1, + liveTime: undefined, }); } }; @@ -363,7 +389,7 @@ export class PanelChrome extends Component { } const PanelComponent = plugin.panel!; - const timeRange = data.timeRange || this.timeSrv.timeRange(); + const timeRange = this.state.liveTime ?? data.timeRange ?? this.timeSrv.timeRange(); const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight; const chromePadding = plugin.noPadding ? 0 : theme.panelPadding; const panelWidth = width - chromePadding * 2 - PANEL_BORDER; diff --git a/public/app/features/dashboard/dashgrid/liveTimer.ts b/public/app/features/dashboard/dashgrid/liveTimer.ts new file mode 100644 index 00000000000..113e9f64d7b --- /dev/null +++ b/public/app/features/dashboard/dashgrid/liveTimer.ts @@ -0,0 +1,113 @@ +import { dateMath, dateTime, TimeRange } from '@grafana/data'; +import { PanelChrome } from './PanelChrome'; + +// target is 20hz (50ms), but we poll at 100ms to smooth out jitter +const interval = 100; + +interface LiveListener { + last: number; + intervalMs: number; + panel: PanelChrome; +} + +class LiveTimer { + listeners: LiveListener[] = []; + + budget = 1; + threshold = 1.5; // trial and error appears about right + ok = true; + lastUpdate = Date.now(); + + isLive = false; // the dashboard time range ends in "now" + timeRange?: TimeRange; + liveTimeOffset = 0; + + /** Called when the dashboard time range changes */ + setLiveTimeRange(v?: TimeRange) { + this.timeRange = v; + this.isLive = v?.raw?.to === 'now'; + + if (this.isLive) { + const from = dateMath.parse(v!.raw.from, false)?.valueOf()!; + const to = dateMath.parse(v!.raw.to, true)?.valueOf()!; + this.liveTimeOffset = to - from; + + for (const listener of this.listeners) { + listener.intervalMs = getLiveTimerInterval(this.liveTimeOffset, listener.panel.props.width); + } + } + } + + listen(panel: PanelChrome) { + this.listeners.push({ + last: this.lastUpdate, + panel: panel, + intervalMs: getLiveTimerInterval( + 60000, // 1min + panel.props.width + ), + }); + } + + remove(panel: PanelChrome) { + this.listeners = this.listeners.filter((v) => v.panel !== panel); + } + + updateInterval(panel: PanelChrome) { + if (!this.timeRange || !this.isLive) { + return; + } + for (const listener of this.listeners) { + if (listener.panel === panel) { + listener.intervalMs = getLiveTimerInterval(this.liveTimeOffset, listener.panel.props.width); + return; + } + } + } + + // Called at the consistent dashboard interval + measure = () => { + const now = Date.now(); + this.budget = (now - this.lastUpdate) / interval; + this.ok = this.budget <= this.threshold; + this.lastUpdate = now; + + // For live dashboards, listen to changes + if (this.ok && this.isLive && this.timeRange) { + // when the time-range is relative fire events + let tr: TimeRange | undefined = undefined; + for (const listener of this.listeners) { + if (!listener.panel.props.isInView) { + continue; + } + + const elapsed = now - listener.last; + if (elapsed >= listener.intervalMs) { + if (!tr) { + const { raw } = this.timeRange; + tr = { + raw, + from: dateTime(now - this.liveTimeOffset), + to: dateTime(now), + }; + } + listener.panel.liveTimeChanged(tr); + listener.last = now; + } + } + } + }; +} + +const FIVE_MINS = 5 * 60 * 1000; + +export function getLiveTimerInterval(delta: number, width: number): number { + const millisPerPixel = Math.ceil(delta / width / 100) * 100; + if (millisPerPixel > FIVE_MINS) { + return FIVE_MINS; + } + return millisPerPixel; +} + +export const liveTimer = new LiveTimer(); +setInterval(liveTimer.measure, interval); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index e20a203ca1f..207fde267b9 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -78,6 +78,7 @@ export class DashboardModel { editable: any; graphTooltip: DashboardCursorSync; time: any; + liveNow: boolean; private originalTime: any; timepicker: any; templating: { list: any[] }; @@ -138,6 +139,7 @@ export class DashboardModel { this.graphTooltip = data.graphTooltip || 0; this.time = data.time || { from: 'now-6h', to: 'now' }; this.timepicker = data.timepicker || {}; + this.liveNow = Boolean(data.liveNow); this.templating = this.ensureListExist(data.templating); this.annotations = this.ensureListExist(data.annotations); this.refresh = data.refresh; diff --git a/public/app/features/live/live.ts b/public/app/features/live/live.ts index eafbfa6cca9..b7141861f8f 100644 --- a/public/app/features/live/live.ts +++ b/public/app/features/live/live.ts @@ -37,7 +37,7 @@ import { } from './scopes'; import { registerLiveFeatures } from './features'; import { contextSrv } from '../../core/services/context_srv'; -import { perf } from './perf'; +import { liveTimer } from '../dashboard/dashgrid/liveTimer'; export const sessionId = (window as any)?.grafanaBootData?.user?.id + @@ -221,7 +221,7 @@ export class CentrifugeSrv implements GrafanaLiveSrv { let data: StreamingDataFrame | undefined = undefined; let filtered: DataFrame | undefined = undefined; let state = LoadingState.Streaming; - let last = perf.last; + let last = liveTimer.lastUpdate; let lastWidth = -1; const process = (msg: DataFrameJSON) => { @@ -248,11 +248,11 @@ export class CentrifugeSrv implements GrafanaLiveSrv { } } - const elapsed = perf.last - last; - if (elapsed > 1000 || perf.ok) { + const elapsed = liveTimer.lastUpdate - last; + if (elapsed > 1000 || liveTimer.ok) { filtered.length = data.length; // make sure they stay up-to-date subscriber.next({ state, data: [filtered], key }); - last = perf.last; + last = liveTimer.lastUpdate; } }; diff --git a/public/app/features/live/perf.ts b/public/app/features/live/perf.ts deleted file mode 100644 index aea98083981..00000000000 --- a/public/app/features/live/perf.ts +++ /dev/null @@ -1,28 +0,0 @@ -let lastUpdate = Date.now(); - -/** - * This object indicats how overloaded the main thread is - */ -export const perf = { - budget: 1, - threshold: 1.5, // trial and error appears about right - ok: true, - last: lastUpdate, -}; - -// Expose this as a global object so it can be changed locally -// NOTE: when we are confident this is the right budget, this should be removed -(window as any).grafanaStreamingPerf = perf; - -// target is 20hz (50ms), but we poll at 100ms to smooth out jitter -const interval = 100; - -function measure() { - const now = Date.now(); - perf.last = now; - perf.budget = (now - lastUpdate) / interval; - perf.ok = perf.budget <= perf.threshold; - lastUpdate = now; -} - -setInterval(measure, interval); diff --git a/public/app/plugins/datasource/testdata/runStreams.ts b/public/app/plugins/datasource/testdata/runStreams.ts index 7293ac390e6..e7a2bf3f316 100644 --- a/public/app/plugins/datasource/testdata/runStreams.ts +++ b/public/app/plugins/datasource/testdata/runStreams.ts @@ -16,7 +16,7 @@ import { import { TestDataQuery, StreamingQuery } from './types'; import { getRandomLine } from './LogIpsum'; -import { perf } from 'app/features/live/perf'; +import { liveTimer } from 'app/features/dashboard/dashgrid/liveTimer'; export const defaultStreamQuery: StreamingQuery = { type: 'signal', @@ -105,14 +105,14 @@ export function runSignalStream( const pushNextEvent = () => { addNextRow(Date.now()); - const elapsed = perf.last - lastSent; - if (elapsed > 1000 || perf.ok) { + const elapsed = liveTimer.lastUpdate - lastSent; + if (elapsed > 1000 || liveTimer.ok) { subscriber.next({ data: [frame], key: streamId, state: LoadingState.Streaming, }); - lastSent = perf.last; + lastSent = liveTimer.lastUpdate; } timeoutId = setTimeout(pushNextEvent, speed);