Dashboard: keep live timeseries moving left (v2) (#37769)

This commit is contained in:
Ryan McKinley 2021-08-20 14:48:55 -07:00 committed by GitHub
parent 32e11434da
commit 8c4c05493b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 197 additions and 41 deletions

View File

@ -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<PlotProps, UPlotChartState> {
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(),
});
}
}

View File

@ -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}
/>
<CollapsableSection label="Panel options" isOpen={true}>

View File

@ -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<Props, State> {
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<Props, State> {
<Field label="Hide time picker">
<Switch value={!!this.props.timePickerHidden} onChange={this.onHideTimePickerChange} />
</Field>
<Field
label="Refresh live dashboards"
description="Continuously re-draw panels where the time range references 'now'"
>
<Switch value={!!this.props.liveNow} onChange={this.onLiveNowChange} />
</Field>
</CollapsableSection>
);
}

View File

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
}
}
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;

View File

@ -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<Props, State> {
@ -134,14 +137,31 @@ export class PanelChrome extends Component<Props, State> {
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<Props, State> {
}
}
}
// 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<Props, State> {
break;
}
this.setState({ isFirstLoad, errorMessage, data });
this.setState({ isFirstLoad, errorMessage, data, liveTime: undefined });
}
onRefresh = () => {
@ -253,6 +278,7 @@ export class PanelChrome extends Component<Props, State> {
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<Props, State> {
}
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;

View File

@ -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);

View File

@ -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;

View File

@ -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;
}
};

View File

@ -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);

View File

@ -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);