diff --git a/e2e/suite1/specs/explore.spec.ts b/e2e/suite1/specs/explore.spec.ts index 8524e96991c..a4f67690e1f 100644 --- a/e2e/suite1/specs/explore.spec.ts +++ b/e2e/suite1/specs/explore.spec.ts @@ -25,6 +25,6 @@ e2e.scenario({ }); const canvases = e2e().get('canvas'); - canvases.should('have.length', 2); + canvases.should('have.length', 1); }, }); diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 4fc49935059..661048d7068 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -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; diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 957907a092b..bad07dafd26 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -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), diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index ebace9dae6a..0faa74f01e4 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -45,6 +45,8 @@ const defaultConfig: GraphFieldConfig = { axisPlacement: AxisPlacement.Auto, }; +export const FIXED_UNIT = '__fixed'; + export const GraphNG: React.FC = ({ data, fields, @@ -88,7 +90,7 @@ export const GraphNG: React.FC = ({ [onLegendClick, data] ); - // reference change will not triger re-render + // reference change will not trigger re-render const currentTimeRange = useRef(timeRange); useLayoutEffect(() => { @@ -104,7 +106,7 @@ export const GraphNG: React.FC = ({ 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 = ({ } 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); diff --git a/packages/grafana-ui/src/components/Logs/FieldLink.tsx b/packages/grafana-ui/src/components/Logs/FieldLink.tsx new file mode 100644 index 00000000000..a0fe50f2f7e --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/FieldLink.tsx @@ -0,0 +1,29 @@ +import { Field, LinkModel } from '@grafana/data'; +import React from 'react'; +import { Button } from '..'; + +type FieldLinkProps = { + link: LinkModel; +}; + +export function FieldLink({ link }: FieldLinkProps) { + return ( + { + if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) { + event.preventDefault(); + link.onClick(event); + } + } + : undefined + } + > + + + ); +} diff --git a/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx b/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx index 7b2112c9761..04dbb2300b4 100644 --- a/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx +++ b/packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx @@ -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 { } } -const getLinkStyles = stylesFactory(() => { - return { - tag: css` - margin-left: 6px; - font-size: 11px; - padding: 2px 6px; - `, - }; -}); - -type FieldLinkProps = { - link: LinkModel; -}; -function FieldLink({ link }: FieldLinkProps) { - const styles = getLinkStyles(); - return ( - { - if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) { - event.preventDefault(); - link.onClick(event); - } - } - : undefined - } - > - - - ); -} - export const LogDetailsRow = withTheme(UnThemedLogDetailsRow); LogDetailsRow.displayName = 'LogDetailsRow'; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 7749be6e914..d7b457bdad5 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx index cc247362a5a..d45d5cf6ba1 100644 --- a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx +++ b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx @@ -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 { +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({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps) { +export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps) { const plotCtx = usePlotContext(); const renderToken = useRefreshAfterGraphRendered(id); @@ -23,17 +23,15 @@ export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoo } for (let i = 0; i < events.length; i++) { - const view = new DataFrameView(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( - {renderEventMarker(event)} + {renderEventMarker(frame, j)} ); } diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 0467c7e7cf5..ecf3c834b68 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -55,7 +55,6 @@ const dummyProps: ExploreProps = { syncedTimes: false, updateTimeRange: jest.fn(), graphResult: [], - loading: false, absoluteRange: { from: 0, to: 0, diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 1d21deeddc8..d03bb3ab11f 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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 { split, queryKeys, graphResult, - loading, absoluteRange, timeZone, queryResponse, @@ -311,6 +309,7 @@ export class Explore extends React.PureComponent { 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 { )} {!showStartPage && ( <> - {showMetrics && ( - )} {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, diff --git a/public/app/features/explore/ExploreGraphNGPanel.tsx b/public/app/features/explore/ExploreGraphNGPanel.tsx new file mode 100644 index 00000000000..6765b63e8d2 --- /dev/null +++ b/public/app/features/explore/ExploreGraphNGPanel.tsx @@ -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({ + 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 && ( +
+ + {`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `} + setShowAllTimeSeries(true)} + >{`Show all ${dataWithConfig.length}`} +
+ )} + + + + + + + {annotations ? ( + + ) : ( + <> + )} + + + + ); +} + +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}; + `, +}); diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index 6dab51b6e96..073e456e801 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -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', () => { diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index 7195b88766a..019858c5203 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -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 }; }; /** diff --git a/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx b/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx new file mode 100644 index 00000000000..6e43b313018 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx @@ -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) => { + const exemplar = e.target.checked; + onChange({ ...query, exemplar }); +}; + +export function PromExemplarField(props: Props) { + return ( + + + + ); +} diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx index a0dc88def26..e00df58884a 100644 --- a/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx @@ -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, diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx index fccf7150c84..91025032261 100644 --- a/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx @@ -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) => void; onKeyDownFunc: (e: React.KeyboardEvent) => void; onQueryTypeChange: (value: string) => void; + onChange: (value: PromQuery) => void; } export const PromExploreExtraField: React.FC = 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 = memo( value={stepValue} /> + + ); } diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx index e94ecedda65..49de300022c 100644 --- a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx @@ -63,6 +63,8 @@ export const PromExploreQueryEditor: FC = (props: Props) => { onQueryTypeChange={onQueryTypeChange} onStepChange={onStepChange} onKeyDownFunc={onReturnKeyDown} + query={query} + onChange={onChange} /> } /> diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx index 3b7739e558f..a38882957dc 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx @@ -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 { }; 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 { /> + + ); diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap index 62c64f76a8c..7baccbbc1a7 100644 --- a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap @@ -4,9 +4,17 @@ exports[`PromExploreQueryEditor should render component 1`] = ` diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap index 8ba30330cf8..409a763a822 100644 --- a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap @@ -181,6 +181,15 @@ exports[`Render PromQueryEditor with basic options should render 1`] = ` /> + `; diff --git a/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx b/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx new file mode 100644 index 00000000000..0297bfd792d --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx @@ -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 ( +
+ + <> + setIsInternalLink(ev.currentTarget.checked)} /> +
+ ); +} diff --git a/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx new file mode 100644 index 00000000000..07baf039251 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx @@ -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 ( + <> +

Exemplars

+ + {options && + options.map((option, index) => { + return ( + { + const newOptions = [...options]; + newOptions.splice(index, 1, newField); + onChange(newOptions); + }} + onDelete={() => { + const newOptions = [...options]; + newOptions.splice(index, 1); + onChange(newOptions); + }} + /> + ); + })} + + + + ); +} diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx index 3a850d3e8b7..9966241d27d 100644 --- a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx @@ -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) => { + + updateDatasourcePluginJsonDataOption( + { onOptionsChange, options }, + 'exemplarTraceIdDestinations', + exemplarOptions + ) + } + /> ); }; diff --git a/public/app/plugins/datasource/prometheus/datasource.test.ts b/public/app/plugins/datasource/prometheus/datasource.test.ts index 9d01a3b8169..89a262ca391 100644 --- a/public/app/plugins/datasource/prometheus/datasource.test.ts +++ b/public/app/plugins/datasource/prometheus/datasource.test.ts @@ -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); }); }); }); diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 26e0e2fefbd..c53808ad2f6 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -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 queryTimeout: string; httpMethod: string; languageProvider: PrometheusLanguageProvider; + exemplarTraceIdDestinations: ExemplarTraceIdDestination[] | undefined; lookupsDisabled: boolean; customQueryParameters: any; @@ -75,6 +78,7 @@ export class PrometheusDatasource extends DataSourceApi 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 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 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 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 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 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 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 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 return eventList; } + getExemplars(query: PromQueryRequest) { + const url = '/api/v1/query_exemplar'; + return this._request>( + 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 })) ?? []; diff --git a/public/app/plugins/datasource/prometheus/result_transformer.test.ts b/public/app/plugins/datasource/prometheus/result_transformer.test.ts index 8fa8d3b1ef2..fb1ff7673b9 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.test.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.test.ts @@ -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); + }); + }); + }); }); diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index a9a65cd89a1..27307a66a7b 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -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, 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, }, diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts index 8ee3eb449b9..331418d2d95 100644 --- a/public/app/plugins/datasource/prometheus/types.ts +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -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 { 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 { diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 7076c6b772e..eed8d82ea49 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -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 {} @@ -29,6 +30,10 @@ export const TimeSeriesPanel: React.FC = ({ [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 = ({ onLegendClick={onLegendClick} onSeriesColorChange={onSeriesColorChange} > - + - {data.annotations ? : <>} + {data.annotations ? ( + + ) : ( + <> + )} {data.annotations ? : <>} ); diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx index 813f5ed67cd..49769442e4f 100644 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx @@ -92,7 +92,9 @@ export const AnnotationsPlugin: React.FC = ({ annotation }, []); const mapAnnotationToXYCoords = useCallback( - (annotation: AnnotationsDataFrameViewDTO) => { + (frame: DataFrame, index: number) => { + const view = new DataFrameView(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 = ({ annotation ); const renderMarker = useCallback( - (annotation: AnnotationsDataFrameViewDTO) => { + (frame: DataFrame, index: number) => { + const view = new DataFrameView(frame); + const annotation = view.get(index); return ; }, [timeFormatter] ); return ( - + Array>; } -export const ExemplarMarker: React.FC = ({ time, text, tags }) => { +export const ExemplarMarker: React.FC = ({ timeZone, dataFrame, index, getFieldLinks }) => { const styles = useStyles(getExemplarMarkerStyles); const [isOpen, setIsOpen] = useState(false); const markerRef = useRef(null); const annotationPopoverRef = useRef(null); const popoverRenderTimeout = useRef(); + 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 = ({ time, text, tags >
- {/*{exemplar.title}*/} - {time && {time}} + Exemplar
- {text &&
} - <> - - {tags?.map((t, i) => ( - - ))} - - +
+ + + {dataFrame.fields.map((field, i) => { + const value = field.values.get(index); + const links = field.config.links?.length ? getFieldLinks(field, index) : undefined; + return ( + + + + + ); + })} + +
{field.name} + {field.type === FieldType.time ? timeFormatter(value) : value}{' '} + {links && + links.map((link, i) => { + return ( +
+ +
+ ); + })} +
+
); - }, [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, }; diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx index 54b74e2c03d..859eec582fc 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx @@ -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>; } -// Type representing exemplars data frame fields -interface ExemplarsDataFrameViewDTO { - time: number; - y: number; - text: string; - tags: string[]; -} - -export const ExemplarsPlugin: React.FC = ({ exemplars, timeZone }) => { +export const ExemplarsPlugin: React.FC = ({ exemplars, timeZone, getFieldLinks }) => { const plotCtx = usePlotContext(); - // TEMPORARY MOCK - const [exemplarsMock, setExemplarsMock] = useState([]); - - 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 ; + (dataFrame: DataFrame, index: number) => { + return ; }, - [timeFormatter] + [timeZone, getFieldLinks] ); return ( - + diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 2d918b5d2a0..e8a148dbe53 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -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; }