Compare commits

...

2 Commits

Author SHA1 Message Date
Nic Westvold cd7e052723 Explore: Rename PrometheusQueryResults directory back to RawPrometheus
Reverts the directory rename to fix the codeowners-validator CI check.
The CODEOWNERS file references /public/app/features/explore/RawPrometheus/
which must match the actual directory path.
2026-01-09 13:46:31 -05:00
Nic Westvold b1057b3cbf Explore: Expose Prometheus query results component for plugins
Add grafana/prometheus-query-results/v1 exposed component to allow
plugins to display Prometheus instant query results with Table/Raw toggle.
2026-01-08 15:03:18 -05:00
8 changed files with 315 additions and 126 deletions
+1
View File
@@ -599,6 +599,7 @@ export {
type PluginExtensionResourceAttributesContext,
type CentralAlertHistorySceneV1Props,
} from './types/pluginExtensions';
export { type PrometheusQueryResultsV1Props } from './types/exposedComponentProps';
export {
type ScopeDashboardBindingSpec,
type ScopeDashboardBindingStatus,
@@ -0,0 +1,27 @@
import { LoadingState } from './data';
import { DataFrame } from './dataFrame';
import { DataLinkPostProcessor } from './fieldOverrides';
import { TimeZone } from './time';
/**
* Props for the PrometheusQueryResults exposed component.
* @see PluginExtensionExposedComponents.PrometheusQueryResultsV1
*/
export type PrometheusQueryResultsV1Props = {
/** Raw DataFrames to display (processing handled internally). Defaults to empty array. */
tableResult?: DataFrame[];
/** Width of the container in pixels. Defaults to 800. */
width?: number;
/** Timezone for value formatting. Defaults to 'browser'. */
timeZone?: TimeZone;
/** Loading state for panel chrome indicator */
loading?: LoadingState;
/** Aria label for accessibility */
ariaLabel?: string;
/** Start in Raw view instead of Table view. When true, shows toggle. */
showRawPrometheus?: boolean;
/** Callback when user adds a cell filter */
onCellFilterAdded?: (filter: { key: string; value: string; operator: '=' | '!=' }) => void;
/** Optional post-processor for data links (used by Explore for split view) */
dataLinkPostProcessor?: DataLinkPostProcessor;
};
@@ -245,6 +245,7 @@ export enum PluginExtensionPointPatterns {
export enum PluginExtensionExposedComponents {
CentralAlertHistorySceneV1 = 'grafana/central-alert-history-scene/v1',
AddToDashboardFormV1 = 'grafana/add-to-dashboard-form/v1',
PrometheusQueryResultsV1 = 'grafana/prometheus-query-results/v1',
}
export type PluginExtensionPanelContext = {
@@ -1,10 +1,9 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame, LoadingState } from '@grafana/data';
import { FieldType, InternalTimeZones, toDataFrame, LoadingState } from '@grafana/data';
import { getTemplateSrv } from 'app/features/templating/template_srv';
import { TABLE_RESULTS_STYLE } from 'app/types/explore';
import { RawPrometheusContainer } from './RawPrometheusContainer';
import { PrometheusQueryResultsContainer } from './PrometheusQueryResultsContainer';
function getTable(): HTMLElement {
return screen.getAllByRole('table')[0];
@@ -52,27 +51,30 @@ const dataFrame = toDataFrame({
});
const defaultProps = {
exploreId: 'left',
loading: LoadingState.NotStarted,
width: 800,
onCellFilterAdded: jest.fn(),
tableResult: [dataFrame],
splitOpenFn: () => {},
range: getDefaultTimeRange(),
timeZone: InternalTimeZones.utc,
resultsStyle: TABLE_RESULTS_STYLE.raw,
showRawPrometheus: false,
};
describe('RawPrometheusContainer', () => {
describe('PrometheusQueryResultsContainer', () => {
beforeAll(() => {
getTemplateSrv();
});
it('should render component for prometheus', () => {
render(<RawPrometheusContainer {...defaultProps} showRawPrometheus={true} />);
it('should render table with data and toggle when showRawPrometheus is true', async () => {
render(<PrometheusQueryResultsContainer {...defaultProps} showRawPrometheus={true} />);
// Wait for lazy-loaded component to render
await waitFor(() => {
expect(screen.queryAllByRole('table').length).toBe(1);
});
// Toggle should be visible
expect(screen.queryAllByRole('radio').length).toBeGreaterThan(0);
expect(screen.queryAllByRole('table').length).toBe(1);
fireEvent.click(getTableToggle());
expect(getTable()).toBeInTheDocument();
@@ -85,4 +87,25 @@ describe('RawPrometheusContainer', () => {
{ time: '2021-01-01 02:00:00', text: 'test_string_4' },
]);
});
it('should render table without toggle when showRawPrometheus is false', async () => {
render(<PrometheusQueryResultsContainer {...defaultProps} showRawPrometheus={false} />);
// Wait for lazy-loaded component to render
await waitFor(() => {
expect(screen.queryAllByRole('table').length).toBe(1);
});
// Toggle should NOT be visible
expect(screen.queryAllByRole('radio').length).toBe(0);
});
it('should render empty state when no data', async () => {
render(<PrometheusQueryResultsContainer {...defaultProps} tableResult={[]} showRawPrometheus={true} />);
// Wait for lazy-loaded component to render
await waitFor(() => {
expect(screen.getByText('0 series returned')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,70 @@
import { cloneDeep } from 'lodash';
import { lazy, Suspense, useMemo } from 'react';
import { applyFieldOverrides, PrometheusQueryResultsV1Props } from '@grafana/data';
import { config, getTemplateSrv } from '@grafana/runtime';
const RawPrometheusContainerPureLazy = lazy(() =>
import('./RawPrometheusContainerPure').then((m) => ({ default: m.RawPrometheusContainerPure }))
);
/**
* EXPOSED COMPONENT (stable): grafana/prometheus-query-results/v1
*
* This component is exposed to plugins via the Plugin Extensions system.
* Treat its props and user-visible behavior as a stable contract. Do not make
* breaking changes in-place. If you need to change the API or behavior in a
* breaking way, create a new versioned component (e.g. PrometheusQueryResultsV2)
* and register it under a new ID: "grafana/prometheus-query-results/v2".
*
* Displays Prometheus query results with Table/Raw toggle.
* Pass raw DataFrames - processing (applyFieldOverrides) is handled internally.
*
* Example usage in a plugin:
* ```typescript
* import { usePluginComponent } from '@grafana/runtime';
* import { PluginExtensionExposedComponents } from '@grafana/data';
*
* const { component: PrometheusQueryResults } = usePluginComponent(
* PluginExtensionExposedComponents.PrometheusQueryResultsV1
* );
*
* // Render - just pass raw data
* <PrometheusQueryResults tableResult={rawDataFrames} width={800} timeZone="browser" />
* ```
*/
export const PrometheusQueryResultsContainer = (props: PrometheusQueryResultsV1Props) => {
const width = props.width ?? 800;
const timeZone = props.timeZone ?? 'browser';
// Memoize cloneDeep + applyFieldOverrides to avoid expensive operations on every render
// cloneDeep is needed to avoid mutating frozen props from plugin extension system
const processedData = useMemo(() => {
const tableResult = props.tableResult ?? [];
const cloned = cloneDeep(tableResult);
if (cloned?.length) {
return applyFieldOverrides({
data: cloned,
timeZone,
theme: config.theme2,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
fieldConfig: { defaults: {}, overrides: [] },
dataLinkPostProcessor: props.dataLinkPostProcessor,
});
}
return cloned;
}, [props.tableResult, timeZone, props.dataLinkPostProcessor]);
return (
<Suspense fallback={null}>
<RawPrometheusContainerPureLazy
tableResult={processedData}
width={width}
loading={props.loading}
ariaLabel={props.ariaLabel}
showRawPrometheus={props.showRawPrometheus}
onCellFilterAdded={props.onCellFilterAdded}
/>
</Suspense>
);
};
@@ -1,31 +1,31 @@
import { css } from '@emotion/css';
import { memo, useState } from 'react';
import { memo, useMemo } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data';
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { DataFrame, SplitOpen } from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { ExploreItemState, TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { AdHocFilterItem } from '@grafana/ui';
import { ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types/store';
import { MetaInfoText } from '../MetaInfoText';
import RawListContainer from '../PrometheusListView/RawListContainer';
import { exploreDataLinkPostProcessorFactory } from '../utils/links';
interface RawPrometheusContainerProps {
import { PrometheusQueryResultsContainer } from './PrometheusQueryResultsContainer';
// ============================================================================
// Redux-connected Component - Used by Explore
// ============================================================================
interface ExploreRawPrometheusContainerProps {
ariaLabel?: string;
exploreId: string;
width: number;
timeZone: TimeZone;
onCellFilterAdded?: (filter: AdHocFilterItem) => void;
showRawPrometheus?: boolean;
splitOpenFn: SplitOpen;
splitOpenFn?: SplitOpen;
}
function mapStateToProps(state: StoreState, { exploreId }: RawPrometheusContainerProps) {
function mapStateToProps(state: StoreState, { exploreId }: ExploreRawPrometheusContainerProps) {
const explore = state.explore;
const item: ExploreItemState = explore.panes[exploreId]!;
const { rawPrometheusResult, range, queryResponse } = item;
@@ -37,121 +37,47 @@ function mapStateToProps(state: StoreState, { exploreId }: RawPrometheusContaine
const connector = connect(mapStateToProps, {});
type Props = RawPrometheusContainerProps & ConnectedProps<typeof connector>;
type ExploreProps = ExploreRawPrometheusContainerProps & ConnectedProps<typeof connector>;
export const RawPrometheusContainer = memo(
/**
* Redux-connected wrapper for Explore.
* Gets data from Redux and passes to PrometheusQueryResultsContainer for processing and display.
*/
const ExploreRawPrometheusContainer = memo(
({
loading,
onCellFilterAdded,
tableResult,
width,
splitOpenFn,
range,
ariaLabel,
timeZone,
showRawPrometheus,
}: Props) => {
// If resultsStyle is undefined we won't render the toggle, and the default table will be rendered
const [resultsStyle, setResultsStyle] = useState<TableResultsStyle | undefined>(
showRawPrometheus ? TABLE_RESULTS_STYLE.raw : undefined
range,
splitOpenFn,
}: ExploreProps) => {
const dataLinkPostProcessor = useMemo(
() => exploreDataLinkPostProcessorFactory(splitOpenFn, range),
[splitOpenFn, range]
);
const onChangeResultsStyle = (newResultsStyle: TableResultsStyle) => {
setResultsStyle(newResultsStyle);
};
const getTableHeight = () => {
if (!tableResult || tableResult.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, tableResult[0].length * 35) + 35);
};
const renderLabel = () => {
const spacing = css({
display: 'flex',
justifyContent: 'space-between',
flex: '1',
});
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<TableResultsStyle>> = TABLE_RESULTS_STYLES.map((style) => ({
value: style,
// capital-case it and switch `_` to ` `
label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '),
}));
return (
<div className={spacing}>
<RadioButtonGroup
onClick={() => {
const props = {
state: resultsStyle === TABLE_RESULTS_STYLE.table ? TABLE_RESULTS_STYLE.raw : TABLE_RESULTS_STYLE.table,
};
reportInteraction('grafana_explore_prometheus_instant_query_ui_toggle_clicked', props);
}}
size="sm"
options={ALL_GRAPH_STYLE_OPTIONS}
value={resultsStyle}
onChange={onChangeResultsStyle}
/>
</div>
);
};
const height = getTableHeight();
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
let dataFrames = tableResult;
const dataLinkPostProcessor = exploreDataLinkPostProcessorFactory(splitOpenFn, range);
if (dataFrames?.length) {
dataFrames = applyFieldOverrides({
data: dataFrames,
timeZone,
theme: config.theme2,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
fieldConfig: {
defaults: {},
overrides: [],
},
dataLinkPostProcessor,
});
}
const frames = dataFrames?.filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
const title = resultsStyle === TABLE_RESULTS_STYLE.raw ? 'Raw' : 'Table';
const label = resultsStyle !== undefined ? renderLabel() : 'Table';
// Render table as default if resultsStyle is not set.
const renderTable = !resultsStyle || resultsStyle === TABLE_RESULTS_STYLE.table;
return (
<PanelChrome title={title} actions={label} loadingState={loading}>
{frames?.length && (
<>
{renderTable && (
<Table
ariaLabel={ariaLabel}
data={frames[0]}
width={tableWidth}
height={height}
onCellFilterAdded={onCellFilterAdded}
/>
)}
{resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={frames[0]} />}
</>
)}
{!frames?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</PanelChrome>
<PrometheusQueryResultsContainer
tableResult={tableResult}
width={width}
timeZone={timeZone}
loading={loading}
ariaLabel={ariaLabel}
showRawPrometheus={showRawPrometheus}
onCellFilterAdded={onCellFilterAdded}
dataLinkPostProcessor={dataLinkPostProcessor}
/>
);
}
);
RawPrometheusContainer.displayName = 'RawPrometheusContainer';
ExploreRawPrometheusContainer.displayName = 'ExploreRawPrometheusContainer';
export default connector(RawPrometheusContainer);
// Keep the old export name for backwards compatibility
export const RawPrometheusContainer = ExploreRawPrometheusContainer;
export default connector(ExploreRawPrometheusContainer);
@@ -0,0 +1,134 @@
import { css } from '@emotion/css';
import { memo, useState } from 'react';
import { DataFrame, GrafanaTheme2, LoadingState, SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome, useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { MetaInfoText } from '../MetaInfoText';
import RawListContainer from '../PrometheusListView/RawListContainer';
const getStyles = (_theme: GrafanaTheme2) => ({
spacing: css({
display: 'flex',
justifyContent: 'space-between',
flex: '1',
}),
});
/**
* Props for the pure RawPrometheusContainer component.
* This component expects pre-processed DataFrames (caller should apply applyFieldOverrides).
*/
export interface RawPrometheusContainerPureProps {
/** Pre-processed DataFrames to display */
tableResult: DataFrame[];
/** Width of the container in pixels */
width: number;
/** Loading state for panel chrome indicator */
loading?: LoadingState;
/** Aria label for accessibility */
ariaLabel?: string;
/** Start in Raw view instead of Table view. When true, shows toggle. When false/undefined, shows table only. */
showRawPrometheus?: boolean;
/** Callback when user adds a cell filter */
onCellFilterAdded?: (filter: AdHocFilterItem) => void;
}
/**
* Pure component for displaying Prometheus query results with Table/Raw toggle.
* This component does NOT connect to Redux and expects pre-processed data.
*/
export const RawPrometheusContainerPure = memo(
({
loading,
onCellFilterAdded,
tableResult,
width,
ariaLabel,
showRawPrometheus,
}: RawPrometheusContainerPureProps) => {
const styles = useStyles2(getStyles);
// If resultsStyle is undefined we won't render the toggle, and the default table will be rendered
const [resultsStyle, setResultsStyle] = useState<TableResultsStyle | undefined>(
showRawPrometheus ? TABLE_RESULTS_STYLE.raw : undefined
);
const onChangeResultsStyle = (newResultsStyle: TableResultsStyle) => {
setResultsStyle(newResultsStyle);
};
const getTableHeight = () => {
if (!tableResult || tableResult.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, tableResult[0].length * 35) + 35);
};
const renderLabel = () => {
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<TableResultsStyle>> = TABLE_RESULTS_STYLES.map((style) => ({
value: style,
// capital-case it and switch `_` to ` `
label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '),
}));
return (
<div className={styles.spacing}>
<RadioButtonGroup
onClick={() => {
const props = {
state: resultsStyle === TABLE_RESULTS_STYLE.table ? TABLE_RESULTS_STYLE.raw : TABLE_RESULTS_STYLE.table,
};
reportInteraction('grafana_explore_prometheus_instant_query_ui_toggle_clicked', props);
}}
size="sm"
options={ALL_GRAPH_STYLE_OPTIONS}
value={resultsStyle}
onChange={onChangeResultsStyle}
/>
</div>
);
};
const height = getTableHeight();
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
const frames = tableResult?.filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
const title = resultsStyle === TABLE_RESULTS_STYLE.raw ? 'Raw' : 'Table';
const label = resultsStyle !== undefined ? renderLabel() : 'Table';
// Render table as default if resultsStyle is not set.
const renderTable = !resultsStyle || resultsStyle === TABLE_RESULTS_STYLE.table;
return (
<PanelChrome title={title} actions={label} loadingState={loading}>
{frames?.length && (
<>
{renderTable && (
<Table
ariaLabel={ariaLabel}
data={frames[0]}
width={tableWidth}
height={height}
onCellFilterAdded={onCellFilterAdded}
/>
)}
{resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={frames[0]} />}
</>
)}
{!frames?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</PanelChrome>
);
}
);
RawPrometheusContainerPure.displayName = 'RawPrometheusContainerPure';
@@ -1,6 +1,7 @@
import { PluginExtensionExposedComponents } from '@grafana/data';
import CentralAlertHistorySceneExposedComponent from 'app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistorySceneExposedComponent';
import { AddToDashboardFormExposedComponent } from 'app/features/dashboard-scene/addToDashboard/AddToDashboardFormExposedComponent';
import { PrometheusQueryResultsContainer } from 'app/features/explore/RawPrometheus/PrometheusQueryResultsContainer';
import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations';
@@ -43,5 +44,11 @@ exposedComponentsRegistry.register({
description: 'Add to dashboard form',
component: AddToDashboardFormExposedComponent,
},
{
id: PluginExtensionExposedComponents.PrometheusQueryResultsV1,
title: 'Prometheus query results',
description: 'Display Prometheus query results with Table/Raw toggle',
component: PrometheusQueryResultsContainer,
},
],
});