The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
grafana/public/app/plugins/panel/state-timeline/utils.ts

633 lines
17 KiB

import React from 'react';
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types';
import {
ArrayVector,
DataFrame,
DashboardCursorSync,
DataHoverPayload,
DataHoverEvent,
DataHoverClearEvent,
FALLBACK_COLOR,
Field,
FieldColorModeId,
FieldConfig,
FieldType,
formattedValueToString,
getFieldDisplayName,
getValueFormat,
GrafanaTheme2,
getActiveThreshold,
Threshold,
getFieldConfigWithMinMax,
outerJoinDataFrames,
ThresholdsMode,
} from '@grafana/data';
import {
FIXED_UNIT,
SeriesVisibilityChangeMode,
UPlotConfigBuilder,
UPlotConfigPrepFn,
VizLegendItem,
} from '@grafana/ui';
import { getConfig, TimelineCoreOptions } from './timeline';
import { VizLegendOptions, AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema';
import { TimelineFieldConfig, TimelineOptions } from './types';
import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import uPlot from 'uplot';
const defaultConfig: TimelineFieldConfig = {
lineWidth: 0,
fillOpacity: 80,
};
export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return SeriesVisibilityChangeMode.AppendToSelection;
}
return SeriesVisibilityChangeMode.ToggleSelection;
}
export function preparePlotFrame(data: DataFrame[], dimFields: XYFieldMatchers) {
return outerJoinDataFrames({
frames: data,
joinBy: dimFields.x,
keep: dimFields.y,
keepOriginIndices: true,
});
}
export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
frame,
theme,
timeZone,
getTimeRange,
mode,
eventBus,
sync,
rowHeight,
colWidth,
showValue,
alignValue,
}) => {
const builder = new UPlotConfigBuilder(timeZone);
const xScaleUnit = 'time';
const xScaleKey = 'x';
const isDiscrete = (field: Field) => {
const mode = field.config?.color?.mode;
return !(mode && field.display && mode.startsWith('continuous-'));
};
const getValueColor = (seriesIdx: number, value: any) => {
const field = frame.fields[seriesIdx];
ValueMapping: Support for mapping text to color, boolean values, NaN and Null. Improved UI for value mapping. (#33820) * alternative mapping editor * alternative mapping editor * values updating * UI updates * remove empty operators * fix types * horizontal * New value mapping model and migration * DataSource: show the uid in edit url, not the local id (#33818) * update mapping model object * Update to UI * fixing ts issues * Editing starting to work * adding missing thing * Update display processor to use color from value mapping * Range maps now work * Working on unit tests for modal editor * Updated * Adding new NullToText mapping type * Added null to text UI * add color from old threshold config * Added migration for overrides, added Type column * Added compact view model with color edit capability * [Alerting]: store encrypted receiver secure settings (#33832) * [Alerting]: Store secure settings encrypted * Move encryption to the API handler * CloudMonitoring: Migrate config editor from angular to react (#33645) * fix broken config ctrl * replace angular config with react config editor * remove not used code * add extra linebreak * add noopener to link * only test jwt props that we actually need * Elasticsearch: automatically set date_histogram field based on data source configuration (#33840) * Docs: delete from high availability docs references to removed configurations related to session storage (#33827) * docs: delete from high availability docs references to removed configurations related to session storage * docs: remove session storage mention and focus on the auth token implementation * fix postgres to have precision of ms (#33853) * Use ids for enterprise nav model items (#33854) * Alerting: Disable dash alerting if NG enabled (#33794) * Scuemata: Add grafana-cli cue schema validation to CI (#33798) * Add scuemata validation in CI * Fixes according to reviewer's comments * Ensure http client has no timeout (#33856) * Redact sensitive values before logging them (#33829) * use a common way to redact sensitive values before logging them * fix panic on missing testCase.err, simplify require checks * fix a silly typo * combine readConfig and buildConnectionString methods, as they are closely related * Tempo: Search for Traces by querying Loki directly from Tempo (#33308) * Loki query from Tempo UI - add query type selector to tempo - introduce linkedDatasource concept that runs queries on behalf of another datasource - Tempo uses Loki's query field and Loki's derived fields to find a trace matcher - Tempo uses the trace-to-logs mechanism to determine which dataource is linked Loki data loads successfully via tempo Extracted result transformers Skip null values Show trace on list id click Query type selector Use linked field trace regexp * Review feedback * Add isolation level db configuration parameter (#33830) * add isolation level db configuration parameter * add isolation_level to default.ini and sample.ini * add note that only mysql supports isolation levels for now * mention isolation_level in the documentation * Update docs/sources/administration/configuration.md Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Drawer: fixes title overflowing its container (#33857) * Timeline: move grafana/ui elements to the panel folder (#33803) * revendor loki with new Tripperware (#33858) * live: move connection endpoint to api scope, fixes #33861 (#33863) * OAuth: Add support for empty scopes (#32129) * add parameter empty_scopes to override scope parameter with empty value and thus be able to authenticate against IdPs without scopes. Issue #27503 Update docs/sources/auth/generic-oauth.md Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * updated check according to feedback * Update generic-oauth.md Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Prometheus: Fix exemplars hover disappearing and broken link (#33866) * Revert "Tooltip: eliminate flickering when repaint can't keep up (#33609)" This reverts commit e159985aa2907e2c2889853f9295183edc1032ac. * Fix exemplar linking Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com> * Removed content as per MarcusE's suggestion in https://github.com/grafana/grafana/issues/33822. (#33870) * Fixed grammar usage. (#33871) * Explore: Wrap each panel in separate error boundary (#33868) * New Panel: Histogram (#33752) * Sanitize PromLink button (#33874) * Refactor and unify option creation between new visualizations (#33867) * Refactor and unify option creation between new visualizations * move to grafana/ui * move to grafana/ui * resolve duplicate scale config * more imports Co-authored-by: Ryan McKinley <ryantxu@gmail.com> * Live: do not show connection warning when on the login page (#33865) * enforce receivers align with backend type when posting AM config (#33877) * special values * merge fix * Document `hide_version` flag (#33670) Unauthenticated users can be barred from being shown the current Grafana server version since https://github.com/grafana/grafana/pull/24919 * GraphNG: always use "x" as scaleKey for x axis (#33884) * Timeline: add support for strings & booleans (#33882) * Chore(deps): Bump hosted-git-info from 2.8.5 to 2.8.9 (#33886) Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.5 to 2.8.9. - [Release notes](https://github.com/npm/hosted-git-info/releases) - [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md) - [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.5...v2.8.9) Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * merge with torkel * add empty special character * Fixed centered text in special value match select * fixed unit tests * Updated snapshot * Update dashboard page * updated snapshot * Fix more unit tests * Fixed test * Updates * Added back tests * Fixed doc issue Co-authored-by: Ryan McKinley <ryantxu@gmail.com> Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> Co-authored-by: Sofia Papagiannaki <papagian@users.noreply.github.com> Co-authored-by: Erik Sundell <erik.sundell@grafana.com> Co-authored-by: Giordano Ricci <me@giordanoricci.com> Co-authored-by: Daniel dos Santos Pereira <danield1591998@gmail.com> Co-authored-by: ying-jeanne <74549700+ying-jeanne@users.noreply.github.com> Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> Co-authored-by: Kyle Brandt <kyle@grafana.com> Co-authored-by: Dimitris Sotirakis <dimitrios.sotirakis@grafana.com> Co-authored-by: Will Browne <wbrowne@users.noreply.github.com> Co-authored-by: Serge Zaitsev <serge.zaitsev@grafana.com> Co-authored-by: David <david.kaltschmidt@gmail.com> Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Co-authored-by: Uchechukwu Obasi <obasiuche62@gmail.com> Co-authored-by: Owen Diehl <ow.diehl@gmail.com> Co-authored-by: Alexander Emelin <frvzmb@gmail.com> Co-authored-by: jvoeller <48791711+jvoeller@users.noreply.github.com> Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com> Co-authored-by: Leon Sorokin <leeoniya@gmail.com> Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com> Co-authored-by: Tristan Deloche <tde@hey.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4 years ago
if (field.display) {
const disp = field.display(value); // will apply color modes
if (disp.color) {
return disp.color;
}
}
return FALLBACK_COLOR;
};
const opts: TimelineCoreOptions = {
// should expose in panel config
mode: mode!,
numSeries: frame.fields.length - 1,
isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]),
rowHeight: rowHeight!,
colWidth: colWidth,
showValue: showValue!,
alignValue,
theme,
label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame),
getFieldConfig: (seriesIdx) => frame.fields[seriesIdx].config.custom,
getValueColor,
getTimeRange,
// hardcoded formatter for state values
formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
onHover: (seriesIndex, valueIndex) => {
hoveredSeriesIdx = seriesIndex;
hoveredDataIdx = valueIndex;
shouldChangeHover = true;
},
onLeave: () => {
hoveredSeriesIdx = null;
hoveredDataIdx = null;
shouldChangeHover = true;
},
};
let shouldChangeHover = false;
let hoveredSeriesIdx: number | null = null;
let hoveredDataIdx: number | null = null;
const coreConfig = getConfig(opts);
const payload: DataHoverPayload = {
point: {
[xScaleUnit]: null,
[FIXED_UNIT]: null,
},
data: frame,
};
builder.addHook('init', coreConfig.init);
builder.addHook('drawClear', coreConfig.drawClear);
builder.addHook('setCursor', coreConfig.setCursor);
// in TooltipPlugin, this gets invoked and the result is bound to a setCursor hook
// which fires after the above setCursor hook, so can take advantage of hoveringOver
// already set by the above onHover/onLeave callbacks that fire from coreConfig.setCursor
const interpolateTooltip: PlotTooltipInterpolator = (
updateActiveSeriesIdx,
updateActiveDatapointIdx,
updateTooltipPosition
) => {
if (shouldChangeHover) {
if (hoveredSeriesIdx != null) {
updateActiveSeriesIdx(hoveredSeriesIdx);
updateActiveDatapointIdx(hoveredDataIdx);
}
shouldChangeHover = false;
}
updateTooltipPosition(hoveredSeriesIdx == null);
};
builder.setTooltipInterpolator(interpolateTooltip);
builder.setPrepData(preparePlotData);
builder.setCursor(coreConfig.cursor);
builder.addScale({
scaleKey: xScaleKey,
isTime: true,
orientation: ScaleOrientation.Horizontal,
direction: ScaleDirection.Right,
range: coreConfig.xRange,
});
builder.addScale({
scaleKey: FIXED_UNIT, // y
isTime: false,
orientation: ScaleOrientation.Vertical,
direction: ScaleDirection.Up,
range: coreConfig.yRange,
});
builder.addAxis({
scaleKey: xScaleKey,
isTime: true,
splits: coreConfig.xSplits!,
placement: AxisPlacement.Bottom,
timeZone,
theme,
grid: { show: true },
});
builder.addAxis({
scaleKey: FIXED_UNIT, // y
isTime: false,
placement: AxisPlacement.Left,
splits: coreConfig.ySplits,
values: coreConfig.yValues,
grid: { show: false },
ticks: { show: false },
gap: 16,
theme,
});
let seriesIndex = 0;
for (let i = 0; i < frame.fields.length; i++) {
if (i === 0) {
continue;
}
const field = frame.fields[i];
const config = field.config as FieldConfig<TimelineFieldConfig>;
const customConfig: TimelineFieldConfig = {
...defaultConfig,
...config.custom,
};
field.state!.seriesIndex = seriesIndex++;
// const scaleKey = config.unit || FIXED_UNIT;
// const colorMode = getFieldColorModeForField(field);
builder.addSeries({
scaleKey: FIXED_UNIT,
pathBuilder: coreConfig.drawPaths,
pointsBuilder: coreConfig.drawPoints,
//colorMode,
lineWidth: customConfig.lineWidth,
fillOpacity: customConfig.fillOpacity,
theme,
show: !customConfig.hideFrom?.viz,
thresholds: config.thresholds,
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
dataFrameFieldIndex: field.state?.origin,
});
}
if (sync && sync() !== DashboardCursorSync.Off) {
let cursor: Partial<uPlot.Cursor> = {};
cursor.sync = {
key: '__global_',
filters: {
pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
if (sync && sync() === DashboardCursorSync.Off) {
return false;
}
payload.rowIndex = dataIdx;
if (x < 0 && y < 0) {
payload.point[xScaleUnit] = null;
payload.point[FIXED_UNIT] = null;
eventBus.publish(new DataHoverClearEvent());
} else {
payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
payload.point.panelRelY = y > 0 ? y / h : 1; // used for old graph panel to position tooltip
payload.down = undefined;
eventBus.publish(new DataHoverEvent(payload));
}
return true;
},
},
//TODO: remove any once https://github.com/leeoniya/uPlot/pull/611 got merged or the typing is fixed
scales: [xScaleKey, null as any],
};
builder.setSync();
builder.setCursor(cursor);
}
return builder;
};
export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
const names = new Map<string, number>();
for (let i = 0; i < frame.fields.length; i++) {
names.set(getFieldDisplayName(frame.fields[i], frame), i);
}
return names;
}
/**
* If any sequential duplicate values exist, this will return a new array
* with the future values set to undefined.
*
* in: 1, 1,undefined, 1,2, 2,null,2,3
* out: 1,undefined,undefined,undefined,2,undefined,null,2,3
*/
export function unsetSameFutureValues(values: any[]): any[] | undefined {
let prevVal = values[0];
let clone: any[] | undefined = undefined;
for (let i = 1; i < values.length; i++) {
let value = values[i];
if (value === null) {
prevVal = null;
} else {
if (value === prevVal) {
if (!clone) {
clone = [...values];
}
clone[i] = undefined;
} else if (value != null) {
prevVal = value;
}
}
}
return clone;
}
/**
* Merge values by the threshold
*/
export function mergeThresholdValues(field: Field, theme: GrafanaTheme2): Field | undefined {
const thresholds = field.config.thresholds;
if (field.type !== FieldType.number || !thresholds || !thresholds.steps.length) {
return undefined;
}
const items = getThresholdItems(field.config, theme);
if (items.length !== thresholds.steps.length) {
return undefined; // should not happen
}
const thresholdToText = new Map<Threshold, string>();
const textToColor = new Map<string, string>();
for (let i = 0; i < items.length; i++) {
thresholdToText.set(thresholds.steps[i], items[i].label);
textToColor.set(items[i].label, items[i].color!);
}
let prev: Threshold | undefined = undefined;
let input = field.values.toArray();
const vals = new Array<String | undefined>(field.values.length);
if (thresholds.mode === ThresholdsMode.Percentage) {
const { min, max } = getFieldConfigWithMinMax(field);
const delta = max! - min!;
input = input.map((v) => {
if (v == null) {
return v;
}
return ((v - min!) / delta) * 100;
});
}
for (let i = 0; i < vals.length; i++) {
const v = input[i];
if (v == null) {
vals[i] = v;
prev = undefined;
}
const active = getActiveThreshold(v, thresholds.steps);
if (active === prev) {
vals[i] = undefined;
} else {
vals[i] = thresholdToText.get(active);
}
prev = active;
}
return {
...field,
type: FieldType.string,
values: new ArrayVector(vals),
display: (value: string) => ({
text: value,
color: textToColor.get(value),
numeric: NaN,
}),
};
}
// This will return a set of frames with only graphable values included
export function prepareTimelineFields(
series: DataFrame[] | undefined,
mergeValues: boolean,
theme: GrafanaTheme2
): { frames?: DataFrame[]; warn?: string } {
if (!series?.length) {
return { warn: 'No data in response' };
}
let hasTimeseries = false;
const frames: DataFrame[] = [];
for (let frame of series) {
let isTimeseries = false;
let changed = false;
const fields: Field[] = [];
for (let field of frame.fields) {
switch (field.type) {
case FieldType.time:
isTimeseries = true;
hasTimeseries = true;
fields.push(field);
break;
case FieldType.number:
if (mergeValues && field.config.color?.mode === FieldColorModeId.Thresholds) {
const f = mergeThresholdValues(field, theme);
if (f) {
fields.push(f);
changed = true;
continue;
}
}
case FieldType.boolean:
case FieldType.string:
field = {
...field,
config: {
...field.config,
custom: {
...field.config.custom,
// magic value for join() to leave nulls alone
spanNulls: -1,
},
},
};
if (mergeValues) {
let merged = unsetSameFutureValues(field.values.toArray());
if (merged) {
fields.push({
...field,
values: new ArrayVector(merged),
});
changed = true;
continue;
}
}
fields.push(field);
break;
default:
changed = true;
}
}
if (isTimeseries && fields.length > 1) {
hasTimeseries = true;
if (changed) {
frames.push({
...frame,
fields,
});
} else {
frames.push(frame);
}
}
}
if (!hasTimeseries) {
return { warn: 'Data does not have a time field' };
}
if (!frames.length) {
return { warn: 'No graphable fields' };
}
return { frames };
}
export function getThresholdItems(fieldConfig: FieldConfig, theme: GrafanaTheme2): VizLegendItem[] {
const items: VizLegendItem[] = [];
const thresholds = fieldConfig.thresholds;
if (!thresholds || !thresholds.steps.length) {
return items;
}
const steps = thresholds.steps;
const disp = getValueFormat(thresholds.mode === ThresholdsMode.Percentage ? 'percent' : fieldConfig.unit ?? '');
const fmt = (v: number) => formattedValueToString(disp(v));
for (let i = 1; i <= steps.length; i++) {
const step = steps[i - 1];
items.push({
label: i === 1 ? `< ${fmt(step.value)}` : `${fmt(step.value)}+`,
color: theme.visualization.getColorByName(step.color),
yAxis: 1,
});
}
return items;
}
export function prepareTimelineLegendItems(
frames: DataFrame[] | undefined,
options: VizLegendOptions,
theme: GrafanaTheme2
): VizLegendItem[] | undefined {
if (!frames || options.displayMode === 'hidden') {
return undefined;
}
return getFieldLegendItem(allNonTimeFields(frames), theme);
}
export function getFieldLegendItem(fields: Field[], theme: GrafanaTheme2): VizLegendItem[] | undefined {
if (!fields.length) {
return undefined;
}
const items: VizLegendItem[] = [];
const fieldConfig = fields[0].config;
const colorMode = fieldConfig.color?.mode ?? FieldColorModeId.Fixed;
const thresholds = fieldConfig.thresholds;
// If thresholds are enabled show each step in the legend
if (colorMode === FieldColorModeId.Thresholds && thresholds?.steps && thresholds.steps.length > 1) {
return getThresholdItems(fieldConfig, theme);
}
// If thresholds are enabled show each step in the legend
if (colorMode.startsWith('continuous')) {
return undefined; // eventually a color bar
}
let stateColors: Map<string, string | undefined> = new Map();
fields.forEach((field) => {
field.values.toArray().forEach((v) => {
let state = field.display!(v);
if (state.color) {
stateColors.set(state.text, state.color!);
}
});
});
stateColors.forEach((color, label) => {
if (label.length > 0) {
items.push({
label: label!,
color: theme.visualization.getColorByName(color ?? FALLBACK_COLOR),
yAxis: 1,
});
}
});
return items;
}
function allNonTimeFields(frames: DataFrame[]): Field[] {
const fields: Field[] = [];
for (const frame of frames) {
for (const field of frame.fields) {
if (field.type !== FieldType.time) {
fields.push(field);
}
}
}
return fields;
}
export function findNextStateIndex(field: Field, datapointIdx: number) {
let end;
let rightPointer = datapointIdx + 1;
if (rightPointer >= field.values.length) {
return null;
}
while (end === undefined) {
if (rightPointer >= field.values.length) {
return null;
}
const rightValue = field.values.get(rightPointer);
if (rightValue !== undefined) {
end = rightPointer;
} else {
rightPointer++;
}
}
return end;
}
/**
* Returns the precise duration of a time range passed in milliseconds.
* This function calculates with 30 days month and 365 days year.
* adapted from https://gist.github.com/remino/1563878
* @param milliSeconds The duration in milliseconds
* @returns A formated string of the duration
*/
export function fmtDuration(milliSeconds: number): string {
if (milliSeconds < 0 || Number.isNaN(milliSeconds)) {
return '';
}
let yr: number, mo: number, wk: number, d: number, h: number, m: number, s: number, ms: number;
s = Math.floor(milliSeconds / 1000);
m = Math.floor(s / 60);
s = s % 60;
h = Math.floor(m / 60);
m = m % 60;
d = Math.floor(h / 24);
h = h % 24;
yr = Math.floor(d / 365);
if (yr > 0) {
d = d % 365;
}
mo = Math.floor(d / 30);
if (mo > 0) {
d = d % 30;
}
wk = Math.floor(d / 7);
if (wk > 0) {
d = d % 7;
}
ms = Math.round((milliSeconds % 1000) * 1000) / 1000;
return (yr > 0
? yr + 'y ' + (mo > 0 ? mo + 'mo ' : '') + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
: mo > 0
? mo + 'mo ' + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
: wk > 0
? wk + 'w ' + (d > 0 ? d + 'd ' : '')
: d > 0
? d + 'd ' + (h > 0 ? h + 'h ' : '')
: h > 0
? h + 'h ' + (m > 0 ? m + 'm ' : '')
: m > 0
? m + 'm ' + (s > 0 ? s + 's ' : '')
: s > 0
? s + 's ' + (ms > 0 ? ms + 'ms ' : '')
: ms > 0
? ms + 'ms '
: '0'
).trim();
}