mirror of
https://github.com/grafana/grafana.git
synced 2025-01-16 11:42:35 -06:00
Dashboard: keep live timeseries moving left (v2) (#37769)
This commit is contained in:
parent
32e11434da
commit
8c4c05493b
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
113
public/app/features/dashboard/dashgrid/liveTimer.ts
Normal file
113
public/app/features/dashboard/dashgrid/liveTimer.ts
Normal 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);
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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);
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user