From a112dad973b37dc8c15c91b3ca0eace114f83626 Mon Sep 17 00:00:00 2001 From: Gangadhar Chalapaka Date: Sat, 15 Aug 2026 00:42:30 -0700 Subject: [PATCH] [FEATURE] Add a query playground to the Query Viewer Queries in the Query Viewer dialog are now editable and can be run on the fly against a live preview of the panel. All edits are local to the dialog and dropped when it closes, so nothing is ever persisted. Part of perses/perses#4333 Signed-off-by: Gangadhar Chalapaka --- .../components/GridLayout/GridItemContent.tsx | 1 + .../QueryViewerDialog/QueryPlayground.tsx | 141 ++++++++++++++++++ .../QueryViewerDialog.test.tsx | 116 ++++++++++++++ .../QueryViewerDialog/QueryViewerDialog.tsx | 21 ++- .../src/components/QueryViewerDialog/index.ts | 1 + dashboards/src/test/plugin-registry.tsx | 27 +++- 6 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 dashboards/src/components/QueryViewerDialog/QueryPlayground.tsx create mode 100644 dashboards/src/components/QueryViewerDialog/QueryViewerDialog.test.tsx diff --git a/dashboards/src/components/GridLayout/GridItemContent.tsx b/dashboards/src/components/GridLayout/GridItemContent.tsx index b45db86c..4b5ab246 100644 --- a/dashboards/src/components/GridLayout/GridItemContent.tsx +++ b/dashboards/src/components/GridLayout/GridItemContent.tsx @@ -135,6 +135,7 @@ export function GridItemContent(props: GridItemContentProps): ReactElement { setOpenQueryViewer(false)} /> diff --git a/dashboards/src/components/QueryViewerDialog/QueryPlayground.tsx b/dashboards/src/components/QueryViewerDialog/QueryPlayground.tsx new file mode 100644 index 00000000..1787a966 --- /dev/null +++ b/dashboards/src/components/QueryViewerDialog/QueryPlayground.tsx @@ -0,0 +1,141 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Box, Stack, Typography } from '@mui/material'; +import { ErrorAlert, ErrorBoundary } from '@perses-dev/components'; +import { + DataQueriesProvider, + MultiQueryEditor, + useDataQueriesContext, + usePlugin, + useSuggestedStepMs, +} from '@perses-dev/plugin-system'; +import { PanelDefinition, QueryDefinition, QueryPluginType } from '@perses-dev/spec'; +import { ReactElement, useCallback, useContext, useMemo, useState } from 'react'; +import { PanelEditorContext } from '../../context'; +import { PanelEditorProvider } from '../../context/PanelEditorProvider/PanelEditorProvider'; +import { PanelPreview } from '../PanelDrawer'; + +export interface QueryPlaygroundProps { + panelDefinition: PanelDefinition; +} + +/** + * An ephemeral "playground" for the queries of a panel: it renders a live preview of the panel + * along with editable query inputs, so edited queries can be run and assessed on the fly. + * All state is local to the component, so edits are never persisted: unmounting it (e.g. closing + * the dialog rendering it) drops every change. + */ +export function QueryPlayground({ panelDefinition }: QueryPlaygroundProps): ReactElement { + return ( + + + + ); +} + +function QueryPlaygroundContent({ panelDefinition }: QueryPlaygroundProps): ReactElement | null { + const { data: plugin, isLoading } = usePlugin('Panel', panelDefinition.spec.plugin.kind); + const panelEditorContext = useContext(PanelEditorContext); + const suggestedStepMs = useSuggestedStepMs(panelEditorContext?.preview.previewPanelWidth); + + // Draft queries drive the editors, preview queries drive the chart: a draft only + // becomes part of the preview when the user runs it. + const [draftQueries, setDraftQueries] = useState(panelDefinition.spec.queries ?? []); + const [previewQueries, setPreviewQueries] = useState(panelDefinition.spec.queries ?? []); + + const pluginQueryOptions = useMemo( + () => + typeof plugin?.queryOptions === 'function' + ? plugin.queryOptions(panelDefinition.spec.plugin.spec) + : plugin?.queryOptions, + [panelDefinition.spec.plugin.spec, plugin] + ); + + const handleQueriesChange = useCallback((queries: QueryDefinition[]) => { + setDraftQueries(queries); + // If the number of queries has changed, sync the preview to drop results of deleted queries. + setPreviewQueries((prev) => (queries.length !== prev.length ? queries : prev)); + }, []); + + const handleQueryRun = useCallback((index: number, query: QueryDefinition) => { + setPreviewQueries((prev) => { + const next = [...prev]; + next[index] = query; + return next; + }); + }, []); + + if (isLoading) { + return null; + } + + return ( + + + + + Preview + + + + + + + + + + + ); +} + +interface QueryPlaygroundEditorProps { + queryTypes: QueryPluginType[]; + queries: QueryDefinition[]; + previewQueries: QueryDefinition[]; + onChange: (queries: QueryDefinition[]) => void; + onQueryRun: (index: number, query: QueryDefinition) => void; +} + +// Separate component because reading the query results requires being inside the DataQueriesProvider. +function QueryPlaygroundEditor({ + queryTypes, + queries, + previewQueries, + onChange, + onQueryRun, +}: QueryPlaygroundEditorProps): ReactElement { + const { queryResults } = useDataQueriesContext(); + + return ( + { + onQueryRun(index, query); + // If the spec has not changed, refetch to update the data + if (JSON.stringify(previewQueries[index]) === JSON.stringify(query)) { + queryResults[index]?.refetch?.(); + } + }} + /> + ); +} diff --git a/dashboards/src/components/QueryViewerDialog/QueryViewerDialog.test.tsx b/dashboards/src/components/QueryViewerDialog/QueryViewerDialog.test.tsx new file mode 100644 index 00000000..91afbc44 --- /dev/null +++ b/dashboards/src/components/QueryViewerDialog/QueryViewerDialog.test.tsx @@ -0,0 +1,116 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PanelDefinition, QueryDefinition } from '@perses-dev/spec'; +import { TimeRangeProviderBasic, TimeSeriesQueryPlugin } from '@perses-dev/plugin-system'; +import { ReactElement, useState } from 'react'; +import { renderWithContext } from '../../test'; +import { MOCK_PLUGINS } from '../../test/plugin-registry'; +import { VariableProvider } from '../../context'; +import { QueryViewerDialog } from './QueryViewerDialog'; + +const queryDefinitions: QueryDefinition[] = [ + { + kind: 'TimeSeriesQuery', + spec: { plugin: { kind: 'PrometheusTimeSeriesQuery', spec: { query: 'up' } } }, + }, +]; + +const panelDefinition: PanelDefinition = { + kind: 'Panel', + spec: { + display: { name: 'My Panel' }, + plugin: { kind: 'TimeSeriesChart', spec: {} }, + queries: queryDefinitions, + }, +}; + +function Harness({ withPanelDefinition = true }: { withPanelDefinition?: boolean }): ReactElement { + const [open, setOpen] = useState(true); + return ( + + + + setOpen(false)} + /> + + + ); +} + +describe('QueryViewerDialog', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders editable queries and a live panel preview when panelDefinition is provided', async () => { + renderWithContext(); + + expect(await screen.findByText('TimeSeriesChart panel')).toBeInTheDocument(); + const input = await screen.findByLabelText('query expression'); + expect(input).toHaveValue('up'); + expect(input).toBeEnabled(); + expect(await screen.findByTestId('run_query_button')).toBeInTheDocument(); + }); + + it('runs an edited query against the preview without mutating the original definition', async () => { + const timeSeriesQueryPlugin = MOCK_PLUGINS.find((plugin) => plugin.kind === 'TimeSeriesQuery'); + if (timeSeriesQueryPlugin?.kind !== 'TimeSeriesQuery') { + throw new Error('missing TimeSeriesQuery mock plugin'); + } + const getTimeSeriesDataSpy = jest.spyOn(timeSeriesQueryPlugin.plugin as TimeSeriesQueryPlugin, 'getTimeSeriesData'); + + renderWithContext(); + const input = await screen.findByLabelText('query expression'); + userEvent.clear(input); + userEvent.type(input, 'up == 1'); + userEvent.click(screen.getByTestId('run_query_button')); + + await waitFor(() => { + const queriedSpecs = getTimeSeriesDataSpy.mock.calls.map((call) => call[0] as { query?: string }); + expect(queriedSpecs.some((spec) => spec.query === 'up == 1')).toBe(true); + }); + // The panel's original definition must never be touched by playground edits. + expect(queryDefinitions[0]?.spec.plugin.spec).toEqual({ query: 'up' }); + }); + + it('drops edits when the dialog is closed and reopened', async () => { + renderWithContext(); + const input = await screen.findByLabelText('query expression'); + userEvent.clear(input); + userEvent.type(input, 'sum(up)'); + expect(input).toHaveValue('sum(up)'); + + userEvent.click(screen.getByText('toggle dialog')); + await waitFor(() => { + expect(screen.queryByLabelText('query expression')).not.toBeInTheDocument(); + }); + userEvent.click(screen.getByText('toggle dialog')); + + expect(await screen.findByLabelText('query expression')).toHaveValue('up'); + }); + + it('renders read-only queries when panelDefinition is not provided', async () => { + renderWithContext(); + + const input = await screen.findByLabelText('query expression'); + expect(input).toBeDisabled(); + expect(screen.queryByText('TimeSeriesChart panel')).not.toBeInTheDocument(); + }); +}); diff --git a/dashboards/src/components/QueryViewerDialog/QueryViewerDialog.tsx b/dashboards/src/components/QueryViewerDialog/QueryViewerDialog.tsx index dbef63d0..cd69f80b 100644 --- a/dashboards/src/components/QueryViewerDialog/QueryViewerDialog.tsx +++ b/dashboards/src/components/QueryViewerDialog/QueryViewerDialog.tsx @@ -15,15 +15,27 @@ import React, { ReactElement, useMemo } from 'react'; import { Dialog } from '@perses-dev/components'; import { Button, Divider } from '@mui/material'; import { PluginSpecEditor } from '@perses-dev/plugin-system'; -import { QueryDefinition } from '@perses-dev/spec'; +import { PanelDefinition, QueryDefinition } from '@perses-dev/spec'; +import { QueryPlayground } from './QueryPlayground'; export interface QueryViewerDialogProps { open: boolean; queryDefinitions: QueryDefinition[]; + /** + * When provided, the dialog becomes a query playground: queries are editable and can be run + * on the fly against a live preview of the panel. Edits are local to the dialog and dropped + * when it closes. When omitted, queries are rendered read-only. + */ + panelDefinition?: PanelDefinition; onClose: () => void; } -export function QueryViewerDialog({ open, queryDefinitions, onClose }: QueryViewerDialogProps): ReactElement { +export function QueryViewerDialog({ + open, + queryDefinitions, + panelDefinition, + onClose, +}: QueryViewerDialogProps): ReactElement { const queryRows = useMemo(() => { if (!queryDefinitions?.length) return null; @@ -50,7 +62,10 @@ export function QueryViewerDialog({ open, queryDefinitions, onClose }: QueryView return ( Query Viewer - {queryRows} + {/* Gating on `open` guarantees playground edits are dropped whenever the dialog closes. */} + + {open && panelDefinition ? : queryRows} +