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');
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[];
/**
* 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
*/
@@ -431,7 +431,7 @@ export interface DataQuery {
queryType?: string;
/**
* The data topic resuls should be attached to
* The data topic results should be attached to
*/
dataTopic?: DataTopic;

View File

@@ -44,7 +44,7 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod
const title = link.title ? link.title : internalLink.datasourceName;
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
// to explore but this way you can open it in new tab.
href: generateInternalHref(internalLink.datasourceName, interpolatedQuery, range),

View File

@@ -45,6 +45,8 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Auto,
};
export const FIXED_UNIT = '__fixed';
export const GraphNG: React.FC<GraphNGProps> = ({
data,
fields,
@@ -88,7 +90,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
[onLegendClick, data]
);
// reference change will not triger re-render
// reference change will not trigger re-render
const currentTimeRange = useRef<TimeRange>(timeRange);
useLayoutEffect(() => {
@@ -104,7 +106,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
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];
if (xField.type === FieldType.time) {
builder.addScale({
@@ -147,7 +149,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
}
const fmt = field.display ?? defaultFormatter;
const scaleKey = config.unit || '__fixed';
const scaleKey = config.unit || FIXED_UNIT;
const colorMode = getFieldColorModeForField(field);
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
import { LogLabelStats } from './LogLabelStats';
import { IconButton } from '../IconButton/IconButton';
import { Tag } from '..';
import { FieldLink } from './FieldLink';
export interface Props extends Themeable {
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);
LogDetailsRow.displayName = 'LogDetailsRow';

View File

@@ -87,6 +87,7 @@ export { LogLabels } from './Logs/LogLabels';
export { LogMessageAnsi } from './Logs/LogMessageAnsi';
export { LogRows } from './Logs/LogRows';
export { getLogRowStyles } from './Logs/getLogRowStyles';
export { FieldLink } from './Logs/FieldLink';
export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup';
// Panel editors
export { FullWidthButtonContainer } from './Button/FullWidthButtonContainer';
@@ -204,5 +205,5 @@ export * from './uPlot/geometries';
export * from './uPlot/plugins';
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
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';

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ import {
DataQuery,
DataSourceApi,
GrafanaTheme,
GraphSeriesXY,
LoadingState,
PanelData,
RawTimeRange,
@@ -20,6 +19,7 @@ import {
TimeZone,
ExploreUrlState,
LogsModel,
DataFrame,
EventBusExtended,
EventBusSrv,
TraceViewData,
@@ -49,11 +49,11 @@ import { ExploreToolbar } from './ExploreToolbar';
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
import { getTimeZone } from '../profile/state/selectors';
import { ErrorContainer } from './ErrorContainer';
import { ExploreGraphPanel } from './ExploreGraphPanel';
//TODO:unification
import { TraceView } from './TraceView/TraceView';
import { SecondaryActions } from './SecondaryActions';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types';
import { ExploreGraphNGPanel } from './ExploreGraphNGPanel';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
@@ -100,11 +100,10 @@ export interface ExploreProps {
isLive: boolean;
syncedTimes: boolean;
updateTimeRange: typeof updateTimeRange;
graphResult?: GraphSeriesXY[] | null;
graphResult: DataFrame[] | null;
logsResult?: LogsModel;
loading?: boolean;
absoluteRange: AbsoluteTimeRange;
timeZone?: TimeZone;
timeZone: TimeZone;
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
queryResponse: PanelData;
originPanelId: number;
@@ -293,7 +292,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
split,
queryKeys,
graphResult,
loading,
absoluteRange,
timeZone,
queryResponse,
@@ -311,6 +309,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const styles = getStyles(theme);
const StartPage = datasourceInstance?.components?.ExploreStartPage;
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
const queryErrors = queryResponse.error ? [queryResponse.error] : undefined;
@@ -360,19 +359,16 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
)}
{!showStartPage && (
<>
{showMetrics && (
<ExploreGraphPanel
ariaLabel={selectors.pages.Explore.General.graph}
series={graphResult}
{showMetrics && graphResult && (
<ExploreGraphNGPanel
data={graphResult}
width={width}
loading={loading}
absoluteRange={absoluteRange}
isStacked={false}
showPanel={true}
timeZone={timeZone}
onUpdateTimeRange={this.onUpdateTimeRange}
showBars={false}
showLines={true}
annotations={queryResponse.annotations}
splitOpenFn={splitOpen}
isLoading={isLoading}
/>
)}
{showTable && (
@@ -456,7 +452,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
showMetrics,
showTable,
showTrace,
loading,
absoluteRange,
queryResponse,
} = item;
@@ -479,9 +474,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
initialQueries,
initialRange,
isLive,
graphResult: graphResult ?? undefined,
graphResult,
logsResult: logsResult ?? undefined,
loading,
absoluteRange,
queryResponse,
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', () => {
const { timeSeries } = getTestContext();
const panelData = createExplorePanelData({ graphFrames: [timeSeries] });
console.log(decorateWithGraphResult(panelData).graphResult);
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,
},
]);
expect(decorateWithGraphResult(panelData).graphResult).toMatchObject([timeSeries]);
});
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 {
AbsoluteTimeRange,
DataFrame,
@@ -11,12 +9,11 @@ import {
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { groupBy } from 'lodash';
import { ExplorePanelData } from '../../../types';
import { getGraphSeriesModel } from '../flotgraph/getGraphSeriesModel';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { dataFrameToLogsModel } from '../../../core/logs_model';
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
@@ -80,22 +77,11 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane
};
export const decorateWithGraphResult = (data: ExplorePanelData): ExplorePanelData => {
if (data.error) {
if (data.error || !data.graphFrames.length) {
return { ...data, graphResult: null };
}
const graphResult =
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 };
return { ...data, graphResult: data.graphFrames };
};
/**

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 queryType = 'range';
const stepValue = '1';
const query = { exemplar: false };
const onStepChange = jest.fn();
const onQueryTypeChange = jest.fn();
const onKeyDownFunc = jest.fn();
@@ -12,6 +13,7 @@ const setup = (propOverrides?: PromExploreExtraFieldProps) => {
const props: any = {
queryType,
stepValue,
query,
onStepChange,
onQueryTypeChange,
onKeyDownFunc,

View File

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

View File

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

View File

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

View File

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

View File

@@ -181,6 +181,15 @@ exports[`Render PromQueryEditor with basic options should render 1`] = `
/>
</FormLabel>
</div>
<PromExemplarField
onChange={[MockFunction]}
query={
Object {
"expr": "",
"refId": "A",
}
}
/>
</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 {
SelectableValue,
onUpdateDatasourceJsonDataOptionChecked,
DataSourcePluginOptionsEditorProps,
onUpdateDatasourceJsonDataOptionChecked,
SelectableValue,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data';
import { EventsWithValidation, InlineFormLabel, LegacyForms, regexValidation } from '@grafana/ui';
import React, { SyntheticEvent } from 'react';
import { PromOptions } from '../types';
import { ExemplarsSettings } from './ExemplarsSettings';
const { Select, Input, FormField, Switch } = LegacyForms;
const httpOptions = [
{ value: 'GET', label: 'GET' },
@@ -104,6 +106,16 @@ export const PromSettings = (props: Props) => {
</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', () => {
it('returns empty array when no queries', done => {
expect.assertions(2);
ds.query(createDataRequest([])).subscribe({
next(next) {
expect(next.data).toEqual([]);
expect(next.state).toBe(LoadingState.Done);
},
complete() {
done();
},
it('returns empty array when no queries', async () => {
await expect(ds.query(createDataRequest([]))).toEmitValuesWith(response => {
expect(response[0].data).toEqual([]);
expect(response[0].state).toBe(LoadingState.Done);
});
});
it('performs time series queries', done => {
expect.assertions(2);
ds.query(createDataRequest([{}])).subscribe({
next(next) {
expect(next.data.length).not.toBe(0);
expect(next.state).toBe(LoadingState.Done);
},
complete() {
done();
},
it('performs time series queries', async () => {
await expect(ds.query(createDataRequest([{}]))).toEmitValuesWith(response => {
expect(response[0].data.length).not.toBe(0);
expect(response[0].state).toBe(LoadingState.Done);
});
});
it('with 2 queries and used from Explore, sends results as they arrive', done => {
expect.assertions(4);
const responseStatus = [LoadingState.Loading, LoadingState.Done];
ds.query(createDataRequest([{}, {}], { app: CoreApp.Explore })).subscribe({
next(next) {
expect(next.data.length).not.toBe(0);
expect(next.state).toBe(responseStatus.shift());
},
complete() {
done();
},
it('with 2 queries and used from Explore, sends results as they arrive', async () => {
await expect(ds.query(createDataRequest([{}, {}], { app: CoreApp.Explore }))).toEmitValuesWith(response => {
expect(response[0].data.length).not.toBe(0);
expect(response[0].state).toBe(LoadingState.Loading);
expect(response[1].state).toBe(LoadingState.Done);
});
});
it('with 2 queries and used from Panel, waits for all to finish until sending Done status', done => {
expect.assertions(2);
ds.query(createDataRequest([{}, {}], { app: CoreApp.Dashboard })).subscribe({
next(next) {
expect(next.data.length).not.toBe(0);
expect(next.state).toBe(LoadingState.Done);
},
complete() {
done();
},
it('with 2 queries and used from Panel, waits for all to finish until sending Done status', async () => {
await expect(ds.query(createDataRequest([{}, {}], { app: CoreApp.Dashboard }))).toEmitValuesWith(response => {
expect(response[0].data.length).not.toBe(0);
expect(response[0].state).toBe(LoadingState.Done);
});
});
});
@@ -228,7 +201,7 @@ describe('PrometheusDatasource', () => {
};
});
it('should convert cumullative histogram to ordinary', () => {
it('should convert cumulative histogram to ordinary', async () => {
const resultMock = [
{
metric: { __name__: 'metric', job: 'testjob', le: '10' },
@@ -254,38 +227,16 @@ describe('PrometheusDatasource', () => {
];
const responseMock = { data: { data: { result: resultMock } } };
const expected = [
{
target: '10',
datapoints: [
[10, 1443454528000],
[10, 1443454528000],
],
},
{
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);
ds.performTimeSeriesQuery = jest.fn().mockReturnValue(of(responseMock));
await expect(ds.query(query)).toEmitValuesWith(result => {
const results = result[0].data;
expect(results[0].fields[1].values.toArray()).toEqual([10, 10]);
expect(results[1].fields[1].values.toArray()).toEqual([10, 0]);
expect(results[2].fields[1].values.toArray()).toEqual([5, 0]);
});
});
it('should sort series by label value', () => {
it('should sort series by label value', async () => {
const resultMock = [
{
metric: { __name__: 'metric', job: 'testjob', le: '2' },
@@ -320,10 +271,10 @@ describe('PrometheusDatasource', () => {
const expected = ['1', '2', '4', '+Inf'];
ds.performTimeSeriesQuery = jest.fn().mockReturnValue(of([responseMock]));
ds.query(query).subscribe((result: any) => {
const seriesLabels = _.map(result.data, 'target');
return expect(seriesLabels).toEqual(expected);
ds.performTimeSeriesQuery = jest.fn().mockReturnValue(of(responseMock));
await expect(ds.query(query)).toEmitValuesWith(result => {
const seriesLabels = _.map(result[0].data, 'name');
expect(seriesLabels).toEqual(expected);
});
});
});

View File

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

View File

@@ -1,6 +1,19 @@
import { DataFrame } from '@grafana/data';
import { transform } from './result_transformer';
jest.mock('@grafana/runtime', () => ({
getTemplateSrv: () => ({
replace: (str: string) => str,
}),
getDataSourceSrv: () => {
return {
getInstanceSettings: () => {
return { name: 'Tempo' };
},
};
},
}));
const matrixResponse = {
status: 'success',
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 {
ArrayDataFrame,
ArrayVector,
DataFrame,
DataLink,
DataTopic,
Field,
FieldType,
formatLabels,
getDisplayProcessor,
Labels,
MutableField,
ScopedVars,
TIME_SERIES_TIME_FIELD_NAME,
TIME_SERIES_VALUE_FIELD_NAME,
} from '@grafana/data';
import { FetchResponse } from '@grafana/runtime';
import { getTemplateSrv } from 'app/features/templating/template_srv';
import { FetchResponse, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime';
import { descending, deviation } from 'd3';
import {
ExemplarTraceIdDestination,
isExemplarData,
isMatrixData,
MatrixOrVectorResult,
PromDataSuccessResponse,
@@ -26,10 +32,16 @@ import {
const POSITIVE_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(
response: FetchResponse<PromDataSuccessResponse>,
transformOptions: {
query: PromQueryRequest;
exemplarTraceIdDestinations?: ExemplarTraceIdDestination[];
target: PromQuery;
responseListLength: number;
scopedVars?: ScopedVars;
@@ -61,7 +73,42 @@ export function transform(
};
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 [];
}
@@ -98,6 +145,86 @@ export function transform(
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) {
if (isInstantQuery) {
return 'table';
@@ -237,6 +364,7 @@ function getValueField({
return {
name: valueName,
type: FieldType.number,
display: getDisplayProcessor(),
config: {
displayNameFromDS,
},

View File

@@ -6,6 +6,7 @@ export interface PromQuery extends DataQuery {
format?: string;
instant?: boolean;
range?: boolean;
exemplar?: boolean;
hinting?: boolean;
interval?: string;
intervalFactor?: number;
@@ -23,8 +24,15 @@ export interface PromOptions extends DataSourceJsonData {
directUrl: string;
customQueryParameters?: string;
disableMetricsLookup?: boolean;
exemplarTraceIdDestinations?: ExemplarTraceIdDestination[];
}
export type ExemplarTraceIdDestination = {
name: string;
url?: string;
datasourceUid?: string;
};
export interface PromQueryRequest extends PromQuery {
step?: number;
requestId?: string;
@@ -55,7 +63,28 @@ export interface PromDataErrorResponse<T = PromData> {
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 {
resultType: 'vector';
@@ -93,6 +122,13 @@ export function isMatrixData(result: MatrixOrVectorResult): result is PromMatrix
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 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 { 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 { 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> {}
@@ -29,6 +30,10 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
[fieldConfig, onFieldConfigChange, data.series]
);
const getFieldLinks = (field: Field, rowIndex: number) => {
return getFieldLinksForExplore({ field, rowIndex, range: timeRange });
};
const onSeriesColorChange = useCallback(
(label: string, color: string) => {
onFieldConfigChange(changeSeriesColorConfigFactory(label, color, fieldConfig));
@@ -47,10 +52,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange}
>
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<TooltipPlugin mode={options.tooltipOptions.mode} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} />
<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} /> : <></>}
</GraphNG>
);

View File

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

View File

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

View File

@@ -1,96 +1,70 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ArrayVector,
DataFrame,
dateTimeFormat,
FieldType,
MutableDataFrame,
systemDateFormats,
Field,
LinkModel,
TimeZone,
TIME_SERIES_TIME_FIELD_NAME,
TIME_SERIES_VALUE_FIELD_NAME,
} 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';
interface ExemplarsPluginProps {
exemplars: DataFrame[];
timeZone: TimeZone;
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
}
// Type representing exemplars data frame fields
interface ExemplarsDataFrameViewDTO {
time: number;
y: number;
text: string;
tags: string[];
}
export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone }) => {
export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone, getFieldLinks }) => {
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(
(exemplar: ExemplarsDataFrameViewDTO) => {
(dataFrame: DataFrame, index: number) => {
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;
}
// 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 {
x: plotInstance.valToPos(exemplar.time, 'x'),
// exemplar.y is a temporary mock for an examplar. This Needs to be calculated according to examplar scale!
y: Math.floor((exemplar.y * plotInstance.bbox.height) / window.devicePixelRatio),
x: plotInstance.valToPos(time.values.get(index), 'x'),
y: plotInstance.valToPos(y, yScale),
};
},
[plotCtx.isPlotReady, plotCtx.getPlotInstance]
[plotCtx]
);
const renderMarker = useCallback(
(exemplar: ExemplarsDataFrameViewDTO) => {
return <ExemplarMarker time={timeFormatter(exemplar.time)} text={exemplar.text} tags={exemplar.tags} />;
(dataFrame: DataFrame, index: number) => {
return <ExemplarMarker timeZone={timeZone} getFieldLinks={getFieldLinks} dataFrame={dataFrame} index={index} />;
},
[timeFormatter]
[timeZone, getFieldLinks]
);
return (
<EventsCanvas<ExemplarsDataFrameViewDTO>
<EventsCanvas
id="exemplars"
events={exemplarsMock}
events={exemplars}
renderEventMarker={renderMarker}
mapEventToXYCoords={mapExemplarToXYCoords}
/>

View File

@@ -6,7 +6,6 @@ import {
DataQueryRequest,
DataSourceApi,
ExploreUrlState,
GraphSeriesXY,
HistoryItem,
LogLevel,
LogsDedupStrategy,
@@ -69,7 +68,7 @@ export interface ExploreItemState {
/**
* 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.
*/
@@ -216,7 +215,7 @@ export interface ExplorePanelData extends PanelData {
tableFrames: DataFrame[];
logsFrames: DataFrame[];
traceFrames: DataFrame[];
graphResult: GraphSeriesXY[] | null;
graphResult: DataFrame[] | null;
tableResult: DataFrame | null;
logsResult: LogsModel | null;
}