Prometheus: Add support for Exemplars (#28057)

* Fix typos

* Query exemplars API

* Add link to traceID

* Update exemplar to show more information

Reduce exemplars density

* Fix typos

* Query exemplars API

* Add link to traceID

* Update exemplar to show more information

Reduce exemplars density

* Update GraphNG legend type

* Show new graph component in Explore

* Add exemplar annotation a design update

* Graph panel not to show red line annotation

Exemplar plugin to use y value

* Address review comments

* Density filter for exemplars

* Update schema of exemplars

* Density filter with y-value sampling

* Enforce axis scales to include 0

* Changes after merge with master

* Show metrics when there is no result

* Decorators tests fix

* ExemplarMarker to receive component prop

* Remove context menu from explore graph

* Add color to graph

* Update explore graph panel

* Update graph config to use default values

* Fix data source tests

* Do not show exemplars outside of graph

* Add exemplars switch

* Fix typos

* Add exemplars query only when enabled

* Show graph in explore without filling it

* Update exemplars plugin y value scale selection

* Update tests

* Add data source picker for internal linking

* Increase pointSize for better visibility

* Fix explore e2e test

* Fix data link title variable interpolation

* Use new switch component in PromExemplarField

* Move FieldLink component to new file

* Convert exemplar to datalink

* Add legend toggling logic to Explore

* Add legend toggling to Explore

* Address Ivana's feedback

* Address Andrej's comments

* Address Gio's feedback

* Add tests for result_transformer

* Fix eslint issues

* Change sampler formula for better readability

Co-authored-by: David Kaltschmidt <david@leia.lan>
Co-authored-by: David Kaltschmidt <david@leia.fritz.box>
Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com>
This commit is contained in:
Zoltán Bedi
2021-01-15 16:20:20 +01:00
committed by GitHub
parent 46167785e6
commit b649bfc270
33 changed files with 959 additions and 322 deletions

View File

@@ -25,6 +25,6 @@ e2e.scenario({
}); });
const canvases = e2e().get('canvas'); const canvases = e2e().get('canvas');
canvases.should('have.length', 2); canvases.should('have.length', 1);
}, },
}); });

View File

@@ -282,7 +282,7 @@ export abstract class DataSourceApi<
interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): TQuery[]; interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): TQuery[];
/** /**
* An annotation processor allows explict control for how annotations are managed. * An annotation processor allows explicit control for how annotations are managed.
* *
* It is only necessary to configure an annotation processor if the default behavior is not desirable * It is only necessary to configure an annotation processor if the default behavior is not desirable
*/ */
@@ -431,7 +431,7 @@ export interface DataQuery {
queryType?: string; queryType?: string;
/** /**
* The data topic resuls should be attached to * The data topic results should be attached to
*/ */
dataTopic?: DataTopic; dataTopic?: DataTopic;

View File

@@ -44,7 +44,7 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod
const title = link.title ? link.title : internalLink.datasourceName; const title = link.title ? link.title : internalLink.datasourceName;
return { return {
title: replaceVariables(title), title: replaceVariables(title, scopedVars),
// In this case this is meant to be internal link (opens split view by default) the href will also points // In this case this is meant to be internal link (opens split view by default) the href will also points
// to explore but this way you can open it in new tab. // to explore but this way you can open it in new tab.
href: generateInternalHref(internalLink.datasourceName, interpolatedQuery, range), href: generateInternalHref(internalLink.datasourceName, interpolatedQuery, range),

View File

@@ -45,6 +45,8 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Auto, axisPlacement: AxisPlacement.Auto,
}; };
export const FIXED_UNIT = '__fixed';
export const GraphNG: React.FC<GraphNGProps> = ({ export const GraphNG: React.FC<GraphNGProps> = ({
data, data,
fields, fields,
@@ -88,7 +90,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
[onLegendClick, data] [onLegendClick, data]
); );
// reference change will not triger re-render // reference change will not trigger re-render
const currentTimeRange = useRef<TimeRange>(timeRange); const currentTimeRange = useRef<TimeRange>(timeRange);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -104,7 +106,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
return builder; return builder;
} }
// X is the first field in the alligned frame // X is the first field in the aligned frame
const xField = alignedFrame.fields[0]; const xField = alignedFrame.fields[0];
if (xField.type === FieldType.time) { if (xField.type === FieldType.time) {
builder.addScale({ builder.addScale({
@@ -147,7 +149,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
} }
const fmt = field.display ?? defaultFormatter; const fmt = field.display ?? defaultFormatter;
const scaleKey = config.unit || '__fixed'; const scaleKey = config.unit || FIXED_UNIT;
const colorMode = getFieldColorModeForField(field); const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0); const seriesColor = colorMode.getCalculator(field, theme)(0, 0);

View File

@@ -0,0 +1,29 @@
import { Field, LinkModel } from '@grafana/data';
import React from 'react';
import { Button } from '..';
type FieldLinkProps = {
link: LinkModel<Field>;
};
export function FieldLink({ link }: FieldLinkProps) {
return (
<a
href={link.href}
target="_blank"
rel="noreferrer"
onClick={
link.onClick
? event => {
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) {
event.preventDefault();
link.onClick(event);
}
}
: undefined
}
>
<Button icon="external-link-alt">{link.title}</Button>
</a>
);
}

View File

@@ -10,7 +10,7 @@ import { stylesFactory } from '../../themes/stylesFactory';
//Components //Components
import { LogLabelStats } from './LogLabelStats'; import { LogLabelStats } from './LogLabelStats';
import { IconButton } from '../IconButton/IconButton'; import { IconButton } from '../IconButton/IconButton';
import { Tag } from '..'; import { FieldLink } from './FieldLink';
export interface Props extends Themeable { export interface Props extends Themeable {
parsedValue: string; parsedValue: string;
@@ -177,41 +177,5 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
} }
} }
const getLinkStyles = stylesFactory(() => {
return {
tag: css`
margin-left: 6px;
font-size: 11px;
padding: 2px 6px;
`,
};
});
type FieldLinkProps = {
link: LinkModel<Field>;
};
function FieldLink({ link }: FieldLinkProps) {
const styles = getLinkStyles();
return (
<a
href={link.href}
target={'_blank'}
rel="noreferrer"
onClick={
link.onClick
? event => {
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) {
event.preventDefault();
link.onClick(event);
}
}
: undefined
}
>
<Tag name={link.title} className={styles.tag} colorIndex={6} />
</a>
);
}
export const LogDetailsRow = withTheme(UnThemedLogDetailsRow); export const LogDetailsRow = withTheme(UnThemedLogDetailsRow);
LogDetailsRow.displayName = 'LogDetailsRow'; LogDetailsRow.displayName = 'LogDetailsRow';

View File

@@ -87,6 +87,7 @@ export { LogLabels } from './Logs/LogLabels';
export { LogMessageAnsi } from './Logs/LogMessageAnsi'; export { LogMessageAnsi } from './Logs/LogMessageAnsi';
export { LogRows } from './Logs/LogRows'; export { LogRows } from './Logs/LogRows';
export { getLogRowStyles } from './Logs/getLogRowStyles'; export { getLogRowStyles } from './Logs/getLogRowStyles';
export { FieldLink } from './Logs/FieldLink';
export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup'; export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup';
// Panel editors // Panel editors
export { FullWidthButtonContainer } from './Button/FullWidthButtonContainer'; export { FullWidthButtonContainer } from './Button/FullWidthButtonContainer';
@@ -204,5 +205,5 @@ export * from './uPlot/geometries';
export * from './uPlot/plugins'; export * from './uPlot/plugins';
export { useRefreshAfterGraphRendered } from './uPlot/hooks'; export { useRefreshAfterGraphRendered } from './uPlot/hooks';
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context'; export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';
export { GraphNG } from './GraphNG/GraphNG'; export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types'; export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types';

View File

