diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 6027b566764..c075eb8bbd4 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -599,6 +599,7 @@ export { type PluginExtensionResourceAttributesContext, type CentralAlertHistorySceneV1Props, } from './types/pluginExtensions'; +export { type PrometheusQueryResultsV1Props } from './types/exposedComponentProps'; export { type ScopeDashboardBindingSpec, type ScopeDashboardBindingStatus, diff --git a/packages/grafana-data/src/types/exposedComponentProps.ts b/packages/grafana-data/src/types/exposedComponentProps.ts new file mode 100644 index 00000000000..5b2c0a8f6ba --- /dev/null +++ b/packages/grafana-data/src/types/exposedComponentProps.ts @@ -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; +}; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index cc2a97b4a50..5c919b48a5e 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -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 = { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 9aa7bc1904b..76044e38f97 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -50,8 +50,8 @@ import { LogsSamplePanel } from './Logs/LogsSamplePanel'; import { NoData } from './NoData'; import { NoDataSourceCallToAction } from './NoDataSourceCallToAction'; import { NodeGraphContainer } from './NodeGraph/NodeGraphContainer'; +import RawPrometheusContainer from './PrometheusQueryResults/RawPrometheusContainer'; import { QueryRows } from './QueryRows'; -import RawPrometheusContainer from './RawPrometheus/RawPrometheusContainer'; import { ResponseErrorContainer } from './ResponseErrorContainer'; import { SecondaryActions } from './SecondaryActions'; import TableContainer from './Table/TableContainer'; diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.test.tsx b/public/app/features/explore/PrometheusQueryResults/PrometheusQueryResultsContainer.test.tsx similarity index 54% rename from public/app/features/explore/RawPrometheus/RawPrometheusContainer.test.tsx rename to public/app/features/explore/PrometheusQueryResults/PrometheusQueryResultsContainer.test.tsx index c1f0ed2aad2..d7de1750270 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.test.tsx +++ b/public/app/features/explore/PrometheusQueryResults/PrometheusQueryResultsContainer.test.tsx @@ -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(); + it('should render table with data and toggle when showRawPrometheus is true', async () => { + render(); + + // 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(); + + // 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(); + + // Wait for lazy-loaded component to render + await waitFor(() => { + expect(screen.getByText('0 series returned')).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/explore/PrometheusQueryResults/PrometheusQueryResultsContainer.tsx b/public/app/features/explore/PrometheusQueryResults/PrometheusQueryResultsContainer.tsx new file mode 100644 index 00000000000..33fa349a78e --- /dev/null +++ b/public/app/features/explore/PrometheusQueryResults/PrometheusQueryResultsContainer.tsx @@ -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 + * + * ``` + */ +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 ( + + + + ); +}; diff --git a/public/app/features/explore/PrometheusQueryResults/RawPrometheusContainer.tsx b/public/app/features/explore/PrometheusQueryResults/RawPrometheusContainer.tsx new file mode 100644 index 00000000000..3898160e5e8 --- /dev/null +++ b/public/app/features/explore/PrometheusQueryResults/RawPrometheusContainer.tsx @@ -0,0 +1,83 @@ +import { memo, useMemo } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { DataFrame, SplitOpen } from '@grafana/data'; +import { TimeZone } from '@grafana/schema'; +import { AdHocFilterItem } from '@grafana/ui'; +import { ExploreItemState } from 'app/types/explore'; +import { StoreState } from 'app/types/store'; + +import { exploreDataLinkPostProcessorFactory } from '../utils/links'; + +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; +} + +function mapStateToProps(state: StoreState, { exploreId }: ExploreRawPrometheusContainerProps) { + const explore = state.explore; + const item: ExploreItemState = explore.panes[exploreId]!; + const { rawPrometheusResult, range, queryResponse } = item; + const rawPrometheusFrame: DataFrame[] = rawPrometheusResult ? [rawPrometheusResult] : []; + const loading = queryResponse.state; + + return { loading, tableResult: rawPrometheusFrame, range }; +} + +const connector = connect(mapStateToProps, {}); + +type ExploreProps = ExploreRawPrometheusContainerProps & ConnectedProps; + +/** + * Redux-connected wrapper for Explore. + * Gets data from Redux and passes to PrometheusQueryResultsContainer for processing and display. + */ +const ExploreRawPrometheusContainer = memo( + ({ + loading, + onCellFilterAdded, + tableResult, + width, + ariaLabel, + timeZone, + showRawPrometheus, + range, + splitOpenFn, + }: ExploreProps) => { + const dataLinkPostProcessor = useMemo( + () => exploreDataLinkPostProcessorFactory(splitOpenFn, range), + [splitOpenFn, range] + ); + + return ( + + ); + } +); + +ExploreRawPrometheusContainer.displayName = 'ExploreRawPrometheusContainer'; + +// Keep the old export name for backwards compatibility +export const RawPrometheusContainer = ExploreRawPrometheusContainer; + +export default connector(ExploreRawPrometheusContainer); diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx b/public/app/features/explore/PrometheusQueryResults/RawPrometheusContainerPure.tsx similarity index 59% rename from public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx rename to public/app/features/explore/PrometheusQueryResults/RawPrometheusContainerPure.tsx index ebd005f595b..f94f88077e3 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx +++ b/public/app/features/explore/PrometheusQueryResults/RawPrometheusContainerPure.tsx @@ -1,56 +1,58 @@ import { css } from '@emotion/css'; import { memo, useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; -import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data'; -import { getTemplateSrv, reportInteraction } from '@grafana/runtime'; -import { TimeZone } from '@grafana/schema'; -import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui'; +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 { ExploreItemState, TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore'; -import { StoreState } from 'app/types/store'; +import { TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore'; import { MetaInfoText } from '../MetaInfoText'; import RawListContainer from '../PrometheusListView/RawListContainer'; -import { exploreDataLinkPostProcessorFactory } from '../utils/links'; -interface RawPrometheusContainerProps { - ariaLabel?: string; - exploreId: string; +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; - timeZone: TimeZone; - onCellFilterAdded?: (filter: AdHocFilterItem) => void; + /** 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; - splitOpenFn: SplitOpen; + /** Callback when user adds a cell filter */ + onCellFilterAdded?: (filter: AdHocFilterItem) => void; } -function mapStateToProps(state: StoreState, { exploreId }: RawPrometheusContainerProps) { - const explore = state.explore; - const item: ExploreItemState = explore.panes[exploreId]!; - const { rawPrometheusResult, range, queryResponse } = item; - const rawPrometheusFrame: DataFrame[] = rawPrometheusResult ? [rawPrometheusResult] : []; - const loading = queryResponse.state; - - return { loading, tableResult: rawPrometheusFrame, range }; -} - -const connector = connect(mapStateToProps, {}); - -type Props = RawPrometheusContainerProps & ConnectedProps; - -export const RawPrometheusContainer = memo( +/** + * 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, - splitOpenFn, - range, ariaLabel, - timeZone, showRawPrometheus, - }: Props) => { + }: 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( showRawPrometheus ? TABLE_RESULTS_STYLE.raw : undefined @@ -70,11 +72,6 @@ export const RawPrometheusContainer = memo( }; const renderLabel = () => { - const spacing = css({ - display: 'flex', - justifyContent: 'space-between', - flex: '1', - }); const ALL_GRAPH_STYLE_OPTIONS: Array> = TABLE_RESULTS_STYLES.map((style) => ({ value: style, // capital-case it and switch `_` to ` ` @@ -82,7 +79,7 @@ export const RawPrometheusContainer = memo( })); return ( -
+
{ const props = { @@ -102,25 +99,7 @@ export const RawPrometheusContainer = memo( 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( + const frames = tableResult?.filter( (frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0 ); @@ -152,6 +131,4 @@ export const RawPrometheusContainer = memo( } ); -RawPrometheusContainer.displayName = 'RawPrometheusContainer'; - -export default connector(RawPrometheusContainer); +RawPrometheusContainerPure.displayName = 'RawPrometheusContainerPure'; diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts index bb25c487dc3..efcce575eff 100644 --- a/public/app/features/plugins/extensions/registry/setup.ts +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -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/PrometheusQueryResults/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, + }, ], });