add options panel

This commit is contained in:
Paul Marbach
2025-12-02 10:57:39 -05:00
parent dbca357f82
commit 61b5880d95
3 changed files with 293 additions and 140 deletions
@@ -401,6 +401,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({
direction: 'row',
initialSize: 0.25,
handleSize: 'xs',
});
return (
@@ -423,7 +424,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
onRemoveTransform={handleRemoveTransform}
/>
</div>
<div {...splitterProps} className={cx(splitterProps.className, styles.splitter)} />
<div
{...splitterProps}
className={cx(splitterProps.className, styles.splitter)}
style={{ ...splitterProps.style, width: 0 }}
/>
<div {...secondaryProps}>
<DetailView selectedItem={selectedItem} panel={panel} tabs={tabs} />
</div>
@@ -1,16 +1,27 @@
import { css } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import { css, cx } from '@emotion/css';
import { useCallback, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { CoreApp, DataQuery, DataSourcePluginContextProvider, GrafanaTheme2, TimeRange } from '@grafana/data';
import {
CoreApp,
DataQuery,
DataSourcePluginContextProvider,
GrafanaTheme2,
TimeRange,
getDataSourceRef,
} from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { SceneDataQuery, VizPanel } from '@grafana/scenes';
import { SceneDataQuery, VizPanel, sceneGraph, SceneQueryRunner } from '@grafana/scenes';
import { ErrorBoundaryAlert, useStyles2 } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { QueryErrorAlert } from 'app/features/query/components/QueryErrorAlert';
import { QueryGroupOptionsEditor } from 'app/features/query/components/QueryGroupOptions';
import { QueryGroupOptions } from 'app/types/query';
import { PanelTimeRange } from '../../scene/panel-timerange/PanelTimeRange';
import { getQueryRunnerFor } from '../../utils/utils';
import { getUpdatedHoverHeader } from '../getPanelFrameOptions';
interface QueryDetailViewProps {
panel: VizPanel;
@@ -20,6 +31,7 @@ interface QueryDetailViewProps {
export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewProps) {
const styles = useStyles2(getStyles);
const [showOptions, setShowOptions] = useState(false);
const dsSettings = useMemo(() => {
try {
@@ -75,6 +87,89 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
}
}, [queryRunner]);
// FIXME this should be a memo or some other structure, not a callback
const buildQueryOptions = useCallback((): QueryGroupOptions => {
if (!queryRunner) {
return {
queries: [],
dataSource: dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined },
};
}
const timeRangeObj = sceneGraph.getTimeRange(panel);
let timeRangeOpts: QueryGroupOptions['timeRange'] = {
from: undefined,
shift: undefined,
hide: undefined,
};
if (timeRangeObj instanceof PanelTimeRange) {
timeRangeOpts = {
from: timeRangeObj.state.timeFrom,
shift: timeRangeObj.state.timeShift,
hide: timeRangeObj.state.hideTimeOverride,
};
}
return {
cacheTimeout: dsSettings?.meta.queryOptions?.cacheTimeout ? queryRunner.state.cacheTimeout : undefined,
queryCachingTTL: dsSettings?.cachingConfig?.enabled ? queryRunner.state.queryCachingTTL : undefined,
dataSource: {
default: dsSettings?.isDefault,
...(dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }),
},
queries: queryRunner.state.queries,
maxDataPoints: queryRunner.state.maxDataPoints,
minInterval: queryRunner.state.minInterval,
timeRange: timeRangeOpts,
};
}, [queryRunner, panel, dsSettings]);
const handleQueryOptionsChange = useCallback(
(options: QueryGroupOptions) => {
if (!queryRunner) {
return;
}
const dataObjStateUpdate: Partial<SceneQueryRunner['state']> = {};
const panelStateUpdate: Partial<VizPanel['state']> = {};
if (options.maxDataPoints !== queryRunner.state.maxDataPoints) {
dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined;
}
if (options.minInterval !== queryRunner.state.minInterval) {
dataObjStateUpdate.minInterval = options.minInterval ?? undefined;
}
const timeFrom = options.timeRange?.from ?? undefined;
const timeShift = options.timeRange?.shift ?? undefined;
const hideTimeOverride = options.timeRange?.hide;
if (timeFrom !== undefined || timeShift !== undefined) {
panelStateUpdate.$timeRange = new PanelTimeRange({ timeFrom, timeShift, hideTimeOverride });
panelStateUpdate.hoverHeader = getUpdatedHoverHeader(panel.state.title, panelStateUpdate.$timeRange);
} else {
panelStateUpdate.$timeRange = undefined;
panelStateUpdate.hoverHeader = getUpdatedHoverHeader(panel.state.title, undefined);
}
if (options.cacheTimeout !== queryRunner.state.cacheTimeout) {
dataObjStateUpdate.cacheTimeout = options.cacheTimeout;
}
if (options.queryCachingTTL !== queryRunner.state.queryCachingTTL) {
dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL;
}
panel.setState(panelStateUpdate);
queryRunner.setState(dataObjStateUpdate);
queryRunner.runQueries();
},
[queryRunner, panel]
);
const renderQueryEditor = () => {
if (!datasource || !dsSettings) {
return (
@@ -114,21 +209,72 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
const error = data?.error || data?.errors?.find((e) => e.refId === query.refId);
const queryOptions = buildQueryOptions();
const panelData = queryRunnerState?.data;
const renderCollapsedText = (): React.ReactNode | undefined => {
if (!panelData) {
return undefined;
}
let mdDesc = queryOptions.maxDataPoints ?? '';
if (mdDesc === '' && panelData.request) {
mdDesc = `auto = ${panelData.request.maxDataPoints}`;
}
const intervalDesc = panelData.request?.interval ?? queryOptions.minInterval;
return (
<>
{
<span className={styles.collapsedText}>
<Trans i18nKey="query.query-group-options-editor.collapsed-max-data-points">MD = {{ mdDesc }}</Trans>
</span>
}
{
<span className={styles.collapsedText}>
<Trans i18nKey="query.query-group-options-editor.collapsed-interval">Interval = {{ intervalDesc }}</Trans>
</span>
}
</>
);
};
return (
<div className={styles.container}>
<QueryOperationRow
id={`query-${query.refId}`}
index={queryIndex}
draggable={false}
collapsable={false}
isOpen={true}
hideHeader={true}
>
<div className={styles.queryContent}>
{renderQueryEditor()}
{error && <QueryErrorAlert error={error} />}
<div className={cx(styles.contentWrapper, showOptions && styles.contentWrapperTwoColumn)}>
<div className={styles.mainContent}>
<QueryOperationRow
id={`query-${query.refId}`}
index={queryIndex}
draggable={false}
collapsable={false}
isOpen={true}
hideHeader={true}
>
<div className={styles.queryContent}>
{renderQueryEditor()}
{error && <QueryErrorAlert error={error} />}
</div>
</QueryOperationRow>
</div>
</QueryOperationRow>
{showOptions && datasource && panelData && (
<div className={styles.optionsColumn}>
<QueryGroupOptionsEditor
options={queryOptions}
dataSource={datasource}
data={panelData}
onChange={handleQueryOptionsChange}
/>
</div>
)}
</div>
<div className={styles.footer}>
{renderCollapsedText()}
<button type="button" className={styles.optionsLink} onClick={() => setShowOptions(!showOptions)}>
<Trans i18nKey="dashboard-scene.query-detail-view.options">Options</Trans>
</button>
</div>
</div>
);
}
@@ -136,21 +282,71 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
width: '100%',
height: '100%',
position: 'relative',
}),
contentWrapper: css({
display: 'grid',
gridTemplateColumns: '1fr',
width: '100%',
minHeight: 'calc(100% - 36px)', // 36px for footer
}),
contentWrapperTwoColumn: css({
gridTemplateColumns: '1fr 0.5fr',
}),
mainContent: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
padding: theme.spacing(2),
width: '100%',
gap: theme.spacing(1),
overflow: 'scroll',
padding: theme.spacing(2, 2, 0, 2),
}),
queryContent: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
minHeight: '100%',
}),
footer: css({
display: 'flex',
justifyContent: 'flex-end',
borderTop: `1px solid ${theme.colors.border.weak}`,
position: 'sticky',
bottom: 0,
zIndex: theme.zIndex.navbarFixed,
padding: theme.spacing(1, 2),
background: theme.colors.background.secondary,
}),
optionsLink: css({
background: 'none',
border: 'none',
color: theme.colors.text.link,
cursor: 'pointer',
fontSize: theme.typography.bodySmall.fontSize,
padding: 0,
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
},
}),
optionsColumn: css({
display: 'flex',
flexDirection: 'column',
paddingLeft: theme.spacing(2),
borderLeft: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.secondary,
padding: theme.spacing(2),
}),
noEditor: css({
padding: theme.spacing(2),
textAlign: 'center',
color: theme.colors.text.secondary,
}),
collapsedText: css({
marginInline: theme.spacing(1),
fontSize: theme.typography.size.sm,
color: theme.colors.text.secondary,
}),
};
};
@@ -2,9 +2,8 @@ import { css, cx } from '@emotion/css';
import React, { useState, ChangeEvent, FocusEvent, useCallback } from 'react';
import { rangeUtil, PanelData, DataSourceApi, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Trans } from '@grafana/i18n';
import { Input, InlineSwitch, useStyles2, InlineLabel } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { QueryGroupOptions } from 'app/types/query';
interface Props {
@@ -18,7 +17,6 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
const [timeRangeFrom, setTimeRangeFrom] = useState(options.timeRange?.from || '');
const [timeRangeShift, setTimeRangeShift] = useState(options.timeRange?.shift || '');
const [timeRangeHide, setTimeRangeHide] = useState(options.timeRange?.hide ?? false);
const [isOpen, setIsOpen] = useState(false);
const [relativeTimeIsValid, setRelativeTimeIsValid] = useState(true);
const [timeShiftIsValid, setTimeShiftIsValid] = useState(true);
@@ -141,14 +139,6 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
[onChange, options]
);
const onOpenOptions = useCallback(() => {
setIsOpen(true);
}, []);
const onCloseOptions = useCallback(() => {
setIsOpen(false);
}, []);
const renderCacheTimeoutOption = () => {
const tooltip = `If your time series store has a query cache this option can override the default cache timeout. Specify a
numeric value in seconds.`;
@@ -294,117 +284,79 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
);
};
const renderCollapsedText = (): React.ReactNode | undefined => {
if (isOpen) {
return undefined;
}
let mdDesc = options.maxDataPoints ?? '';
if (mdDesc === '' && data.request) {
mdDesc = `auto = ${data.request.maxDataPoints}`;
}
const intervalDesc = data.request?.interval ?? options.minInterval;
return (
<>
{
<span className={styles.collapsedText}>
<Trans i18nKey="query.query-group-options-editor.collapsed-max-data-points">MD = {{ mdDesc }}</Trans>
</span>
}
{
<span className={styles.collapsedText}>
<Trans i18nKey="query.query-group-options-editor.collapsed-interval">Interval = {{ intervalDesc }}</Trans>
</span>
}
</>
);
};
return (
<QueryOperationRow
id="Query options"
index={0}
title={t('query.query-group-options-editor.Query options-title-query-options', 'Query options')}
headerElement={renderCollapsedText()}
isOpen={isOpen}
onOpen={onOpenOptions}
onClose={onCloseOptions}
>
<div className={styles.grid}>
{renderMaxDataPointsOption()}
{renderIntervalOption()}
{renderCacheTimeoutOption()}
{renderQueryCachingTTLOption()}
<div className={styles.grid}>
{renderMaxDataPointsOption()}
{renderIntervalOption()}
{renderCacheTimeoutOption()}
{renderQueryCachingTTLOption()}
<InlineLabel
htmlFor="relative-time-input"
tooltip={
<Trans
i18nKey="query.query-group-options-editor.relative-time-tooltip"
values={{ relativeFrom: 'now-5m', relativeTo: '5m', variable: '$_relativeTime' }}
>
Overrides the relative time range for individual panels, which causes them to be different than what is
selected in the dashboard time picker in the top-right corner of the dashboard. For example to configure
the Last 5 minutes the Relative time should be <code>{'{{relativeFrom}}'}</code> and{' '}
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
</Trans>
}
>
<Trans i18nKey="query.query-group-options-editor.relative-time">Relative time</Trans>
</InlineLabel>
<Input
id="relative-time-input"
type="text"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1h"
onChange={onRelativeTimeChange}
onBlur={onOverrideTime}
invalid={!relativeTimeIsValid}
value={timeRangeFrom}
/>
<InlineLabel
htmlFor="time-shift-input"
className={styles.firstColumn}
tooltip={
<Trans
i18nKey="query.query-group-options-editor.time-shift-tooltip"
values={{ relativeFrom: 'now-1h', relativeTo: '1h', variable: '$_timeShift' }}
>
Overrides the time range for individual panels by shifting its start and end relative to the time picker.
For example to configure the Last 1h the Time shift should be <code>{'{{relativeFrom}}'}</code> and{' '}
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
</Trans>
}
>
<Trans i18nKey="query.query-group-options-editor.time-shift">Time shift</Trans>
</InlineLabel>
<Input
id="time-shift-input"
type="text"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1h"
onChange={onTimeShiftChange}
onBlur={onTimeShift}
invalid={!timeShiftIsValid}
value={timeRangeShift}
/>
{(timeRangeShift || timeRangeFrom) && (
<>
<InlineLabel htmlFor="hide-time-info-switch" className={styles.firstColumn}>
<Trans i18nKey="query.query-group-options-editor.hide-time-info">Hide time info</Trans>
</InlineLabel>
<InlineSwitch
id="hide-time-info-switch"
className={styles.left}
value={timeRangeHide}
onChange={onToggleTimeOverride}
/>
</>
)}
</div>
</QueryOperationRow>
<InlineLabel
htmlFor="relative-time-input"
tooltip={
<Trans
i18nKey="query.query-group-options-editor.relative-time-tooltip"
values={{ relativeFrom: 'now-5m', relativeTo: '5m', variable: '$_relativeTime' }}
>
Overrides the relative time range for individual panels, which causes them to be different than what is
selected in the dashboard time picker in the top-right corner of the dashboard. For example to configure the
Last 5 minutes the Relative time should be <code>{'{{relativeFrom}}'}</code> and{' '}
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
</Trans>
}
>
<Trans i18nKey="query.query-group-options-editor.relative-time">Relative time</Trans>
</InlineLabel>
<Input
id="relative-time-input"
type="text"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1h"
onChange={onRelativeTimeChange}
onBlur={onOverrideTime}
invalid={!relativeTimeIsValid}
value={timeRangeFrom}
/>
<InlineLabel
htmlFor="time-shift-input"
className={styles.firstColumn}
tooltip={
<Trans
i18nKey="query.query-group-options-editor.time-shift-tooltip"
values={{ relativeFrom: 'now-1h', relativeTo: '1h', variable: '$_timeShift' }}
>
Overrides the time range for individual panels by shifting its start and end relative to the time picker.
For example to configure the Last 1h the Time shift should be <code>{'{{relativeFrom}}'}</code> and{' '}
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
</Trans>
}
>
<Trans i18nKey="query.query-group-options-editor.time-shift">Time shift</Trans>
</InlineLabel>
<Input
id="time-shift-input"
type="text"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1h"
onChange={onTimeShiftChange}
onBlur={onTimeShift}
invalid={!timeShiftIsValid}
value={timeRangeShift}
/>
{(timeRangeShift || timeRangeFrom) && (
<>
<InlineLabel htmlFor="hide-time-info-switch" className={styles.firstColumn}>
<Trans i18nKey="query.query-group-options-editor.hide-time-info">Hide time info</Trans>
</InlineLabel>
<InlineSwitch
id="hide-time-info-switch"
className={styles.left}
value={timeRangeHide}
onChange={onToggleTimeOverride}
/>
</>
)}
</div>
);
});