Actions: Infinity authentication (#109493)
This commit is contained in:
@@ -849,6 +849,8 @@ export {
|
||||
defaultActionConfig,
|
||||
contentTypeOptions,
|
||||
httpMethodOptions,
|
||||
type FetchOptions,
|
||||
type InfinityOptions,
|
||||
} from './types/action';
|
||||
export { DataFrameType } from './types/dataFrameTypes';
|
||||
export {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-3
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user