mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
29
packages/grafana-ui/src/components/Logs/FieldLink.tsx
Normal file
29
packages/grafana-ui/src/components/Logs/FieldLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
165
public/app/features/explore/ExploreGraphNGPanel.tsx
Normal file
165
public/app/features/explore/ExploreGraphNGPanel.tsx
Normal 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};
|
||||||
|
`,
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 })) ?? [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user