@@ -1,18 +1,18 @@
import { DataFrame } from '@grafana/data';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { DataFrame, DataFrameView } from '@grafana/data';
import { usePlotContext } from '../context'; import { usePlotContext } from '../context';
import { useRefreshAfterGraphRendered } from '../hooks';
import { Marker } from './Marker'; import { Marker } from './Marker';
import { XYCanvas } from './XYCanvas'; import { XYCanvas } from './XYCanvas';
import { useRefreshAfterGraphRendered } from '../hooks';
interface EventsCanvasProps<T> { interface EventsCanvasProps {
id: string; id: string;
events: DataFrame[]; events: DataFrame[];
renderEventMarker: (event: T) => React.ReactNode; renderEventMarker: (dataFrame: DataFrame, index: number) => React.ReactNode;
mapEventToXYCoords: (event: T) => { x: number; y: number } | undefined; mapEventToXYCoords: (dataFrame: DataFrame, index: number) => { x: number; y: number } | undefined;
} }
export function EventsCanvas<T>({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps<T>) { export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps) {
const plotCtx = usePlotContext(); const plotCtx = usePlotContext();
const renderToken = useRefreshAfterGraphRendered(id); const renderToken = useRefreshAfterGraphRendered(id);
@@ -23,17 +23,15 @@ export function EventsCanvas<T>({ id, events, renderEventMarker, mapEventToXYCoo
} }
for (let i = 0; i < events.length; i++) { for (let i = 0; i < events.length; i++) {
const view = new DataFrameView<T>(events[i]); const frame = events[i];
for (let j = 0; j < view.length; j++) { for (let j = 0; j < frame.length; j++) {
const event = view.get(j); const coords = mapEventToXYCoords(frame, j);
const coords = mapEventToXYCoords(event);
if (!coords) { if (!coords) {
continue; continue;
} }
markers.push( markers.push(
<Marker {...coords} key={`${id}-marker-${i}-${j}`}> <Marker {...coords} key={`${id}-marker-${i}-${j}`}>
{renderEventMarker(event)} {renderEventMarker(frame, j)}
</Marker> </Marker>
); );
} }

View File

@@ -55,7 +55,6 @@ const dummyProps: ExploreProps = {
syncedTimes: false, syncedTimes: false,
updateTimeRange: jest.fn(), updateTimeRange: jest.fn(),
graphResult: [], graphResult: [],
loading: false,
absoluteRange: { absoluteRange: {
from: 0, from: 0,
to: 0, to: 0,

View File

@@ -12,7 +12,6 @@ import {
DataQuery, DataQuery,
DataSourceApi, DataSourceApi,
GrafanaTheme, GrafanaTheme,
GraphSeriesXY,
LoadingState, LoadingState,
PanelData, PanelData,
RawTimeRange, RawTimeRange,
@@ -20,6 +19,7 @@ import {
TimeZone, TimeZone,
ExploreUrlState, ExploreUrlState,
LogsModel, LogsModel,
DataFrame,
EventBusExtended, EventBusExtended,
EventBusSrv, EventBusSrv,
TraceViewData, TraceViewData,
@@ -49,11 +49,11 @@ import { ExploreToolbar } from './ExploreToolbar';
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction'; import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
import { getTimeZone } from '../profile/state/selectors'; import { getTimeZone } from '../profile/state/selectors';
import { ErrorContainer } from './ErrorContainer'; import { ErrorContainer } from './ErrorContainer';
import { ExploreGraphPanel } from './ExploreGraphPanel';
//TODO:unification //TODO:unification
import { TraceView } from './TraceView/TraceView'; import { TraceView } from './TraceView/TraceView';
import { SecondaryActions } from './SecondaryActions'; import { SecondaryActions } from './SecondaryActions';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types';
import { ExploreGraphNGPanel } from './ExploreGraphNGPanel';
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
return { return {
@@ -100,11 +100,10 @@ export interface ExploreProps {
isLive: boolean; isLive: boolean;
syncedTimes: boolean; syncedTimes: boolean;
updateTimeRange: typeof updateTimeRange; updateTimeRange: typeof updateTimeRange;
graphResult?: GraphSeriesXY[] | null; graphResult: DataFrame[] | null;
logsResult?: LogsModel; logsResult?: LogsModel;
loading?: boolean;
absoluteRange: AbsoluteTimeRange; absoluteRange: AbsoluteTimeRange;
timeZone?: TimeZone; timeZone: TimeZone;
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void; onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
queryResponse: PanelData; queryResponse: PanelData;
originPanelId: number; originPanelId: number;
@@ -293,7 +292,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
split, split,
queryKeys, queryKeys,
graphResult, graphResult,
loading,
absoluteRange, absoluteRange,
timeZone, timeZone,
queryResponse, queryResponse,
@@ -311,6 +309,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const styles = getStyles(theme); const styles = getStyles(theme);
const StartPage = datasourceInstance?.components?.ExploreStartPage; const StartPage = datasourceInstance?.components?.ExploreStartPage;
const showStartPage = !queryResponse || queryResponse.state === LoadingState.NotStarted; const showStartPage = !queryResponse || queryResponse.state === LoadingState.NotStarted;
const isLoading = queryResponse.state === LoadingState.Loading;
// gets an error without a refID, so non-query-row-related error, like a connection error // gets an error without a refID, so non-query-row-related error, like a connection error
const queryErrors = queryResponse.error ? [queryResponse.error] : undefined; const queryErrors = queryResponse.error ? [queryResponse.error] : undefined;
@@ -360,19 +359,16 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
)} )}
{!showStartPage && ( {!showStartPage && (
<> <>
{showMetrics && ( {showMetrics && graphResult && (
<ExploreGraphPanel <ExploreGraphNGPanel
ariaLabel={selectors.pages.Explore.General.graph} data={graphResult}
series={graphResult}
width={width} width={width}
loading={loading}
absoluteRange={absoluteRange} absoluteRange={absoluteRange}
isStacked={false}
showPanel={true}
timeZone={timeZone} timeZone={timeZone}
onUpdateTimeRange={this.onUpdateTimeRange} onUpdateTimeRange={this.onUpdateTimeRange}
showBars={false} annotations={queryResponse.annotations}
showLines={true} splitOpenFn={splitOpen}
isLoading={isLoading}
/> />
)} )}
{showTable && ( {showTable && (
@@ -456,7 +452,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
showMetrics, showMetrics,
showTable, showTable,
showTrace, showTrace,
loading,
absoluteRange, absoluteRange,
queryResponse, queryResponse,
} = item; } = item;
@@ -479,9 +474,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
initialQueries, initialQueries,
initialRange, initialRange,
isLive, isLive,
graphResult: graphResult ?? undefined, graphResult,
logsResult: logsResult ?? undefined, logsResult: logsResult ?? undefined,
loading,
absoluteRange, absoluteRange,
queryResponse, queryResponse,
originPanelId, originPanelId,

View File

@@ -0,0 +1,165 @@
import {
AbsoluteTimeRange,
applyFieldOverrides,
createFieldConfigRegistry,
DataFrame,
dateTime,
Field,
FieldColorModeId,
FieldConfigSource,
GrafanaTheme,
TimeZone,
} from '@grafana/data';
import {
Collapse,
DrawStyle,
GraphNG,
GraphNGLegendEvent,
Icon,
LegendDisplayMode,
TooltipPlugin,
useStyles,
useTheme,
ZoomPlugin,
} from '@grafana/ui';
import { defaultGraphConfig, getGraphFieldConfig } from 'app/plugins/panel/timeseries/config';
import { hideSeriesConfigFactory } from 'app/plugins/panel/timeseries/overrides/hideSeriesConfigFactory';
import { ContextMenuPlugin } from 'app/plugins/panel/timeseries/plugins/ContextMenuPlugin';
import { ExemplarsPlugin } from 'app/plugins/panel/timeseries/plugins/ExemplarsPlugin';
import { css, cx } from 'emotion';
import React, { useCallback, useMemo, useState } from 'react';
import { splitOpen } from './state/main';
import { getFieldLinksForExplore } from './utils/links';
const MAX_NUMBER_OF_TIME_SERIES = 20;
interface Props {
data: DataFrame[];
annotations?: DataFrame[];
isLoading: boolean;
width: number;
absoluteRange: AbsoluteTimeRange;
timeZone: TimeZone;
onUpdateTimeRange: (absoluteRange: AbsoluteTimeRange) => void;
splitOpenFn: typeof splitOpen;
}
export function ExploreGraphNGPanel({
width,
data,
timeZone,
absoluteRange,
onUpdateTimeRange,
isLoading,
annotations,
splitOpenFn,
}: Props) {
const theme = useTheme();
const [showAllTimeSeries, setShowAllTimeSeries] = useState(false);
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>({
defaults: {
color: {
mode: FieldColorModeId.PaletteClassic,
},
custom: {
drawStyle: DrawStyle.Line,
fillOpacity: 0,
pointSize: 5,
},
},
overrides: [],
});
const style = useStyles(getStyles);
const timeRange = {
from: dateTime(absoluteRange.from),
to: dateTime(absoluteRange.to),
raw: {
from: dateTime(absoluteRange.from),
to: dateTime(absoluteRange.to),
},
};
const dataWithConfig = useMemo(() => {
const registry = createFieldConfigRegistry(getGraphFieldConfig(defaultGraphConfig), 'Explore');
return applyFieldOverrides({
fieldConfig,
data,
timeZone,
replaceVariables: value => value, // We don't need proper replace here as it is only used in getLinks and we use getFieldLinks
theme,
fieldConfigRegistry: registry,
});
}, [fieldConfig, data, timeZone, theme]);
const onLegendClick = useCallback(
(event: GraphNGLegendEvent) => {
setFieldConfig(hideSeriesConfigFactory(event, fieldConfig, data));
},
[fieldConfig, data]
);
const seriesToShow = showAllTimeSeries ? dataWithConfig : dataWithConfig.slice(0, MAX_NUMBER_OF_TIME_SERIES);
const getFieldLinks = (field: Field, rowIndex: number) => {
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn, range: timeRange });
};
return (
<>
{dataWithConfig.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
<div className={cx([style.timeSeriesDisclaimer])}>
<Icon className={style.disclaimerIcon} name="exclamation-triangle" />
{`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
<span
className={cx([style.showAllTimeSeries])}
onClick={() => setShowAllTimeSeries(true)}
>{`Show all ${dataWithConfig.length}`}</span>
</div>
)}
<Collapse label="Graph" loading={isLoading} isOpen>
<GraphNG
data={seriesToShow}
width={width}
height={400}
timeRange={timeRange}
onLegendClick={onLegendClick}
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom' }}
timeZone={timeZone}
>
<TooltipPlugin mode="single" timeZone={timeZone} />
<ZoomPlugin onZoom={onUpdateTimeRange} />
<ContextMenuPlugin timeZone={timeZone} />
{annotations ? (
<ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />
) : (
<></>
)}
</GraphNG>
</Collapse>
</>
);
}
const getStyles = (theme: GrafanaTheme) => ({
timeSeriesDisclaimer: css`
label: time-series-disclaimer;
width: 300px;
margin: ${theme.spacing.sm} auto;
padding: 10px 0;
border-radius: ${theme.border.radius.md};
text-align: center;
background-color: ${theme.colors.bg1};
`,
disclaimerIcon: css`
label: disclaimer-icon;
color: ${theme.palette.yellow};
margin-right: ${theme.spacing.xs};
`,
showAllTimeSeries: css`
label: show-all-time-series;
cursor: pointer;
color: ${theme.colors.linkExternal};
`,
});

View File

@@ -159,37 +159,7 @@ describe('decorateWithGraphResult', () => {
it('should process the graph dataFrames', () => { it('should process the graph dataFrames', () => {
const { timeSeries } = getTestContext(); const { timeSeries } = getTestContext();
const panelData = createExplorePanelData({ graphFrames: [timeSeries] }); const panelData = createExplorePanelData({ graphFrames: [timeSeries] });
console.log(decorateWithGraphResult(panelData).graphResult); expect(decorateWithGraphResult(panelData).graphResult).toMatchObject([timeSeries]);
expect(decorateWithGraphResult(panelData).graphResult).toMatchObject([
{
label: 'A-series',
data: [
[100, 4],
[200, 5],
[300, 6],
],
isVisible: true,
yAxis: {
index: 1,
},
seriesIndex: 0,
timeStep: 100,
},
{
label: 'B-series',
data: [
[100, 7],
[200, 8],
[300, 9],
],
isVisible: true,
yAxis: {
index: 1,
},
seriesIndex: 1,
timeStep: 100,
},
]);
}); });
it('returns null if it gets empty array', () => { it('returns null if it gets empty array', () => {

View File

@@ -1,5 +1,3 @@
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { import {
AbsoluteTimeRange, AbsoluteTimeRange,
DataFrame, DataFrame,
@@ -11,12 +9,11 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { Observable, of } from 'rxjs';
import { ExplorePanelData } from '../../../types'; import { map } from 'rxjs/operators';
import { getGraphSeriesModel } from '../flotgraph/getGraphSeriesModel';
import { dataFrameToLogsModel } from '../../../core/logs_model'; import { dataFrameToLogsModel } from '../../../core/logs_model';
import { refreshIntervalToSortOrder } from '../../../core/utils/explore'; import { refreshIntervalToSortOrder } from '../../../core/utils/explore';
import { LegendDisplayMode } from '@grafana/ui'; import { ExplorePanelData } from '../../../types';
/** /**
* When processing response first we try to determine what kind of dataframes we got as one query can return multiple * When processing response first we try to determine what kind of dataframes we got as one query can return multiple
@@ -80,22 +77,11 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane
}; };
export const decorateWithGraphResult = (data: ExplorePanelData): ExplorePanelData => { export const decorateWithGraphResult = (data: ExplorePanelData): ExplorePanelData => {
if (data.error) { if (data.error || !data.graphFrames.length) {
return { ...data, graphResult: null }; return { ...data, graphResult: null };
} }
const graphResult = return { ...data, graphResult: data.graphFrames };
data.graphFrames.length === 0
? null
: getGraphSeriesModel(
data.graphFrames,
data.request?.timezone ?? 'browser',
{},
{ showBars: false, showLines: true, showPoints: false },
{ displayMode: LegendDisplayMode.List, placement: 'bottom' }
);
return { ...data, graphResult };
}; };
/** /**

View File

@@ -0,0 +1,21 @@
import { InlineField, InlineSwitch } from '@grafana/ui';
import React from 'react';
import { PromQuery } from '../types';
interface Props {
query: PromQuery;
onChange: (value: PromQuery) => void;
}
const onExemplarsChange = ({ query, onChange }: Props) => (e: React.ChangeEvent<HTMLInputElement>) => {
const exemplar = e.target.checked;
onChange({ ...query, exemplar });
};
export function PromExemplarField(props: Props) {
return (
<InlineField label="Exemplars" labelWidth="auto">
<InlineSwitch label="Exemplars" value={props.query.exemplar} onChange={onExemplarsChange(props)} />
</InlineField>
);
}

View File

@@ -5,6 +5,7 @@ import { PromExploreExtraFieldProps, PromExploreExtraField } from './PromExplore
const setup = (propOverrides?: PromExploreExtraFieldProps) => { const setup = (propOverrides?: PromExploreExtraFieldProps) => {
const queryType = 'range'; const queryType = 'range';
const stepValue = '1'; const stepValue = '1';
const query = { exemplar: false };
const onStepChange = jest.fn(); const onStepChange = jest.fn();
const onQueryTypeChange = jest.fn(); const onQueryTypeChange = jest.fn();
const onKeyDownFunc = jest.fn(); const onKeyDownFunc = jest.fn();
@@ -12,6 +13,7 @@ const setup = (propOverrides?: PromExploreExtraFieldProps) => {
const props: any = { const props: any = {
queryType, queryType,
stepValue, stepValue,
query,
onStepChange, onStepChange,
onQueryTypeChange, onQueryTypeChange,
onKeyDownFunc, onKeyDownFunc,

View File

@@ -4,17 +4,21 @@ import { css, cx } from 'emotion';
// Types // Types
import { InlineFormLabel, RadioButtonGroup } from '@grafana/ui'; import { InlineFormLabel, RadioButtonGroup } from '@grafana/ui';
import { PromQuery } from '../types';
import { PromExemplarField } from './PromExemplarField';
export interface PromExploreExtraFieldProps { export interface PromExploreExtraFieldProps {
queryType: string; queryType: string;
stepValue: string; stepValue: string;
query: PromQuery;
onStepChange: (e: React.SyntheticEvent<HTMLInputElement>) => void; onStepChange: (e: React.SyntheticEvent<HTMLInputElement>) => void;
onKeyDownFunc: (e: React.KeyboardEvent<HTMLInputElement>) => void; onKeyDownFunc: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onQueryTypeChange: (value: string) => void; onQueryTypeChange: (value: string) => void;
onChange: (value: PromQuery) => void;
} }
export const PromExploreExtraField: React.FC<PromExploreExtraFieldProps> = memo( export const PromExploreExtraField: React.FC<PromExploreExtraFieldProps> = memo(
({ queryType, stepValue, onStepChange, onQueryTypeChange, onKeyDownFunc }) => { ({ queryType, stepValue, query, onChange, onStepChange, onQueryTypeChange, onKeyDownFunc }) => {
const rangeOptions = [ const rangeOptions = [
{ value: 'range', label: 'Range' }, { value: 'range', label: 'Range' },
{ value: 'instant', label: 'Instant' }, { value: 'instant', label: 'Instant' },
@@ -71,6 +75,8 @@ export const PromExploreExtraField: React.FC<PromExploreExtraFieldProps> = memo(
value={stepValue} value={stepValue}
/> />
</div> </div>
<PromExemplarField query={query} onChange={onChange} />
</div> </div>
); );
} }

View File

@@ -63,6 +63,8 @@ export const PromExploreQueryEditor: FC<Props> = (props: Props) => {
onQueryTypeChange={onQueryTypeChange} onQueryTypeChange={onQueryTypeChange}
onStepChange={onStepChange} onStepChange={onStepChange}
onKeyDownFunc={onReturnKeyDown} onKeyDownFunc={onReturnKeyDown}
query={query}
onChange={onChange}
/> />
} }
/> />

View File

@@ -9,6 +9,7 @@ import { PromOptions, PromQuery } from '../types';
import PromQueryField from './PromQueryField'; import PromQueryField from './PromQueryField';
import PromLink from './PromLink'; import PromLink from './PromLink';
import { PromExemplarField } from './PromExemplarField';
const { Switch } = LegacyForms; const { Switch } = LegacyForms;
@@ -96,7 +97,7 @@ export class PromQueryEditor extends PureComponent<Props, State> {
}; };
render() { render() {
const { datasource, query, range, data } = this.props; const { datasource, query, range, data, onChange } = this.props;
const { formatOption, instant, interval, intervalFactorOption, legendFormat } = this.state; const { formatOption, instant, interval, intervalFactorOption, legendFormat } = this.state;
return ( return (
@@ -182,6 +183,8 @@ export class PromQueryEditor extends PureComponent<Props, State> {
/> />
</InlineFormLabel> </InlineFormLabel>
</div> </div>
<PromExemplarField query={query} onChange={onChange} />
</div> </div>
</div> </div>
); );

View File

@@ -4,9 +4,17 @@ exports[`PromExploreQueryEditor should render component 1`] = `
<PromQueryField <PromQueryField
ExtraFieldElement={ ExtraFieldElement={
<Memo <Memo
onChange={[MockFunction]}
onKeyDownFunc={[Function]} onKeyDownFunc={[Function]}
onQueryTypeChange={[Function]} onQueryTypeChange={[Function]}
onStepChange={[Function]} onStepChange={[Function]}
query={
Object {
"expr": "",
"interval": "1s",
"refId": "A",
}
}
queryType="both" queryType="both"
stepValue="1s" stepValue="1s"
/> />

View File

@@ -181,6 +181,15 @@ exports[`Render PromQueryEditor with basic options should render 1`] = `
/> />
</FormLabel> </FormLabel>
</div> </div>
<PromExemplarField
onChange={[MockFunction]}
query={
Object {
"expr": "",
"refId": "A",
}
}
/>
</div> </div>
</div> </div>
`; `;

View File

@@ -0,0 +1,97 @@
import { Button, InlineField, InlineSwitch, Input } from '@grafana/ui';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { css } from 'emotion';
import React, { useState } from 'react';
import { ExemplarTraceIdDestination } from '../types';
type Props = {
value: ExemplarTraceIdDestination;
onChange: (value: ExemplarTraceIdDestination) => void;
onDelete: () => void;
};
export default function ExemplarSetting({ value, onChange, onDelete }: Props) {
const [isInternalLink, setIsInternalLink] = useState(Boolean(value.datasourceUid));
return (
<div className="gf-form-group">
<InlineField label="Internal link" labelWidth={24}>
<>
<InlineSwitch value={isInternalLink} onChange={ev => setIsInternalLink(ev.currentTarget.checked)} />
<Button
variant="destructive"
title="Remove link"
icon="times"
onClick={event => {
event.preventDefault();
onDelete();
}}
className={css`
margin-left: 8px;
`}
/>
</>
</InlineField>
{isInternalLink ? (
<InlineField
label="Data source"
labelWidth={24}
tooltip="The data source the exemplar is going to navigate to."
>
<DataSourcePicker
tracing={true}
current={value.datasourceUid}
noDefault={true}
onChange={ds =>
onChange({
datasourceUid: ds.uid,
name: value.name,
url: undefined,
})
}
/>
</InlineField>
) : (
<InlineField
label="URL"
labelWidth={24}
tooltip="The URL of the trace backend the user would go to see its trace."
>
<Input
placeholder="https://example.com/${__value.raw}"
spellCheck={false}
width={40}
value={value.url}
onChange={event =>
onChange({
datasourceUid: undefined,
name: value.name,
url: event.currentTarget.value,
})
}
/>
</InlineField>
)}
<InlineField
label="Label name"
labelWidth={24}
tooltip="The name of the field in the labels object that should be used to get the traceID."
>
<Input
placeholder="traceID"
spellCheck={false}
width={40}
value={value.name}
onChange={event =>
onChange({
...value,
name: event.currentTarget.value,
})
}
/>
</InlineField>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { Button } from '@grafana/ui';
import { css } from 'emotion';
import React from 'react';
import { ExemplarTraceIdDestination } from '../types';
import ExemplarSetting from './ExemplarSetting';
type Props = {
options?: ExemplarTraceIdDestination[];
onChange: (value: ExemplarTraceIdDestination[]) => void;
};
export function ExemplarsSettings({ options, onChange }: Props) {
return (
<>
<h3 className="page-heading">Exemplars</h3>
{options &&
options.map((option, index) => {
return (
<ExemplarSetting
key={index}
value={option}
onChange={newField => {
const newOptions = [...options];
newOptions.splice(index, 1, newField);
onChange(newOptions);
}}
onDelete={() => {
const newOptions = [...options];
newOptions.splice(index, 1);
onChange(newOptions);
}}
/>
);
})}
<Button
variant="secondary"
className={css`
margin-bottom: 10px;
`}
icon="plus"
onClick={event => {
event.preventDefault();
const newOptions = [...(options || []), { name: 'traceID' }];
onChange(newOptions);
}}
>
Add
</Button>
</>
);
}

View File

@@ -1,12 +1,14 @@
import React, { SyntheticEvent } from 'react';
import { EventsWithValidation, InlineFormLabel, regexValidation, LegacyForms } from '@grafana/ui';
const { Select, Input, FormField, Switch } = LegacyForms;
import { import {
SelectableValue,
onUpdateDatasourceJsonDataOptionChecked,
DataSourcePluginOptionsEditorProps, DataSourcePluginOptionsEditorProps,
onUpdateDatasourceJsonDataOptionChecked,
SelectableValue,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data'; } from '@grafana/data';
import { EventsWithValidation, InlineFormLabel, LegacyForms, regexValidation } from '@grafana/ui';
import React, { SyntheticEvent } from 'react';
import { PromOptions } from '../types'; import { PromOptions } from '../types';
import { ExemplarsSettings } from './ExemplarsSettings';
const { Select, Input, FormField, Switch } = LegacyForms;
const httpOptions = [ const httpOptions = [
{ value: 'GET', label: 'GET' }, { value: 'GET', label: 'GET' },
@@ -104,6 +106,16 @@ export const PromSettings = (props: Props) => {
</div> </div>
</div> </div>
</div> </div>
<ExemplarsSettings
options={options.jsonData.exemplarTraceIdDestinations}
onChange={exemplarOptions =>
updateDatasourcePluginJsonDataOption(
{ onOptionsChange, options },
'exemplarTraceIdDestinations',
exemplarOptions
)
}
/>
</> </>
); );
}; };

View File

@@ -69,59 +69,32 @@ describe('PrometheusDatasource', () => {
}); });
describe('Query', () => { describe('Query', () => {
it('returns empty array when no queries', done => { it('returns empty array when no queries', async () => {
expect.assertions(2); await expect(ds.query(createDataRequest([]))).toEmitValuesWith(response => {
expect(response[0].data).toEqual([]);
ds.query(createDataRequest([])).subscribe({ expect(response[0].state).toBe(LoadingState.Done);
next(next) {
expect(next.data).toEqual([]);
expect(next.state).toBe(LoadingState.Done);
},
complete() {
done();
},
}); });
}); });
it('performs time series queries', done => { it('performs time series queries', async () => {
expect.assertions(2); await expect(ds.query(createDataRequest([{}]))).toEmitValuesWith(response => {
expect(response[0].data.length).not.toBe(0);
ds.query(createDataRequest([{}])).subscribe({ expect(response[0].state).toBe(LoadingState.Done);
next(next) {
expect(next.data.length).not.toBe(0);
expect(next.state).toBe(LoadingState.Done);
},
complete() {
done();
},
}); });
}); });
it('with 2 queries and used from Explore, sends results as they arrive', done => { it('with 2 queries and used from Explore, sends results as they arrive', async () => {
expect.assertions(4); await expect(ds.query(createDataRequest([{}, {}], { app: CoreApp.Explore }))).toEmitValuesWith(response => {
expect(response[0].data.length).not.toBe(0);
const responseStatus = [LoadingState.Loading, LoadingState.Done]; expect(response[0].state).toBe(LoadingState.Loading);
ds.query(createDataRequest([{}, {}], { app: CoreApp.Explore })).subscribe({ expect(response[1].state).toBe(LoadingState.Done);
next(next) {
expect(next.data.length).not.toBe(0);
expect(next.state).toBe(responseStatus.shift());
},
complete() {
done();
},
}); });
}); });
it('with 2 queries and used from Panel, waits for all to finish until sending Done status', done => { it('with 2 queries and used from Panel, waits for all to finish until sending Done status', async () => {
expect.assertions(2); await expect(ds.query(createDataRequest([{}, {}], { app: CoreApp.Dashboard }))).toEmitValuesWith(response => {
ds.query(createDataRequest([{}, {}], { app: CoreApp.Dashboard })).subscribe({ expect(response[0].data.length).not.toBe(0);
next(next) { expect(response[0].state).toBe(LoadingState.Done);
expect(next.data.length).not.toBe(0);
expect(next.state).toBe(LoadingState.Done);
},
complete() {
done();
},
}); });
}); });
}); });
@@ -228,7 +201,7 @@ describe('PrometheusDatasource', () => {
}; };
}); });
it('should convert cumullative histogram to ordinary', () => { it('should convert cumulative histogram to ordinary', async () => {
const resultMock = [ const resultMock = [
{ {
metric: { __name__: 'metric', job: 'testjob', le: '10' }, metric: { __name__: 'metric', job: 'testjob', le: '10' },
@@ -254,38 +227,16 @@ describe('PrometheusDatasource', () => {
]; ];
const responseMock = { data: { data: { result: resultMock } } }; const responseMock = { data: { data: { result: resultMock } } };
const expected = [ ds.performTimeSeriesQuery = jest.fn().mockReturnValue(of(responseMock));
{ await expect(ds.query(query)).toEmitValuesWith(result => {
target: '10', const results = result[0].data;
datapoints: [ expect(results[0].fields[1].values.toArray()).toEqual([10, 10]);
[10, 1443454528000], expect(results[1].fields[1].values.toArray()).toEqual([10, 0]);
[10, 1443454528000], expect(results[2].fields[1].values.toArray()).toEqual([5, 0]);
],
},
{
target: '20',
datapoints: [
[10, 1443454528000],
[0, 1443454528000],
],
},
{
target: '30',
datapoints: [
[5, 1443454528000],
[0, 1443454528000],
],
},
];
ds.performTimeSeriesQuery = jest.fn().mockReturnValue(of([responseMock]));
ds.query(query).subscribe((result: any) => {
const results = result.data;
return expect(results).toMatchObject(expected);
}); });
}); });
it('should sort series by label value', () => { it('should sort series by label value', async () => {
const resultMock = [ const resultMock = [
{ {
metric: { __name__: 'metric', job: 'testjob', le: '2' }, metric: { __name__: 'metric', job: 'testjob', le: '2' },
@@ -320,10 +271,10 @@ describe('PrometheusDatasource', () => {
const expected = ['1', '2', '4', '+Inf']; const expected = ['1', '2', '4', '+Inf'];
ds.performTimeSeriesQuery = jest.fn().mockReturnValue(of([responseMock])); ds.performTimeSeriesQuery = jest.fn().mockReturnValue(of(responseMock));
ds.query(query).subscribe((result: any) => { await expect(ds.query(query)).toEmitValuesWith(result => {
const seriesLabels = _.map(result.data, 'target'); const seriesLabels = _.map(result[0].data, 'name');
return expect(seriesLabels).toEqual(expected); expect(seriesLabels).toEqual(expected);
}); });
}); });
}); });

View File

@@ -28,9 +28,11 @@ import { expandRecordingRules } from './language_utils';
import { getQueryHints } from './query_hints'; import { getQueryHints } from './query_hints';
import { getOriginalMetricName, renderTemplate, transform } from './result_transformer'; import { getOriginalMetricName, renderTemplate, transform } from './result_transformer';
import { import {
ExemplarTraceIdDestination,
isFetchErrorResponse, isFetchErrorResponse,
PromDataErrorResponse, PromDataErrorResponse,
PromDataSuccessResponse, PromDataSuccessResponse,
PromExemplarData,
PromMatrixData, PromMatrixData,
PromOptions, PromOptions,
PromQuery, PromQuery,
@@ -56,6 +58,7 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
queryTimeout: string; queryTimeout: string;
httpMethod: string; httpMethod: string;
languageProvider: PrometheusLanguageProvider; languageProvider: PrometheusLanguageProvider;
exemplarTraceIdDestinations: ExemplarTraceIdDestination[] | undefined;
lookupsDisabled: boolean; lookupsDisabled: boolean;
customQueryParameters: any; customQueryParameters: any;
@@ -75,6 +78,7 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
this.queryTimeout = instanceSettings.jsonData.queryTimeout; this.queryTimeout = instanceSettings.jsonData.queryTimeout;
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET'; this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
this.directUrl = instanceSettings.jsonData.directUrl; this.directUrl = instanceSettings.jsonData.directUrl;
this.exemplarTraceIdDestinations = instanceSettings.jsonData.exemplarTraceIdDestinations;
this.ruleMappings = {}; this.ruleMappings = {};
this.languageProvider = new PrometheusLanguageProvider(this); this.languageProvider = new PrometheusLanguageProvider(this);
this.lookupsDisabled = instanceSettings.jsonData.disableMetricsLookup ?? false; this.lookupsDisabled = instanceSettings.jsonData.disableMetricsLookup ?? false;
@@ -194,6 +198,17 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
rangeTarget.instant = false; rangeTarget.instant = false;
instantTarget.range = true; instantTarget.range = true;
// Create exemplar query
if (target.exemplar) {
const exemplarTarget = cloneDeep(target);
exemplarTarget.instant = false;
exemplarTarget.requestId += '_exemplar';
instantTarget.exemplar = false;
rangeTarget.exemplar = false;
queries.push(this.createQuery(exemplarTarget, options, start, end));
activeTargets.push(exemplarTarget);
}
// Add both targets to activeTargets and queries arrays // Add both targets to activeTargets and queries arrays
activeTargets.push(instantTarget, rangeTarget); activeTargets.push(instantTarget, rangeTarget);
queries.push( queries.push(
@@ -207,6 +222,13 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
queries.push(this.createQuery(instantTarget, options, start, end)); queries.push(this.createQuery(instantTarget, options, start, end));
activeTargets.push(instantTarget); activeTargets.push(instantTarget);
} else { } else {
if (target.exemplar) {
const exemplarTarget = cloneDeep(target);
exemplarTarget.requestId += '_exemplar';
target.exemplar = false;
queries.push(this.createQuery(exemplarTarget, options, start, end));
activeTargets.push(exemplarTarget);
}
queries.push(this.createQuery(target, options, start, end)); queries.push(this.createQuery(target, options, start, end));
activeTargets.push(target); activeTargets.push(target);
} }
@@ -251,7 +273,13 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
tap(() => runningQueriesCount--), tap(() => runningQueriesCount--),
filter((response: any) => (response.cancelled ? false : true)), filter((response: any) => (response.cancelled ? false : true)),
map((response: any) => { map((response: any) => {
const data = transform(response, { query, target, responseListLength: queries.length, mixedQueries }); const data = transform(response, {
query,
target,
responseListLength: queries.length,
mixedQueries,
exemplarTraceIdDestinations: this.exemplarTraceIdDestinations,
});
return { return {
data, data,
key: query.requestId, key: query.requestId,
@@ -264,6 +292,10 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
return this.performInstantQuery(query, end).pipe(filterAndMapResponse); return this.performInstantQuery(query, end).pipe(filterAndMapResponse);
} }
if (query.exemplar) {
return this.getExemplars(query).pipe(filterAndMapResponse);
}
return this.performTimeSeriesQuery(query, query.start, query.end).pipe(filterAndMapResponse); return this.performTimeSeriesQuery(query, query.start, query.end).pipe(filterAndMapResponse);
}); });
@@ -283,7 +315,13 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
const filterAndMapResponse = pipe( const filterAndMapResponse = pipe(
filter((response: any) => (response.cancelled ? false : true)), filter((response: any) => (response.cancelled ? false : true)),
map((response: any) => { map((response: any) => {
const data = transform(response, { query, target, responseListLength: queries.length, scopedVars }); const data = transform(response, {
query,
target,
responseListLength: queries.length,
scopedVars,
exemplarTraceIdDestinations: this.exemplarTraceIdDestinations,
});
return data; return data;
}) })
); );
@@ -292,6 +330,10 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
return this.performInstantQuery(query, end).pipe(filterAndMapResponse); return this.performInstantQuery(query, end).pipe(filterAndMapResponse);
} }
if (query.exemplar) {
return this.getExemplars(query).pipe(filterAndMapResponse);
}
return this.performTimeSeriesQuery(query, query.start, query.end).pipe(filterAndMapResponse); return this.performTimeSeriesQuery(query, query.start, query.end).pipe(filterAndMapResponse);
}); });
@@ -313,6 +355,7 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
const query: PromQueryRequest = { const query: PromQueryRequest = {
hinting: target.hinting, hinting: target.hinting,
instant: target.instant, instant: target.instant,
exemplar: target.exemplar,
step: 0, step: 0,
expr: '', expr: '',
requestId: target.requestId, requestId: target.requestId,
@@ -619,6 +662,15 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
return eventList; return eventList;
} }
getExemplars(query: PromQueryRequest) {
const url = '/api/v1/query_exemplar';
return this._request<PromDataSuccessResponse<PromExemplarData>>(
url,
{ query: query.expr, start: query.start.toString(), end: query.end.toString() },
{ requestId: query.requestId, headers: query.headers }
);
}
async getTagKeys() { async getTagKeys() {
const result = await this.metadataRequest('/api/v1/labels'); const result = await this.metadataRequest('/api/v1/labels');
return result?.data?.data?.map((value: any) => ({ text: value })) ?? []; return result?.data?.data?.map((value: any) => ({ text: value })) ?? [];

View File

@@ -1,6 +1,19 @@
import { DataFrame } from '@grafana/data'; import { DataFrame } from '@grafana/data';
import { transform } from './result_transformer'; import { transform } from './result_transformer';
jest.mock('@grafana/runtime', () => ({
getTemplateSrv: () => ({
replace: (str: string) => str,
}),
getDataSourceSrv: () => {
return {
getInstanceSettings: () => {
return { name: 'Tempo' };
},
};
},
}));
const matrixResponse = { const matrixResponse = {
status: 'success', status: 'success',
data: { data: {
@@ -439,4 +452,102 @@ describe('Prometheus Result Transformer', () => {
}); });
}); });
}); });
const exemplarsResponse = {
status: 'success',
data: [
{
seriesLabels: { __name__: 'test' },
exemplars: [
{
scrapeTimestamp: 1610449069957,
exemplar: {
labels: { traceID: '5020b5bc45117f07' },
value: 0.002074123,
timestamp: 1610449054960,
hasTimestamp: true,
},
},
],
},
],
};
describe('When the response is exemplar data', () => {
it('should return as an data frame with a dataTopic annotations', () => {
const result = transform({ data: exemplarsResponse } as any, options);
expect(result[0].meta?.dataTopic).toBe('annotations');
expect(result[0].fields.length).toBe(4); // __name__, traceID, Time, Value
expect(result[0].length).toBe(1);
});
it('should remove exemplars that are too close to each other', () => {
const response = {
status: 'success',
data: [
{
exemplars: [
{
scrapeTimestamp: 1610449070000,
exemplar: {
value: 5,
},
},
{
scrapeTimestamp: 1610449070000,
exemplar: {
value: 1,
},
},
{
scrapeTimestamp: 1610449070500,
exemplar: {
value: 13,
},
},
{
scrapeTimestamp: 1610449070300,
exemplar: {
value: 20,
},
},
],
},
],
};
/**
* the standard deviation for the above values is 8.4 this means that we show the highest
* value (20) and then the next value should be 2 times the standard deviation which is 1
**/
const result = transform({ data: response } as any, options);
expect(result[0].length).toBe(2);
});
describe('data link', () => {
it('should be added to the field if found with url', () => {
const result = transform({ data: exemplarsResponse } as any, {
...options,
exemplarTraceIdDestinations: [{ name: 'traceID', url: 'http://localhost' }],
});
expect(result[0].fields.some(f => f.config.links?.length)).toBe(true);
});
it('should be added to the field if found with internal link', () => {
const result = transform({ data: exemplarsResponse } as any, {
...options,
exemplarTraceIdDestinations: [{ name: 'traceID', datasourceUid: 'jaeger' }],
});
expect(result[0].fields.some(f => f.config.links?.length)).toBe(true);
});
it('should not add link if exemplarTraceIdDestinations is not configured', () => {
const result = transform({ data: exemplarsResponse } as any, options);
expect(result[0].fields.some(f => f.config.links?.length)).toBe(false);
});
});
});
}); });

View File

@@ -1,18 +1,24 @@
import { import {
ArrayDataFrame,
ArrayVector, ArrayVector,
DataFrame, DataFrame,
DataLink,
DataTopic,
Field, Field,
FieldType, FieldType,
formatLabels, formatLabels,
getDisplayProcessor,
Labels, Labels,
MutableField, MutableField,
ScopedVars, ScopedVars,
TIME_SERIES_TIME_FIELD_NAME, TIME_SERIES_TIME_FIELD_NAME,
TIME_SERIES_VALUE_FIELD_NAME, TIME_SERIES_VALUE_FIELD_NAME,
} from '@grafana/data'; } from '@grafana/data';
import { FetchResponse } from '@grafana/runtime'; import { FetchResponse, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime';
import { getTemplateSrv } from 'app/features/templating/template_srv'; import { descending, deviation } from 'd3';
import { import {
ExemplarTraceIdDestination,
isExemplarData,
isMatrixData, isMatrixData,
MatrixOrVectorResult, MatrixOrVectorResult,
PromDataSuccessResponse, PromDataSuccessResponse,
@@ -26,10 +32,16 @@ import {
const POSITIVE_INFINITY_SAMPLE_VALUE = '+Inf'; const POSITIVE_INFINITY_SAMPLE_VALUE = '+Inf';
const NEGATIVE_INFINITY_SAMPLE_VALUE = '-Inf'; const NEGATIVE_INFINITY_SAMPLE_VALUE = '-Inf';
interface TimeAndValue {
[TIME_SERIES_TIME_FIELD_NAME]: number;
[TIME_SERIES_VALUE_FIELD_NAME]: number;
}
export function transform( export function transform(
response: FetchResponse<PromDataSuccessResponse>, response: FetchResponse<PromDataSuccessResponse>,
transformOptions: { transformOptions: {
query: PromQueryRequest; query: PromQueryRequest;
exemplarTraceIdDestinations?: ExemplarTraceIdDestination[];
target: PromQuery; target: PromQuery;
responseListLength: number; responseListLength: number;
scopedVars?: ScopedVars; scopedVars?: ScopedVars;
@@ -61,7 +73,42 @@ export function transform(
}; };
const prometheusResult = response.data.data; const prometheusResult = response.data.data;
if (!prometheusResult.result) { if (isExemplarData(prometheusResult)) {
const events: TimeAndValue[] = [];
prometheusResult.forEach(exemplarData => {
const data = exemplarData.exemplars.map(exemplar => {
return {
[TIME_SERIES_TIME_FIELD_NAME]: exemplar.scrapeTimestamp,
[TIME_SERIES_VALUE_FIELD_NAME]: exemplar.exemplar.value,
...exemplar.exemplar.labels,
...exemplarData.seriesLabels,
};
});
events.push(...data);
});
// Grouping exemplars by step
const sampledExemplars = sampleExemplars(events, options);
const dataFrame = new ArrayDataFrame(sampledExemplars);
dataFrame.meta = { dataTopic: DataTopic.Annotations };
// Add data links if configured
if (transformOptions.exemplarTraceIdDestinations?.length) {
for (const exemplarTraceIdDestination of transformOptions.exemplarTraceIdDestinations) {
const traceIDField = dataFrame.fields.find(field => field.name === exemplarTraceIdDestination!.name);
if (traceIDField) {
const links = getDataLinks(exemplarTraceIdDestination);
traceIDField.config.links = traceIDField.config.links?.length
? [...traceIDField.config.links, ...links]
: links;
}
}
}
return [dataFrame];
}
if (!prometheusResult?.result) {
return []; return [];
} }
@@ -98,6 +145,86 @@ export function transform(
return dataFrame; return dataFrame;
} }
function getDataLinks(options: ExemplarTraceIdDestination): DataLink[] {
const dataLinks: DataLink[] = [];
if (options.datasourceUid) {
const dataSourceSrv = getDataSourceSrv();
const dsSettings = dataSourceSrv.getInstanceSettings(options.datasourceUid);
dataLinks.push({
title: `Query with ${dsSettings?.name}`,
url: '',
internal: {
query: { query: '${__value.raw}', queryType: 'getTrace' },
datasourceUid: options.datasourceUid,
datasourceName: dsSettings?.name ?? 'Data source not found',
},
});
}
if (options.url) {
dataLinks.push({
title: 'Open link',
url: options.url,
});
}
return dataLinks;
}
/**
* Reduce the density of the exemplars by making sure that the highest value exemplar is included
* and then only the ones that are 2 times the standard deviation of the all the values.
* This makes sure not to show too many dots near each other.
*/
function sampleExemplars(events: TimeAndValue[], options: TransformOptions) {
const step = options.step || 15;
const bucketedExemplars: { [ts: string]: TimeAndValue[] } = {};
const values: number[] = [];
for (const exemplar of events) {
// Align exemplar timestamp to nearest step second
const alignedTs = String(Math.floor(exemplar[TIME_SERIES_TIME_FIELD_NAME] / 1000 / step) * step * 1000);
if (!bucketedExemplars[alignedTs]) {
// New bucket found
bucketedExemplars[alignedTs] = [];
}
bucketedExemplars[alignedTs].push(exemplar);
values.push(exemplar[TIME_SERIES_VALUE_FIELD_NAME]);
}
// Getting exemplars from each bucket
const standardDeviation = deviation(values);
const sampledBuckets = Object.keys(bucketedExemplars).sort();
const sampledExemplars = [];
for (const ts of sampledBuckets) {
const exemplarsInBucket = bucketedExemplars[ts];
if (exemplarsInBucket.length === 1) {
sampledExemplars.push(exemplarsInBucket[0]);
} else {
// Choose which values to sample
const bucketValues = exemplarsInBucket.map(ex => ex[TIME_SERIES_VALUE_FIELD_NAME]).sort(descending);
const sampledBucketValues = bucketValues.reduce((acc: number[], curr) => {
if (acc.length === 0) {
// First value is max and is always added
acc.push(curr);
} else {
// Then take values only when at least 2 standard deviation distance to previously taken value
const prev = acc[acc.length - 1];
if (standardDeviation && prev - curr >= 2 * standardDeviation) {
acc.push(curr);
}
}
return acc;
}, []);
// Find the exemplars for the sampled values
sampledExemplars.push(
...sampledBucketValues.map(value => exemplarsInBucket.find(ex => ex[TIME_SERIES_VALUE_FIELD_NAME] === value)!)
);
}
}
return sampledExemplars;
}
function getPreferredVisualisationType(isInstantQuery?: boolean, mixedQueries?: boolean) { function getPreferredVisualisationType(isInstantQuery?: boolean, mixedQueries?: boolean) {
if (isInstantQuery) { if (isInstantQuery) {
return 'table'; return 'table';
@@ -237,6 +364,7 @@ function getValueField({
return { return {
name: valueName, name: valueName,
type: FieldType.number, type: FieldType.number,
display: getDisplayProcessor(),
config: { config: {
displayNameFromDS, displayNameFromDS,
}, },

View File

@@ -6,6 +6,7 @@ export interface PromQuery extends DataQuery {
format?: string; format?: string;
instant?: boolean; instant?: boolean;
range?: boolean; range?: boolean;
exemplar?: boolean;
hinting?: boolean; hinting?: boolean;
interval?: string; interval?: string;
intervalFactor?: number; intervalFactor?: number;
@@ -23,8 +24,15 @@ export interface PromOptions extends DataSourceJsonData {
directUrl: string; directUrl: string;
customQueryParameters?: string; customQueryParameters?: string;
disableMetricsLookup?: boolean; disableMetricsLookup?: boolean;
exemplarTraceIdDestinations?: ExemplarTraceIdDestination[];
} }
export type ExemplarTraceIdDestination = {
name: string;
url?: string;
datasourceUid?: string;
};
export interface PromQueryRequest extends PromQuery { export interface PromQueryRequest extends PromQuery {
step?: number; step?: number;
requestId?: string; requestId?: string;
@@ -55,7 +63,28 @@ export interface PromDataErrorResponse<T = PromData> {
data: T; data: T;
} }
export type PromData = PromMatrixData | PromVectorData | PromScalarData; export type PromData = PromMatrixData | PromVectorData | PromScalarData | PromExemplarData[] | null;
export interface Labels {
[index: string]: any;
}
export interface ScrapeExemplar {
exemplar: Exemplar;
scrapeTimestamp: number;
}
export interface Exemplar {
labels: Labels;
value: number;
timestamp: number;
hasTimestamp: boolean;
}
export interface PromExemplarData {
seriesLabels: PromMetric;
exemplars: ScrapeExemplar[];
}
export interface PromVectorData { export interface PromVectorData {
resultType: 'vector'; resultType: 'vector';
@@ -93,6 +122,13 @@ export function isMatrixData(result: MatrixOrVectorResult): result is PromMatrix
return 'values' in result; return 'values' in result;
} }
export function isExemplarData(result: PromData): result is PromExemplarData[] {
if (result == null || !Array.isArray(result)) {
return false;
}
return 'exemplars' in result[0];
}
export type MatrixOrVectorResult = PromMatrixData['result'][0] | PromVectorData['result'][0]; export type MatrixOrVectorResult = PromMatrixData['result'][0] | PromVectorData['result'][0];
export interface TransformOptions { export interface TransformOptions {

View File

@@ -1,12 +1,13 @@
import { Field, PanelProps } from '@grafana/data';
import { GraphNG, GraphNGLegendEvent, TooltipPlugin, ZoomPlugin } from '@grafana/ui';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { TooltipPlugin, ZoomPlugin, GraphNG, GraphNGLegendEvent } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import { Options } from './types';
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
import { hideSeriesConfigFactory } from './overrides/hideSeriesConfigFactory';
import { changeSeriesColorConfigFactory } from './overrides/colorSeriesConfigFactory'; import { changeSeriesColorConfigFactory } from './overrides/colorSeriesConfigFactory';
import { hideSeriesConfigFactory } from './overrides/hideSeriesConfigFactory';
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
import { Options } from './types';
interface TimeSeriesPanelProps extends PanelProps<Options> {} interface TimeSeriesPanelProps extends PanelProps<Options> {}
@@ -29,6 +30,10 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
[fieldConfig, onFieldConfigChange, data.series] [fieldConfig, onFieldConfigChange, data.series]
); );
const getFieldLinks = (field: Field, rowIndex: number) => {
return getFieldLinksForExplore({ field, rowIndex, range: timeRange });
};
const onSeriesColorChange = useCallback( const onSeriesColorChange = useCallback(
(label: string, color: string) => { (label: string, color: string) => {
onFieldConfigChange(changeSeriesColorConfigFactory(label, color, fieldConfig)); onFieldConfigChange(changeSeriesColorConfigFactory(label, color, fieldConfig));
@@ -47,10 +52,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
onLegendClick={onLegendClick} onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange} onSeriesColorChange={onSeriesColorChange}
> >
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} /> <TooltipPlugin mode={options.tooltipOptions.mode} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} /> <ZoomPlugin onZoom={onChangeTimeRange} />
<ContextMenuPlugin timeZone={timeZone} replaceVariables={replaceVariables} /> <ContextMenuPlugin timeZone={timeZone} replaceVariables={replaceVariables} />
{data.annotations ? <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} /> : <></>} {data.annotations ? (
<ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />
) : (
<></>
)}
{data.annotations ? <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} /> : <></>} {data.annotations ? <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} /> : <></>}
</GraphNG> </GraphNG>
); );

View File

@@ -92,7 +92,9 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
}, []); }, []);
const mapAnnotationToXYCoords = useCallback( const mapAnnotationToXYCoords = useCallback(
(annotation: AnnotationsDataFrameViewDTO) => { (frame: DataFrame, index: number) => {
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
const annotation = view.get(index);
const plotInstance = plotCtx.getPlotInstance(); const plotInstance = plotCtx.getPlotInstance();
if (!annotation.time || !plotInstance) { if (!annotation.time || !plotInstance) {
return undefined; return undefined;
@@ -107,14 +109,16 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
); );
const renderMarker = useCallback( const renderMarker = useCallback(
(annotation: AnnotationsDataFrameViewDTO) => { (frame: DataFrame, index: number) => {
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
const annotation = view.get(index);
return <AnnotationMarker time={timeFormatter(annotation.time)} text={annotation.text} tags={annotation.tags} />; return <AnnotationMarker time={timeFormatter(annotation.time)} text={annotation.text} tags={annotation.tags} />;
}, },
[timeFormatter] [timeFormatter]
); );
return ( return (
<EventsCanvas<AnnotationsDataFrameViewDTO> <EventsCanvas
id="annotations" id="annotations"
events={annotations} events={annotations}
renderEventMarker={renderMarker} renderEventMarker={renderMarker}

View File

@@ -1,21 +1,41 @@
import React, { useCallback, useRef, useState } from 'react'; import {
import { GrafanaTheme } from '@grafana/data'; DataFrame,
import { HorizontalGroup, Portal, Tag, TooltipContainer, useStyles } from '@grafana/ui'; dateTimeFormat,
Field,
FieldType,
GrafanaTheme,
LinkModel,
systemDateFormats,
TimeZone,
} from '@grafana/data';
import { FieldLink, Portal, TooltipContainer, useStyles } from '@grafana/ui';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import React, { useCallback, useRef, useState } from 'react';
interface ExemplarMarkerProps { interface ExemplarMarkerProps {
time: string; timeZone: TimeZone;
text: string; dataFrame: DataFrame;
tags: string[]; index: number;
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
} }
export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ time, text, tags }) => { export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ timeZone, dataFrame, index, getFieldLinks }) => {
const styles = useStyles(getExemplarMarkerStyles); const styles = useStyles(getExemplarMarkerStyles);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const markerRef = useRef<HTMLDivElement>(null); const markerRef = useRef<HTMLDivElement>(null);
const annotationPopoverRef = useRef<HTMLDivElement>(null); const annotationPopoverRef = useRef<HTMLDivElement>(null);
const popoverRenderTimeout = useRef<NodeJS.Timer>(); const popoverRenderTimeout = useRef<NodeJS.Timer>();
const timeFormatter = useCallback(
(value: number) => {
return dateTimeFormat(value, {
format: systemDateFormats.fullDate,
timeZone,
});
},
[timeZone]
);
const onMouseEnter = useCallback(() => { const onMouseEnter = useCallback(() => {
if (popoverRenderTimeout.current) { if (popoverRenderTimeout.current) {
clearTimeout(popoverRenderTimeout.current); clearTimeout(popoverRenderTimeout.current);
@@ -47,23 +67,40 @@ export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ time, text, tags
> >
<div ref={annotationPopoverRef} className={styles.wrapper}> <div ref={annotationPopoverRef} className={styles.wrapper}>
<div className={styles.header}> <div className={styles.header}>
{/*<span className={styles.title}>{exemplar.title}</span>*/} <span className={styles.title}>Exemplar</span>
{time && <span className={styles.time}>{time}</span>}
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{text && <div dangerouslySetInnerHTML={{ __html: text }} />} <div>
<> <table className={styles.exemplarsTable}>
<HorizontalGroup spacing="xs" wrap> <tbody>
{tags?.map((t, i) => ( {dataFrame.fields.map((field, i) => {
<Tag name={t} key={`${t}-${i}`} /> const value = field.values.get(index);
))} const links = field.config.links?.length ? getFieldLinks(field, index) : undefined;
</HorizontalGroup> return (
</> <tr key={i}>
<td>{field.name}</td>
<td className={styles.valueWrapper}>
{field.type === FieldType.time ? timeFormatter(value) : value}{' '}
{links &&
links.map((link, i) => {
return (
<div key={i} className={styles.link}>
<FieldLink link={link} />
</div>
);
})}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</TooltipContainer> </TooltipContainer>
); );
}, [time, tags, text]); }, [dataFrame.fields, getFieldLinks, index, onMouseEnter, onMouseLeave, styles, timeFormatter]);
return ( return (
<> <>
@@ -83,6 +120,7 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
const shadowColor = theme.isDark ? theme.palette.black : theme.palette.white; const shadowColor = theme.isDark ? theme.palette.black : theme.palette.white;
const marbleFill = theme.isDark ? theme.palette.gray3 : theme.palette.gray1; const marbleFill = theme.isDark ? theme.palette.gray3 : theme.palette.gray1;
const marbleFillHover = theme.isDark ? theme.palette.blue85 : theme.palette.blue77; const marbleFillHover = theme.isDark ? theme.palette.blue85 : theme.palette.blue77;
const tableBgOdd = theme.isDark ? theme.palette.dark3 : theme.palette.gray6;
const marble = css` const marble = css`
display: block; display: block;
@@ -120,10 +158,29 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
background: ${bg}; background: ${bg};
border: 1px solid ${headerBg}; border: 1px solid ${headerBg};
border-radius: ${theme.border.radius.md}; border-radius: ${theme.border.radius.md};
max-width: 400px;
box-shadow: 0 0 20px ${shadowColor}; box-shadow: 0 0 20px ${shadowColor};
`, `,
exemplarsTable: css`
width: 100%;
tr td {
padding: 5px 10px;
white-space: nowrap;
border-bottom: 4px solid ${theme.colors.panelBg};
}
tr {
background-color: ${theme.colors.bg1};
&:nth-child(even) {
background-color: ${tableBgOdd};
}
}
`,
valueWrapper: css`
display: flex;
flex-direction: row;
align-items: center;
`,
tooltip: css` tooltip: css`
background: none; background: none;
padding: 0; padding: 0;
@@ -142,18 +199,13 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
text-overflow: ellipsis; text-overflow: ellipsis;
flex-grow: 1; flex-grow: 1;
`, `,
time: css`
color: ${theme.colors.textWeak};
font-style: italic;
font-weight: normal;
display: inline-block;
position: relative;
top: 1px;
`,
body: css` body: css`
padding: ${theme.spacing.sm}; padding: ${theme.spacing.sm};
font-weight: ${theme.typography.weight.semibold}; font-weight: ${theme.typography.weight.semibold};
`, `,
link: css`
margin: 0 ${theme.spacing.sm};
`,
marble, marble,
activeMarble, activeMarble,
}; };

View File

@@ -1,96 +1,70 @@
import React, { useCallback, useEffect, useState } from 'react';
import { import {
ArrayVector,
DataFrame, DataFrame,
dateTimeFormat, Field,
FieldType, LinkModel,
MutableDataFrame,
systemDateFormats,
TimeZone, TimeZone,
TIME_SERIES_TIME_FIELD_NAME,
TIME_SERIES_VALUE_FIELD_NAME,
} from '@grafana/data'; } from '@grafana/data';
import { EventsCanvas, usePlotContext } from '@grafana/ui'; import { EventsCanvas, FIXED_UNIT, usePlotContext } from '@grafana/ui';
import React, { useCallback } from 'react';
import { ExemplarMarker } from './ExemplarMarker'; import { ExemplarMarker } from './ExemplarMarker';
interface ExemplarsPluginProps { interface ExemplarsPluginProps {
exemplars: DataFrame[]; exemplars: DataFrame[];
timeZone: TimeZone; timeZone: TimeZone;
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
} }
// Type representing exemplars data frame fields export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone, getFieldLinks }) => {
interface ExemplarsDataFrameViewDTO {
time: number;
y: number;
text: string;
tags: string[];
}
export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone }) => {
const plotCtx = usePlotContext(); const plotCtx = usePlotContext();
// TEMPORARY MOCK
const [exemplarsMock, setExemplarsMock] = useState<DataFrame[]>([]);
const timeFormatter = useCallback(
(value: number) => {
return dateTimeFormat(value, {
format: systemDateFormats.fullDate,
timeZone,
});
},
[timeZone]
);
// THIS EVENT ONLY MOCKS EXEMPLAR Y VALUE!!!! TO BE REMOVED WHEN WE GET CORRECT EXEMPLARS SHAPE VIA PROPS
useEffect(() => {
if (plotCtx.isPlotReady) {
const mocks: DataFrame[] = [];
for (const frame of exemplars) {
const mock = new MutableDataFrame(frame);
mock.addField({
name: 'y',
type: FieldType.number,
values: new ArrayVector(
Array(frame.length)
.fill(0)
.map(() => Math.random())
),
});
mocks.push(mock);
}
setExemplarsMock(mocks);
}
}, [plotCtx.isPlotReady, exemplars]);
const mapExemplarToXYCoords = useCallback( const mapExemplarToXYCoords = useCallback(
(exemplar: ExemplarsDataFrameViewDTO) => { (dataFrame: DataFrame, index: number) => {
const plotInstance = plotCtx.getPlotInstance(); const plotInstance = plotCtx.getPlotInstance();
const time = dataFrame.fields.find(f => f.name === TIME_SERIES_TIME_FIELD_NAME);
const value = dataFrame.fields.find(f => f.name === TIME_SERIES_VALUE_FIELD_NAME);
if (!exemplar.time || !plotCtx.isPlotReady || !plotInstance) { if (!time || !value || !plotCtx.isPlotReady || !plotInstance) {
return undefined; return undefined;
} }
// Filter x, y scales out
const yScale =
Object.keys(plotInstance.scales).find(scale => !['x', 'y'].some(key => key === scale)) ?? FIXED_UNIT;
const yMin = plotInstance.scales[yScale].min;
const yMax = plotInstance.scales[yScale].max;
let y = value.values.get(index);
// To not to show exemplars outside of the graph we set the y value to min if it is smaller and max if it is bigger than the size of the graph
if (yMin != null && y < yMin) {
y = yMin;
}
if (yMax != null && y > yMax) {
y = yMax;
}
return { return {
x: plotInstance.valToPos(exemplar.time, 'x'), x: plotInstance.valToPos(time.values.get(index), 'x'),
// exemplar.y is a temporary mock for an examplar. This Needs to be calculated according to examplar scale! y: plotInstance.valToPos(y, yScale),
y: Math.floor((exemplar.y * plotInstance.bbox.height) / window.devicePixelRatio),
}; };
}, },
[plotCtx.isPlotReady, plotCtx.getPlotInstance] [plotCtx]
); );
const renderMarker = useCallback( const renderMarker = useCallback(
(exemplar: ExemplarsDataFrameViewDTO) => { (dataFrame: DataFrame, index: number) => {
return <ExemplarMarker time={timeFormatter(exemplar.time)} text={exemplar.text} tags={exemplar.tags} />; return <ExemplarMarker timeZone={timeZone} getFieldLinks={getFieldLinks} dataFrame={dataFrame} index={index} />;
}, },
[timeFormatter] [timeZone, getFieldLinks]
); );
return ( return (
<EventsCanvas<ExemplarsDataFrameViewDTO> <EventsCanvas
id="exemplars" id="exemplars"
events={exemplarsMock} events={exemplars}
renderEventMarker={renderMarker} renderEventMarker={renderMarker}
mapEventToXYCoords={mapExemplarToXYCoords} mapEventToXYCoords={mapExemplarToXYCoords}
/> />

View File

@@ -6,7 +6,6 @@ import {
DataQueryRequest, DataQueryRequest,
DataSourceApi, DataSourceApi,
ExploreUrlState, ExploreUrlState,
GraphSeriesXY,
HistoryItem, HistoryItem,
LogLevel, LogLevel,
LogsDedupStrategy, LogsDedupStrategy,
@@ -69,7 +68,7 @@ export interface ExploreItemState {
/** /**
* List of timeseries to be shown in the Explore graph result viewer. * List of timeseries to be shown in the Explore graph result viewer.
*/ */
graphResult: GraphSeriesXY[] | null; graphResult: DataFrame[] | null;
/** /**
* History of recent queries. Datasource-specific and initialized via localStorage. * History of recent queries. Datasource-specific and initialized via localStorage.
*/ */
@@ -216,7 +215,7 @@ export interface ExplorePanelData extends PanelData {
tableFrames: DataFrame[]; tableFrames: DataFrame[];
logsFrames: DataFrame[]; logsFrames: DataFrame[];
traceFrames: DataFrame[]; traceFrames: DataFrame[];
graphResult: GraphSeriesXY[] | null; graphResult: DataFrame[] | null;
tableResult: DataFrame | null; tableResult: DataFrame | null;
logsResult: LogsModel | null; logsResult: LogsModel | null;
} }