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');
|
||||
canvases.should('have.length', 2);
|
||||
canvases.should('have.length', 1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
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
|
||||
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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ const dummyProps: ExploreProps = {
|
||||
syncedTimes: false,
|
||||
updateTimeRange: jest.fn(),
|
||||
graphResult: [],
|
||||
loading: false,
|
||||
absoluteRange: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ export const PromExploreQueryEditor: FC<Props> = (props: Props) => {
|
||||
onQueryTypeChange={onQueryTypeChange}
|
||||
onStepChange={onStepChange}
|
||||
onKeyDownFunc={onReturnKeyDown}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })) ?? [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user