Actions: Infinity authentication (#109493)

This commit is contained in:
Adela Almasan
2025-09-03 20:56:30 -05:00
committed by GitHub
parent 936406bfe9
commit 4f70b93ca6
31 changed files with 922 additions and 193 deletions
+2
View File
@@ -849,6 +849,8 @@ export {
defaultActionConfig,
contentTypeOptions,
httpMethodOptions,
type FetchOptions,
type InfinityOptions,
} from './types/action';
export { DataFrameType } from './types/dataFrameTypes';
export {
+11 -8
View File
@@ -4,6 +4,7 @@ import { SelectableValue } from './select';
export enum ActionType {
Fetch = 'fetch',
Infinity = 'infinity',
}
type ActionButtonCssProperties = Pick<CSSProperties, 'backgroundColor'>;
@@ -11,11 +12,8 @@ type ActionButtonCssProperties = Pick<CSSProperties, 'backgroundColor'>;
export interface Action {
type: ActionType;
title: string;
// Options for the selected type
// Currently this is required because there is only one valid type (fetch)
// once multiple types are valid, usage of this will need to be optional
[ActionType.Fetch]: FetchOptions;
[ActionType.Fetch]?: FetchOptions;
[ActionType.Infinity]?: InfinityOptions;
confirmation?: string;
oneClick?: boolean;
variables?: ActionVariable[];
@@ -44,7 +42,7 @@ export enum ActionVariableType {
String = 'string',
}
interface FetchOptions {
export interface FetchOptions {
method: HttpRequestMethod;
url: string;
body?: string;
@@ -52,15 +50,20 @@ interface FetchOptions {
headers?: Array<[string, string]>;
}
export interface InfinityOptions extends FetchOptions {
datasourceUid: string;
}
export enum HttpRequestMethod {
POST = 'POST',
PUT = 'PUT',
GET = 'GET',
DELETE = 'DELETE',
PATCH = 'PATCH',
}
export const httpMethodOptions: SelectableValue[] = [
{ label: HttpRequestMethod.POST, value: HttpRequestMethod.POST },
{ label: HttpRequestMethod.PUT, value: HttpRequestMethod.PUT },
{ label: HttpRequestMethod.GET, value: HttpRequestMethod.GET },
];
@@ -74,7 +77,7 @@ export const contentTypeOptions: SelectableValue[] = [
export const defaultActionConfig: Action = {
type: ActionType.Fetch,
title: '',
fetch: {
[ActionType.Fetch]: {
url: '',
method: HttpRequestMethod.POST,
body: '{}',
@@ -617,6 +617,10 @@ export interface FeatureToggles {
*/
newFiltersUI?: boolean;
/**
* Allows authenticated API calls in actions
*/
vizActionsAuth?: boolean;
/**
* Uses Prometheus rules as the primary source of truth for ruler-enabled data sources
*/
alertingPrometheusRulesPrimary?: boolean;
@@ -31,9 +31,7 @@ export function DataLinksListItemBase<T extends DataLink | Action>({
const styles = useStyles2(getDataLinkListItemStyles);
const { title = '', oneClick = false } = item;
// @ts-ignore - https://github.com/microsoft/TypeScript/issues/27808
const url = item.url ?? item.fetch?.url ?? '';
const url = ('type' in item ? item[item.type]?.url : item.url) ?? '';
const hasTitle = title.trim() !== '';
const hasUrl = url.trim() !== '';
@@ -39,6 +39,7 @@ export interface PanelContext {
canAddAnnotations?: () => boolean;
canEditAnnotations?: (dashboardUID?: string) => boolean;
canDeleteAnnotations?: (dashboardUID?: string) => boolean;
canExecuteActions?: () => boolean;
onAnnotationCreate?: (annotation: AnnotationEventUIModel) => void;
onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void;
onAnnotationDelete?: (id: string) => void;
@@ -133,10 +133,16 @@ export function TableNG(props: TableNGProps) {
const theme = useTheme2();
const styles = useStyles2(getGridStyles, enablePagination, transparent);
const panelContext = usePanelContext();
const userCanExecuteActions = useMemo(() => panelContext.canExecuteActions?.() ?? false, [panelContext]);
const getCellActions = useCallback(
(field: Field, rowIdx: number) => getActions(data, field, rowIdx),
[getActions, data]
(field: Field, rowIdx: number) => {
if (!userCanExecuteActions) {
return [];
}
return getActions(data, field, rowIdx);
},
[getActions, data, userCanExecuteActions]
);
const hasHeader = !noHeader;
+9
View File
@@ -1060,6 +1060,15 @@ var (
Owner: grafanaDashboardsSquad,
Expression: "true", // enabled by default
},
{
Name: "vizActionsAuth",
Description: "Allows authenticated API calls in actions",
Stage: FeatureStagePublicPreview,
Owner: grafanaDatavizSquad,
FrontendOnly: true,
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "alertingPrometheusRulesPrimary",
Description: "Uses Prometheus rules as the primary source of truth for ruler-enabled data sources",
+1
View File
@@ -137,6 +137,7 @@ prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,fa
alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false
dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
newFiltersUI,GA,@grafana/dashboards-squad,false,false,false
vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
137 alertingFilterV2 experimental @grafana/alerting-squad false false false
138 dataplaneAggregator experimental @grafana/grafana-app-platform-squad false true false
139 newFiltersUI GA @grafana/dashboards-squad false false false
140 vizActionsAuth preview @grafana/dataviz-squad false false true
141 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
142 exploreLogsShardSplitting experimental @grafana/observability-logs false false true
143 exploreLogsAggregatedMetrics experimental @grafana/observability-logs false false true
+4
View File
@@ -559,6 +559,10 @@ const (
// Enables new combobox style UI for the Ad hoc filters variable in scenes architecture
FlagNewFiltersUI = "newFiltersUI"
// FlagVizActionsAuth
// Allows authenticated API calls in actions
FlagVizActionsAuth = "vizActionsAuth"
// FlagAlertingPrometheusRulesPrimary
// Uses Prometheus rules as the primary source of truth for ruler-enabled data sources
FlagAlertingPrometheusRulesPrimary = "alertingPrometheusRulesPrimary"
+18
View File
@@ -3536,6 +3536,24 @@
"expression": "true"
}
},
{
"metadata": {
"name": "vizActionsAuth",
"resourceVersion": "1756904995830",
"creationTimestamp": "2025-08-08T18:59:18Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-09-03 13:09:55.830412 +0000 UTC"
}
},
"spec": {
"description": "Allows authenticated API calls in actions",
"stage": "preview",
"codeowner": "@grafana/dataviz-squad",
"frontend": true,
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "zanzana",
@@ -0,0 +1,175 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
Action,
ActionType,
defaultActionConfig,
VariableSuggestion,
VariableOrigin,
HttpRequestMethod,
} from '@grafana/data';
import { ActionEditor } from './ActionEditor';
describe('ActionEditor', () => {
const mockOnChange = jest.fn();
const mockSuggestions: VariableSuggestion[] = [
{ value: '${var1}', label: 'Variable 1', origin: VariableOrigin.BuiltIn },
{ value: '${var2}', label: 'Variable 2', origin: VariableOrigin.BuiltIn },
];
const defaultAction: Action = {
...defaultActionConfig,
title: 'Test Action',
type: ActionType.Fetch,
[ActionType.Fetch]: {
method: HttpRequestMethod.POST,
url: 'https://api.example.com',
body: '{}',
queryParams: [],
headers: [['Content-Type', 'application/json']],
},
};
const defaultProps = {
index: 0,
value: defaultAction,
onChange: mockOnChange,
suggestions: mockSuggestions,
showOneClick: true,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders action editor with basic fields', () => {
render(<ActionEditor {...defaultProps} />);
expect(screen.getByDisplayValue('Test Action')).toBeInTheDocument();
expect(screen.getByDisplayValue('https://api.example.com')).toBeInTheDocument();
expect(screen.getByText('Connection')).toBeInTheDocument();
expect(screen.getByText('Variables')).toBeInTheDocument();
expect(screen.getByText('Query parameters')).toBeInTheDocument();
expect(screen.getByText('Headers')).toBeInTheDocument();
});
it('toggles one click setting', async () => {
const user = userEvent.setup();
render(<ActionEditor {...defaultProps} />);
const oneClickSwitch = screen.getByRole('switch');
await user.click(oneClickSwitch);
expect(mockOnChange).toHaveBeenCalledWith(0, {
...defaultAction,
oneClick: !defaultAction.oneClick,
});
});
it('updates HTTP method', async () => {
const user = userEvent.setup();
render(<ActionEditor {...defaultProps} />);
const getMethodButton = screen.getByRole('radio', { name: 'GET' });
await user.click(getMethodButton);
expect(mockOnChange).toHaveBeenCalledWith(0, {
...defaultAction,
[ActionType.Fetch]: {
...defaultAction[ActionType.Fetch],
method: HttpRequestMethod.GET,
},
});
});
it('renders color picker for background color', () => {
render(<ActionEditor {...defaultProps} />);
expect(screen.getByText('Button style')).toBeInTheDocument();
expect(screen.getByText('Color')).toBeInTheDocument();
});
it('hides body field for GET requests', () => {
const getAction: Action = {
...defaultAction,
[ActionType.Fetch]: {
...defaultAction[ActionType.Fetch]!,
method: HttpRequestMethod.GET,
},
};
render(<ActionEditor {...defaultProps} value={getAction} />);
expect(screen.queryByDisplayValue('{}')).not.toBeInTheDocument();
});
describe('Connection functionality', () => {
it('renders connection picker section', () => {
render(<ActionEditor {...defaultProps} />);
expect(screen.getByText('Connection')).toBeInTheDocument();
});
it('renders connection picker for Infinity action type', () => {
const proxyAction: Action = {
...defaultAction,
type: ActionType.Infinity,
[ActionType.Infinity]: {
method: HttpRequestMethod.POST,
url: 'https://api.example.com',
body: '{}',
queryParams: [],
headers: [['Content-Type', 'application/json']],
datasourceUid: 'test-ds-uid',
},
};
render(<ActionEditor {...defaultProps} value={proxyAction} />);
expect(screen.getByText('Connection')).toBeInTheDocument();
});
it('renders with fetch action type showing direct connection', () => {
const fetchAction: Action = {
...defaultAction,
type: ActionType.Fetch,
[ActionType.Fetch]: {
method: HttpRequestMethod.POST,
url: 'https://api.example.com',
body: '{}',
queryParams: [],
headers: [['Content-Type', 'application/json']],
},
};
render(<ActionEditor {...defaultProps} value={fetchAction} />);
expect(screen.getByText('Connection')).toBeInTheDocument();
expect(screen.getByText('Direct from browser')).toBeInTheDocument();
});
it('renders with Infinity action type showing datasource connection', () => {
const proxyAction: Action = {
...defaultAction,
type: ActionType.Infinity,
[ActionType.Infinity]: {
method: HttpRequestMethod.POST,
url: 'https://api.example.com',
body: '{}',
queryParams: [],
headers: [['Content-Type', 'application/json']],
datasourceUid: 'test-datasource-uid',
},
};
render(<ActionEditor {...defaultProps} value={proxyAction} />);
expect(screen.getByText('Connection')).toBeInTheDocument();
const connectionSection = screen.getByText('Connection').closest('.css-15ix71y-InlineFieldRow');
expect(connectionSection).toBeInTheDocument();
});
});
});
+124 -82
View File
@@ -3,28 +3,33 @@ import { memo } from 'react';
import {
Action,
ActionType,
DataSourceInstanceSettings,
GrafanaTheme2,
httpMethodOptions,
HttpRequestMethod,
VariableSuggestion,
InfinityOptions,
FetchOptions,
ActionVariable,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import {
Switch,
ColorPicker,
Field,
InlineField,
InlineFieldRow,
RadioButtonGroup,
JSONFormatter,
RadioButtonGroup,
Switch,
useStyles2,
ColorPicker,
useTheme2,
} from '@grafana/ui';
import { HTMLElementType, SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput';
import { ActionVariablesEditor } from './ActionVariablesEditor';
import { ConnectionPicker } from './ConnectionPicker';
import { ParamsEditor } from './ParamsEditor';
interface ActionEditorProps {
@@ -37,10 +42,56 @@ interface ActionEditorProps {
const LABEL_WIDTH = 13;
const DEFAULT_HTTP_CONFIG: FetchOptions = {
method: HttpRequestMethod.POST,
url: '',
body: '{}',
queryParams: [],
headers: [['Content-Type', 'application/json']],
};
export const ActionEditor = memo(({ index, value, onChange, suggestions, showOneClick }: ActionEditorProps) => {
const styles = useStyles2(getStyles);
const theme = useTheme2();
const getActionConfig = (): FetchOptions | InfinityOptions => {
if (value.type === ActionType.Infinity) {
return (
value[ActionType.Infinity] || {
...DEFAULT_HTTP_CONFIG,
datasourceUid: '',
}
);
}
return value[ActionType.Fetch] || DEFAULT_HTTP_CONFIG;
};
const updateActionConfig = (updates: Partial<FetchOptions | InfinityOptions>) => {
const configKey = value.type === ActionType.Infinity ? ActionType.Infinity : ActionType.Fetch;
const baseConfig = getActionConfig();
const updatedConfig = {
...baseConfig,
...updates,
...(value.type === ActionType.Infinity && {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
datasourceUid: (baseConfig as InfinityOptions).datasourceUid || '',
}),
};
onChange(index, {
...value,
[configKey]: updatedConfig,
});
};
const updateConfig =
<K extends keyof (FetchOptions & InfinityOptions)>(field: K) =>
(newValue: (FetchOptions & InfinityOptions)[K]) => {
updateActionConfig({ [field]: newValue });
};
const onTitleChange = (title: string) => {
onChange(index, { ...value, title });
};
@@ -49,40 +100,6 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
onChange(index, { ...value, confirmation });
};
const onOneClickChanged = () => {
onChange(index, { ...value, oneClick: !value.oneClick });
};
const onUrlChange = (url: string) => {
onChange(index, {
...value,
fetch: {
...value.fetch,
url,
},
});
};
const onBodyChange = (body: string) => {
onChange(index, {
...value,
fetch: {
...value.fetch,
body,
},
});
};
const onMethodChange = (method: HttpRequestMethod) => {
onChange(index, {
...value,
fetch: {
...value.fetch,
method,
},
});
};
const onVariablesChange = (variables: ActionVariable[]) => {
onChange(index, {
...value,
@@ -90,25 +107,15 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
});
};
const onQueryParamsChange = (queryParams: Array<[string, string]>) => {
onChange(index, {
...value,
fetch: {
...value.fetch,
queryParams,
},
});
const onOneClickChanged = () => {
onChange(index, { ...value, oneClick: !value.oneClick });
};
const onHeadersChange = (headers: Array<[string, string]>) => {
onChange(index, {
...value,
fetch: {
...value.fetch,
headers,
},
});
};
const onUrlChange = updateConfig('url');
const onBodyChange = updateConfig('body');
const onMethodChange = updateConfig('method');
const onQueryParamsChange = updateConfig('queryParams');
const onHeadersChange = updateConfig('headers');
const onBackgroundColorChange = (backgroundColor: string) => {
onChange(index, {
@@ -120,6 +127,33 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
});
};
const onConnectionChange = (connectionType: string | DataSourceInstanceSettings) => {
const baseAction = {
title: value.title,
confirmation: value.confirmation,
oneClick: value.oneClick,
variables: value.variables,
style: value.style,
};
if (typeof connectionType === 'string') {
onChange(index, {
...baseAction,
type: ActionType.Fetch,
[ActionType.Fetch]: getActionConfig(),
});
} else {
onChange(index, {
...baseAction,
type: ActionType.Infinity,
[ActionType.Infinity]: {
...getActionConfig(),
datasourceUid: connectionType.uid,
},
});
}
};
const renderJSON = (data = '{}') => {
try {
const json = JSON.parse(data);
@@ -133,9 +167,12 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
}
};
const actionConfig = getActionConfig();
const shouldRenderJSON =
value.fetch.method !== HttpRequestMethod.GET &&
value.fetch.headers?.some(([name, value]) => name === 'Content-Type' && value === 'application/json');
actionConfig.method !== HttpRequestMethod.GET &&
actionConfig.headers?.some(
([name, value]: [string, string]) => name === 'Content-Type' && value === 'application/json'
);
return (
<div className={styles.listItem}>
@@ -168,20 +205,6 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
)}
/>
</Field>
<Field label={t('grafana-ui.action-editor.button.style', 'Button style')}>
<InlineField
label={t('actions.action-editor.button.style.background-color', 'Color')}
labelWidth={LABEL_WIDTH}
className={styles.colorPicker}
>
<ColorPicker
color={value?.style?.backgroundColor || theme.colors.secondary.main}
onChange={onBackgroundColorChange}
/>
</InlineField>
</Field>
{showOneClick && (
<Field
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
@@ -195,13 +218,19 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
)}
<InlineFieldRow>
<InlineField
label={t('grafana-ui.action-editor.modal.action-method', 'Method')}
labelWidth={LABEL_WIDTH}
grow={true}
>
<InlineField label={t('grafana-ui.action-editor.modal.connection', 'Connection')} labelWidth={LABEL_WIDTH}>
<ConnectionPicker
actionType={value.type}
datasourceUid={value?.[ActionType.Infinity]?.datasourceUid}
onChange={onConnectionChange}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={t('grafana-ui.action-editor.modal.action-method', 'Method')} labelWidth={LABEL_WIDTH}>
<RadioButtonGroup<HttpRequestMethod>
value={value?.fetch.method}
value={actionConfig.method}
options={httpMethodOptions}
onChange={onMethodChange}
fullWidth
@@ -212,7 +241,7 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
<InlineFieldRow>
<InlineField label={t('actions.action-editor.label-url', 'URL')} labelWidth={LABEL_WIDTH} grow={true}>
<SuggestionsInput
value={value.fetch.url}
value={actionConfig.url}
onChange={onUrlChange}
suggestions={suggestions}
placeholder={t('actions.action-editor.placeholder-url', 'URL')}
@@ -232,22 +261,22 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
label={t('grafana-ui.action-editor.modal.action-query-params', 'Query parameters')}
className={styles.fieldGap}
>
<ParamsEditor value={value?.fetch.queryParams ?? []} onChange={onQueryParamsChange} suggestions={suggestions} />
<ParamsEditor value={actionConfig.queryParams ?? []} onChange={onQueryParamsChange} suggestions={suggestions} />
</Field>
<Field label={t('actions.action-editor.label-headers', 'Headers')}>
<ParamsEditor
value={value?.fetch.headers ?? []}
value={actionConfig.headers ?? []}
onChange={onHeadersChange}
suggestions={suggestions}
contentTypeHeader={true}
/>
</Field>
{value?.fetch.method !== HttpRequestMethod.GET && (
{actionConfig.method !== HttpRequestMethod.GET && (
<Field label={t('grafana-ui.action-editor.modal.action-body', 'Body')} className={styles.inputField}>
<SuggestionsInput
value={value.fetch.body}
value={actionConfig.body}
onChange={onBodyChange}
suggestions={suggestions}
type={HTMLElementType.TextAreaElement}
@@ -258,9 +287,22 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne
{shouldRenderJSON && (
<>
<br />
{renderJSON(value?.fetch.body)}
{renderJSON(actionConfig.body)}
</>
)}
<Field label={t('grafana-ui.action-editor.button.style', 'Button style')} style={{ marginTop: '8px' }}>
<InlineField
label={t('actions.action-editor.button.style.background-color', 'Color')}
labelWidth={LABEL_WIDTH}
className={styles.colorPicker}
>
<ColorPicker
color={value?.style?.backgroundColor || theme.colors.secondary.main}
onChange={onBackgroundColorChange}
/>
</InlineField>
</Field>
</div>
);
});
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Action, DataFrame, VariableSuggestion } from '@grafana/data';
import { Action, ActionType, DataFrame, VariableSuggestion } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Button, Modal } from '@grafana/ui';
@@ -26,6 +26,11 @@ export const ActionEditorModalContent = ({
}: ActionEditorModalContentProps) => {
const [dirtyAction, setDirtyAction] = useState(action);
const isSaveButtonDisabled =
dirtyAction.title.trim() === '' ||
!dirtyAction[dirtyAction.type]?.url?.trim() ||
(dirtyAction.type === ActionType.Infinity && !dirtyAction[ActionType.Infinity]?.datasourceUid);
return (
<>
<ActionEditor
@@ -45,7 +50,7 @@ export const ActionEditorModalContent = ({
onClick={() => {
onSave(index, dirtyAction);
}}
disabled={dirtyAction.title.trim() === '' || dirtyAction.fetch.url.trim() === ''}
disabled={isSaveButtonDisabled}
>
<Trans i18nKey="action-editor.modal.save-button">Save</Trans>
</Button>
@@ -0,0 +1,96 @@
import { useMemo } from 'react';
import { ActionType, DataSourceInstanceSettings } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { Select } from '@grafana/ui';
import { INFINITY_DATASOURCE_TYPE } from './utils';
interface ConnectionOption {
label: string;
value: string;
description?: string;
imgUrl?: string;
icon?: string;
}
interface ConnectionPickerProps {
actionType: ActionType;
datasourceUid?: string;
onChange: (connectionType: 'direct' | DataSourceInstanceSettings) => void;
}
const DIRECT_OPTION_VALUE = 'direct';
const getSupportedDataSources = () => {
const dataSourceSrv = getDataSourceSrv();
return dataSourceSrv.getList({
filter: (ds) => ds.type === INFINITY_DATASOURCE_TYPE,
});
};
export const ConnectionPicker = ({ actionType, datasourceUid, onChange }: ConnectionPickerProps) => {
const connectionOptions: ConnectionOption[] = useMemo(() => {
const options: ConnectionOption[] = [
{
label: t('grafana-ui.action-editor.modal.connection-direct-label', 'Direct from browser'),
value: DIRECT_OPTION_VALUE,
description: t(
'grafana-ui.action-editor.modal.connection-direct-description',
'Make request directly from browser'
),
icon: 'adjust-circle',
},
];
if (config.featureToggles.vizActionsAuth) {
const supportedDataSources = getSupportedDataSources();
supportedDataSources.forEach((ds) => {
options.push({
label: ds.name,
value: ds.uid,
imgUrl: ds.meta.info.logos.small,
});
});
}
return options;
}, []);
const getCurrentValue = () => {
if (actionType === ActionType.Fetch) {
return DIRECT_OPTION_VALUE;
} else if (actionType === ActionType.Infinity && datasourceUid) {
return datasourceUid;
}
return DIRECT_OPTION_VALUE;
};
const handleConnectionChange = (selectedValue: string) => {
if (selectedValue === DIRECT_OPTION_VALUE) {
onChange(DIRECT_OPTION_VALUE);
} else {
const supportedDataSources = getSupportedDataSources();
const selectedDatasource = supportedDataSources.find((ds) => ds.uid === selectedValue);
if (selectedDatasource) {
onChange(selectedDatasource);
} else {
console.error('ConnectionPicker: Could not find datasource with UID:', selectedValue);
}
}
};
const currentValue = getCurrentValue();
return (
<Select
value={currentValue}
options={connectionOptions}
onChange={(selected) => handleConnectionChange(selected.value!)}
placeholder={t('grafana-ui.action-editor.modal.connection-placeholder', 'Select connection')}
/>
);
};
+177 -3
View File
@@ -1,8 +1,27 @@
import { Action, ActionType, ActionVariableInput, ActionVariableType } from '@grafana/data';
import {
Action,
ActionType,
ActionVariableInput,
ActionVariableType,
HttpRequestMethod,
DataFrame,
Field,
FieldType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { HttpRequestMethod } from '../../plugins/panel/canvas/panelcfg.gen';
import {
buildActionRequest,
buildActionProxyRequest,
genReplaceActionVars,
isInfinityActionWithAuth,
getActions,
INFINITY_DATASOURCE_TYPE,
} from './utils';
import { buildActionRequest, genReplaceActionVars } from './utils';
jest.mock('../query/state/PanelQueryRunner', () => ({
getNextRequestId: jest.fn(() => 'test-request-id-123'),
}));
describe('interpolateActionVariables', () => {
const actionMock = (): Action => ({
@@ -149,3 +168,158 @@ describe('interpolateActionVariables', () => {
expect(JSON.parse(request.data).data.secondary).toBe('Room-$thermostat2');
});
});
describe('Infinity request', () => {
const mockReplaceVariables = jest.fn((str) => str);
beforeEach(() => {
jest.clearAllMocks();
});
describe('buildActionProxyRequest', () => {
const infinityActionMock = (overrides = {}): Action => ({
title: 'Infinity API Call',
type: ActionType.Infinity,
[ActionType.Infinity]: {
method: HttpRequestMethod.POST,
url: 'https://api.example.com/data',
body: '{"test": "data"}',
headers: [
['Content-Type', 'application/json'],
['Authorization', 'Bearer token123'],
],
queryParams: [
['filter', 'active'],
['limit', '10'],
],
datasourceUid: 'infinity-ds-uid',
...overrides,
},
});
it('should build Infinity proxy request with all parameters', () => {
const action = infinityActionMock();
const request = buildActionProxyRequest(action, mockReplaceVariables);
expect(request).toEqual({
url: `api/ds/query?ds_type=${INFINITY_DATASOURCE_TYPE}&requestId=test-request-id-123`,
method: HttpRequestMethod.POST,
data: {
queries: [
{
refId: 'A',
datasource: {
type: INFINITY_DATASOURCE_TYPE,
uid: 'infinity-ds-uid',
},
type: 'json',
source: 'url',
format: 'as-is',
url: new URL('https://api.example.com/data'),
url_options: {
method: HttpRequestMethod.POST,
data: '{"test": "data"}',
headers: [
{ key: 'Content-Type', value: 'application/json' },
{ key: 'Authorization', value: 'Bearer token123' },
],
params: [
{ key: 'filter', value: 'active' },
{ key: 'limit', value: '10' },
],
body_type: 'raw',
body_content_type: 'application/json',
},
},
],
from: expect.any(String),
to: expect.any(String),
},
});
});
it('should handle GET requests without body', () => {
const action = infinityActionMock({
method: HttpRequestMethod.GET,
body: '',
});
const request = buildActionProxyRequest(action, mockReplaceVariables);
expect(request.data.queries[0].url_options.method).toBe(HttpRequestMethod.GET);
expect(request.data.queries[0].url_options.data).toBeUndefined();
});
it('should throw error for missing datasource UID', () => {
const action = infinityActionMock({
datasourceUid: '',
});
expect(() => {
buildActionProxyRequest(action, mockReplaceVariables);
}).toThrow('Datasource not configured for Infinity action');
});
});
});
describe('isInfinityActionWithAuth', () => {
const originalFeatureToggles = config.featureToggles;
const infinityAction: Action = { title: 'Infinity action', type: ActionType.Infinity };
const fetchAction: Action = { title: 'Fetch action', type: ActionType.Fetch };
afterEach(() => {
config.featureToggles = originalFeatureToggles;
});
it.each([
[true, true],
[false, false],
[undefined, false],
])('returns %s when toggle is %s', (toggle, expected) => {
config.featureToggles = { ...originalFeatureToggles, vizActionsAuth: toggle };
expect(isInfinityActionWithAuth(infinityAction)).toBe(expected);
});
it('returns false for Fetch action', () => {
config.featureToggles = { ...originalFeatureToggles, vizActionsAuth: true };
expect(isInfinityActionWithAuth(fetchAction)).toBe(false);
});
});
describe('getActions filtering', () => {
const originalFeatureToggles = config.featureToggles;
const mockFrame: DataFrame = { name: 'test', fields: [], length: 0 };
const mockField: Field = { name: 'test-field', type: FieldType.string, values: [], config: {} };
const mockReplaceVariables = jest.fn((str) => str);
const fetchAction: Action = {
title: 'Fetch action',
type: ActionType.Fetch,
[ActionType.Fetch]: { url: '', method: HttpRequestMethod.GET },
};
const infinityAction: Action = {
title: 'Infinity action',
type: ActionType.Infinity,
[ActionType.Infinity]: { url: '', method: HttpRequestMethod.GET, datasourceUid: 'uid' },
};
afterEach(() => {
config.featureToggles = originalFeatureToggles;
jest.clearAllMocks();
});
it.each([
[true, [infinityAction, fetchAction], 2, ['Infinity action', 'Fetch action']],
[false, [infinityAction, fetchAction], 1, ['Fetch action']],
[false, [infinityAction], 0, []],
[false, [fetchAction], 1, ['Fetch action']],
])('filters correctly when toggle=%s', (toggle, actions, expectedCount, expectedActionTitles) => {
config.featureToggles = { ...originalFeatureToggles, vizActionsAuth: toggle };
const result = getActions(mockFrame, mockField, {}, mockReplaceVariables, actions, {});
expect(result).toHaveLength(expectedCount);
expect(result.map((a) => a.title)).toEqual(expectedActionTitles);
});
});
+207 -77
View File
@@ -1,6 +1,7 @@
import {
Action,
ActionModel,
ActionType,
ActionVariableInput,
AppEvents,
DataContextScopedVar,
@@ -10,6 +11,7 @@ import {
FieldType,
getFieldDataContextClone,
InterpolateFunction,
InfinityOptions,
ScopedVars,
textUtil,
ValueLinkConfig,
@@ -19,6 +21,13 @@ import { appEvents } from 'app/core/core';
import { HttpRequestMethod } from '../../plugins/panel/canvas/panelcfg.gen';
import { createAbsoluteUrl, RelativeUrl } from '../alerting/unified/utils/url';
import { getTimeSrv } from '../dashboard/services/TimeSrv';
import { getNextRequestId } from '../query/state/PanelQueryRunner';
/** @internal */
export const isInfinityActionWithAuth = (action: Action): boolean => {
return (grafanaConfig.featureToggles.vizActionsAuth ?? false) && action.type === ActionType.Infinity;
};
/** @internal */
export const genReplaceActionVars = (
@@ -56,95 +65,142 @@ export const getActions = (
return [];
}
const actionModels = actions.map((action: Action) => {
const dataContext: DataContextScopedVar = getFieldDataContextClone(frame, field, fieldScopedVars);
const actionScopedVars = {
...fieldScopedVars,
__dataContext: dataContext,
};
const actionModels = actions
.filter((action) => {
return action.type === ActionType.Fetch || isInfinityActionWithAuth(action);
})
.map((action: Action) => {
const dataContext: DataContextScopedVar = getFieldDataContextClone(frame, field, fieldScopedVars);
const actionScopedVars = {
...fieldScopedVars,
__dataContext: dataContext,
};
const boundReplaceVariables: InterpolateFunction = (value, scopedVars, format) => {
return replaceVariables(value, { ...actionScopedVars, ...scopedVars }, format);
};
const boundReplaceVariables: InterpolateFunction = (value, scopedVars, format) => {
return replaceVariables(value, { ...actionScopedVars, ...scopedVars }, format);
};
// We are not displaying reduction result
if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) {
dataContext.value.rowIndex = config.valueRowIndex;
} else {
dataContext.value.calculatedValue = config.calculatedValue;
}
// We are not displaying reduction result
if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) {
dataContext.value.rowIndex = config.valueRowIndex;
} else {
dataContext.value.calculatedValue = config.calculatedValue;
}
const actionModel: ActionModel<Field> = {
title: replaceVariables(action.title, actionScopedVars),
confirmation: (actionVars?: ActionVariableInput) =>
genReplaceActionVars(
boundReplaceVariables,
action,
actionVars
)(action.confirmation || `Are you sure you want to ${action.title}?`),
onClick: (evt: MouseEvent, origin: Field, actionVars?: ActionVariableInput) => {
let request = buildActionRequest(action, genReplaceActionVars(boundReplaceVariables, action, actionVars));
const actionModel: ActionModel<Field> = {
title: replaceVariables(action.title, actionScopedVars),
confirmation: (actionVars?: ActionVariableInput) =>
genReplaceActionVars(
boundReplaceVariables,
action,
actionVars
)(action.confirmation || `Are you sure you want to ${action.title}?`),
onClick: (evt: MouseEvent, origin: Field, actionVars?: ActionVariableInput) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
let request = {} as BackendSrvRequest;
if (isInfinityActionWithAuth(action)) {
request = buildActionProxyRequest(action, genReplaceActionVars(boundReplaceVariables, action, actionVars));
} else if (action.type === ActionType.Fetch) {
request = buildActionRequest(action, genReplaceActionVars(boundReplaceVariables, action, actionVars));
}
try {
getBackendSrv()
.fetch(request)
.subscribe({
error: (error) => {
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
console.error(error);
},
complete: () => {
appEvents.emit(AppEvents.alertSuccess, ['API call was successful']);
},
});
} catch (error) {
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
console.error(error);
return;
}
},
oneClick: action.oneClick ?? false,
style: {
backgroundColor: action.style?.backgroundColor ?? grafanaConfig.theme2.colors.secondary.main,
},
variables: action.variables,
};
try {
getBackendSrv()
.fetch(request)
.subscribe({
error: (error) => {
appEvents.emit(AppEvents.alertError, [
'An error has occurred. Check console output for more details.',
]);
console.error(error);
},
complete: () => {
appEvents.emit(AppEvents.alertSuccess, ['API call was successful']);
},
});
} catch (error) {
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
console.error(error);
return;
}
},
oneClick: action.oneClick ?? false,
style: {
backgroundColor: action.style?.backgroundColor ?? grafanaConfig.theme2.colors.secondary.main,
},
variables: action.variables,
};
return actionModel;
});
return actionModel;
});
return actionModels.filter((action): action is ActionModel => !!action);
};
/** @internal */
const processActionConfig = (action: Action, replaceVariables: InterpolateFunction) => {
const config = action[action.type];
if (!config) {
throw new Error('Action does not have the correct configuration');
}
const url = new URL(getUrl(replaceVariables(config.url)));
const data = config.method === HttpRequestMethod.GET ? undefined : config.body ? replaceVariables(config.body) : '{}';
const processedHeaders: Array<[string, string]> = [];
const processedQueryParams: Array<[string, string]> = [];
let contentType = 'application/json';
if (config.headers) {
config.headers.forEach(([name, value]) => {
const processedName = replaceVariables(name);
const processedValue = replaceVariables(value);
processedHeaders.push([processedName, processedValue]);
if (processedName.toLowerCase() === 'content-type') {
contentType = processedValue;
}
});
}
if (config.queryParams) {
config.queryParams.forEach(([name, value]) => {
processedQueryParams.push([replaceVariables(name), replaceVariables(value)]);
});
}
return {
config,
url,
data,
processedHeaders,
processedQueryParams,
contentType,
};
};
/** @internal */
export const buildActionRequest = (action: Action, replaceVariables: InterpolateFunction) => {
const url = new URL(getUrl(replaceVariables(action.fetch.url)));
const { config, url, data, processedHeaders, processedQueryParams } = processActionConfig(action, replaceVariables);
const requestHeaders: Record<string, string> = {};
let request: BackendSrvRequest = {
url: url.toString(),
method: action.fetch.method,
data: getData(action, replaceVariables),
headers: requestHeaders,
};
processedHeaders.forEach(([name, value]) => {
requestHeaders[name] = value;
});
if (action.fetch.headers) {
action.fetch.headers.forEach(([name, value]) => {
requestHeaders[replaceVariables(name)] = replaceVariables(value);
});
}
if (action.fetch.queryParams) {
action.fetch.queryParams?.forEach(([name, value]) => {
url.searchParams.append(replaceVariables(name), replaceVariables(value));
});
request.url = url.toString();
}
processedQueryParams.forEach(([name, value]) => {
url.searchParams.append(name, value);
});
requestHeaders['X-Grafana-Action'] = '1';
request.headers = requestHeaders;
const request: BackendSrvRequest = {
url: url.toString(),
method: config.method,
data,
headers: requestHeaders,
};
return request;
};
@@ -172,11 +228,85 @@ const getUrl = (endpoint: string) => {
};
/** @internal */
const getData = (action: Action, replaceVariables: InterpolateFunction) => {
let data: string | undefined = action.fetch.body ? replaceVariables(action.fetch.body) : '{}';
if (action.fetch.method === HttpRequestMethod.GET) {
data = undefined;
interface KeyValuePair {
key: string;
value: string;
}
export const INFINITY_DATASOURCE_TYPE = 'yesoreyeram-infinity-datasource';
/** @internal */
class InfinityRequestBuilder {
buildRequest(
proxyConfig: InfinityOptions,
url: URL,
data: string | undefined,
headers: Array<[string, string]>,
queryParams: Array<[string, string]>,
contentType: string
): BackendSrvRequest {
const requestId = getNextRequestId();
const infinityUrl = `api/ds/query?ds_type=${INFINITY_DATASOURCE_TYPE}&requestId=${requestId}`;
const timeRange = getTimeSrv().timeRange();
const requestHeaders: KeyValuePair[] = [];
headers.forEach(([name, value]) => {
requestHeaders.push({ key: name, value: value });
});
// Infinity needs [string, string] to {key: string, value: string}
const requestQueryParams: KeyValuePair[] = [];
queryParams.forEach(([name, value]) => {
requestQueryParams.push({ key: name, value: value });
});
const infinityUrlOptions = {
method: proxyConfig.method,
data,
headers: requestHeaders,
params: requestQueryParams,
body_type: 'raw',
body_content_type: contentType,
};
return {
url: infinityUrl,
method: HttpRequestMethod.POST,
data: {
queries: [
{
refId: 'A',
datasource: {
type: INFINITY_DATASOURCE_TYPE,
uid: proxyConfig.datasourceUid,
},
type: 'json',
source: 'url',
format: 'as-is',
url,
url_options: infinityUrlOptions,
},
],
from: timeRange.from.valueOf().toString(),
to: timeRange.to.valueOf().toString(),
},
};
}
}
/** @internal */
export const buildActionProxyRequest = (action: Action, replaceVariables: InterpolateFunction) => {
const { config, url, data, processedHeaders, processedQueryParams, contentType } = processActionConfig(
action,
replaceVariables
);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const infinityConfig = config as InfinityOptions;
if (!infinityConfig.datasourceUid) {
throw new Error('Datasource not configured for Infinity action');
}
return data;
const requestBuilder = new InfinityRequestBuilder();
return requestBuilder.buildRequest(infinityConfig, url, data, processedHeaders, processedQueryParams, contentType);
};
+23 -6
View File
@@ -11,6 +11,7 @@ import {
OneClickMode,
ActionModel,
ActionVariableInput,
ActionType,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { TooltipDisplayMode } from '@grafana/schema';
@@ -34,7 +35,7 @@ import {
removeStyles,
} from 'app/plugins/panel/canvas/utils';
import { getActions, getActionsDefaultField } from '../../actions/utils';
import { getActions, getActionsDefaultField, isInfinityActionWithAuth } from '../../actions/utils';
import { CanvasElementItem, CanvasElementOptions } from '../element';
import { canvasElementRegistry } from '../registry';
@@ -640,8 +641,16 @@ export class ElementState implements LayerElement {
if (this.options.links?.some((link) => link.oneClick === true)) {
this.oneClickMode = OneClickMode.Link;
} else if (this.options.actions?.some((action) => action.oneClick === true)) {
this.oneClickMode = OneClickMode.Action;
} else if (
this.options.actions
?.filter((action) => action.type === ActionType.Fetch || isInfinityActionWithAuth(action))
.some((action) => action.oneClick)
) {
const scene = this.getScene();
const canExecuteActions = scene?.panel?.panelContext?.canExecuteActions;
const userCanExecuteActions = canExecuteActions?.() ?? false;
this.oneClickMode = userCanExecuteActions ? OneClickMode.Action : OneClickMode.Off;
} else {
this.oneClickMode = OneClickMode.Off;
}
@@ -901,9 +910,17 @@ export class ElementState implements LayerElement {
};
getPrimaryAction = () => {
const config: ValueLinkConfig = { valueRowIndex: getRowIndex(this.data.field, this.getScene()!) };
const scene = this.getScene();
const canExecuteActions = scene?.panel?.panelContext?.canExecuteActions;
const userCanExecuteActions = canExecuteActions?.() ?? false;
if (!userCanExecuteActions) {
return undefined;
}
const config: ValueLinkConfig = { valueRowIndex: getRowIndex(this.data.field, scene!) };
const actionsDefaultFieldConfig = { links: this.options.links ?? [], actions: this.options.actions ?? [] };
const frames = this.getScene()?.data?.series;
const frames = scene?.data?.series;
if (frames) {
const defaultField = getActionsDefaultField(actionsDefaultFieldConfig.links, actionsDefaultFieldConfig.actions);
@@ -922,7 +939,7 @@ export class ElementState implements LayerElement {
frames[0],
defaultField,
scopedVars,
this.getScene()?.panel.props.replaceVariables!,
scene?.panel.props.replaceVariables!,
actionsDefaultFieldConfig.actions,
config
);
+6 -2
View File
@@ -5,7 +5,7 @@ import { CSSProperties } from 'react';
import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
import Selecto from 'selecto';
import { AppEvents, PanelData, OneClickMode } from '@grafana/data';
import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
ColorDimensionConfig,
@@ -36,6 +36,7 @@ import { AnchorPoint, CanvasTooltipPayload } from 'app/plugins/panel/canvas/type
import appEvents from '../../../core/app_events';
import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel';
import { isInfinityActionWithAuth } from '../../actions/utils';
import { getDashboardSrv } from '../../dashboard/services/DashboardSrv';
import { CanvasFrameOptions } from '../frame';
import { DEFAULT_CANVAS_ELEMENT_CONFIG } from '../registry';
@@ -372,7 +373,10 @@ export class Scene {
render() {
const hasDataLinks = this.tooltipPayload?.element?.getLinks && this.tooltipPayload.element.getLinks({}).length > 0;
const hasActions =
this.tooltipPayload?.element?.options.actions && this.tooltipPayload.element.options.actions.length > 0;
this.tooltipPayload?.element?.options.actions &&
this.tooltipPayload.element.options.actions.filter(
(action) => action.type === ActionType.Fetch || isInfinityActionWithAuth(action)
).length > 0;
const isTooltipValid = hasDataLinks || hasActions || this.tooltipPayload?.element?.data?.field;
const isCanvasTooltipEnabled = this.tooltipMode !== TooltipDisplayMode.None;
@@ -133,6 +133,11 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
updateAdHocFilterVariable(filterVar, newFilter);
};
context.canExecuteActions = () => {
const dashboard = getDashboardSceneFor(vizPanel);
return dashboard.canEditDashboard();
};
context.onUpdateData = (frames: DataFrame[]): Promise<boolean> => {
// TODO
//return onUpdatePanelSnapshotData(this.props.panel, frames);
@@ -51,6 +51,7 @@ function setupTestContext(options: Partial<Props>) {
canAddAnnotations: jest.fn(),
canEditAnnotations: jest.fn(),
canDeleteAnnotations: jest.fn(),
canExecuteActions: jest.fn(),
meta: {
isPublic: false,
},
@@ -114,6 +114,7 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
canAddAnnotations: props.dashboard.canAddAnnotations.bind(props.dashboard),
canEditAnnotations: props.dashboard.canEditAnnotations.bind(props.dashboard),
canDeleteAnnotations: props.dashboard.canDeleteAnnotations.bind(props.dashboard),
canExecuteActions: props.dashboard.canExecuteActions.bind(props.dashboard),
onAddAdHocFilter: this.onAddAdHocFilter,
onUpdateData: this.onUpdateData,
},
@@ -1267,6 +1267,10 @@ export class DashboardModel implements TimeModel {
return Boolean(this.meta.canEdit || this.meta.canMakeEditable);
}
canExecuteActions() {
return this.canEditDashboard();
}
shouldUpdateDashboardPanelFromJSON(updatedPanel: PanelModel, panel: PanelModel) {
const shouldUpdateGridPositionLayout = !isEqual(updatedPanel?.gridPos, panel?.gridPos);
if (shouldUpdateGridPositionLayout) {
@@ -52,8 +52,11 @@ export const CandlestickPanel = ({
showThresholds,
dataLinkPostProcessor,
eventBus,
canExecuteActions,
} = usePanelContext();
const userCanExecuteActions = useMemo(() => canExecuteActions?.() ?? false, [canExecuteActions]);
const theme = useTheme2();
const info = useMemo(() => {
@@ -309,6 +312,7 @@ export const CandlestickPanel = ({
maxHeight={options.tooltip.maxHeight}
replaceVariables={replaceVariables}
dataLinks={dataLinks}
canExecuteActions={userCanExecuteActions}
/>
);
}}
@@ -97,6 +97,11 @@ export class CanvasPanel extends Component<Props, State> {
activePanelSubject.next({ panel: this });
this.panelContext = this.context;
if (this.scene.data) {
this.scene.updateData(this.scene.data);
}
if (this.panelContext.onInstanceStateChange) {
this.panelContext.onInstanceStateChange({ scene: this.scene, layer: this.scene.root });
@@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
import { createRef } from 'react';
import { createRef, useMemo } from 'react';
import {
Field,
@@ -14,7 +14,7 @@ import {
ValueLinkConfig,
ActionModel,
} from '@grafana/data';
import { Portal, useStyles2, useTheme2, VizTooltipContainer } from '@grafana/ui';
import { Portal, useStyles2, useTheme2, VizTooltipContainer, usePanelContext } from '@grafana/ui';
import {
VizTooltipContent,
VizTooltipFooter,
@@ -35,6 +35,8 @@ interface Props {
export const CanvasTooltip = ({ scene }: Props) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const { canExecuteActions } = usePanelContext();
const userCanExecuteActions = useMemo(() => canExecuteActions?.() ?? false, [canExecuteActions]);
const onClose = () => {
if (scene?.tooltipCallback && scene.tooltipPayload) {
@@ -104,7 +106,7 @@ export const CanvasTooltip = ({ scene }: Props) => {
const elementHasActions = (element.options.actions?.length ?? 0) > 0;
const frames = scene.data?.series;
if (elementHasActions && frames) {
if (elementHasActions && frames && userCanExecuteActions) {
const defaultField = getActionsDefaultField(element.options.links ?? [], element.options.actions ?? []);
const scopedVars: ScopedVars = {
__dataContext: {
@@ -51,6 +51,7 @@ interface HeatmapTooltipProps {
maxHeight?: number;
maxWidth?: number;
replaceVariables: InterpolateFunction;
canExecuteActions?: boolean;
}
export const HeatmapTooltip = (props: HeatmapTooltipProps) => {
@@ -93,6 +94,7 @@ const HeatmapHoverCell = ({
maxHeight,
maxWidth,
replaceVariables,
canExecuteActions,
}: HeatmapTooltipProps) => {
const index = dataIdxs[1]!;
const data = dataRef.current;
@@ -323,7 +325,7 @@ const HeatmapHoverCell = ({
links = getDataLinks(linksField, xValueIdx);
}
actions = getFieldActions(data.series!, linksField, replaceVariables, xValueIdx);
actions = canExecuteActions ? getFieldActions(data.series!, linksField, replaceVariables, xValueIdx) : [];
}
footer = <VizTooltipFooter dataLinks={links} annotate={annotate} actions={actions} />;
@@ -47,7 +47,10 @@ export const StateTimelinePanel = ({
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
const { sync, eventsScope, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
const { sync, eventsScope, canAddAnnotations, dataLinkPostProcessor, eventBus, canExecuteActions } =
usePanelContext();
const userCanExecuteActions = useMemo(() => canExecuteActions?.() ?? false, [canExecuteActions]);
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
const { frames, warn } = useMemo(
@@ -137,6 +140,7 @@ export const StateTimelinePanel = ({
maxHeight={options.tooltip.maxHeight}
replaceVariables={replaceVariables}
dataLinks={dataLinks}
canExecuteActions={userCanExecuteActions}
/>
);
}}
@@ -35,9 +35,11 @@ export function TablePanel(props: Props) {
const theme = useTheme2();
const panelContext = usePanelContext();
const userCanExecuteActions = useMemo(() => panelContext.canExecuteActions?.() ?? false, [panelContext]);
const _getActions = useCallback(
(frame: DataFrame, field: Field, rowIndex: number) => getCellActions(frame, field, rowIndex, replaceVariables),
[replaceVariables]
(frame: DataFrame, field: Field, rowIndex: number) =>
userCanExecuteActions ? getCellActions(frame, field, rowIndex, replaceVariables) : [],
[replaceVariables, userCanExecuteActions]
);
const frames = hasDeprecatedParentRowIndex(data.series)
? migrateFromParentRowIndexToNestedFrames(data.series)
@@ -40,7 +40,10 @@ export const TimeSeriesPanel = ({
showThresholds,
dataLinkPostProcessor,
eventBus,
canExecuteActions,
} = usePanelContext();
const userCanExecuteActions = useMemo(() => canExecuteActions?.() ?? false, [canExecuteActions]);
// Vertical orientation is not available for users through config.
// It is simplified version of horizontal time series panel and it does not support all plugins.
const isVerticallyOriented = options.orientation === VizOrientation.Vertical;
@@ -137,6 +140,7 @@ export const TimeSeriesPanel = ({
maxHeight={options.tooltip.maxHeight}
replaceVariables={replaceVariables}
dataLinks={dataLinks}
canExecuteActions={userCanExecuteActions}
/>
);
}}
@@ -42,6 +42,7 @@ export interface TimeSeriesTooltipProps {
dataLinks: LinkModel[];
hideZeros?: boolean;
adHocFilters?: AdHocFilterModel[];
canExecuteActions?: boolean;
}
export const TimeSeriesTooltip = ({
@@ -58,6 +59,7 @@ export const TimeSeriesTooltip = ({
dataLinks,
hideZeros,
adHocFilters,
canExecuteActions,
}: TimeSeriesTooltipProps) => {
const xField = series.fields[0];
const xVal = formattedValueToString(xField.display!(xField.values[dataIdxs[0]!]));
@@ -82,7 +84,7 @@ export const TimeSeriesTooltip = ({
if (isPinned || hasOneClickLink) {
const dataIdx = dataIdxs[seriesIdx]!;
const actions = getFieldActions(series, field, replaceVariables, dataIdx);
const actions = canExecuteActions ? getFieldActions(series, field, replaceVariables, dataIdx) : [];
footer = (
<VizTooltipFooter dataLinks={dataLinks} actions={actions} annotate={annotate} adHocFilters={adHocFilters} />
+4
View File
@@ -8348,6 +8348,10 @@
"action-title": "Title",
"action-title-placeholder": "Action title",
"action-variables": "Variables",
"connection": "Connection",
"connection-direct-description": "Make request directly from browser",
"connection-direct-label": "Direct from browser",
"connection-placeholder": "Select connection",
"one-click-description": "Only one link or action can have one click enabled at a time"
}
},