-
+ {components.map((Component, i) => (
+
+
))}
diff --git a/public/app/features/alerting/unified/mockGrafanaNotifiers.ts b/public/app/features/alerting/unified/mockGrafanaNotifiers.ts
index 6efaa9d89f8..58c20a86262 100644
--- a/public/app/features/alerting/unified/mockGrafanaNotifiers.ts
+++ b/public/app/features/alerting/unified/mockGrafanaNotifiers.ts
@@ -2461,6 +2461,278 @@ export const grafanaAlertNotifiersMock: NotifierDTO[] = [
},
],
},
+ {
+ type: 'mqtt',
+ name: 'MQTT',
+ heading: 'MQTT settings',
+ description: 'Sends notifications to an MQTT broker',
+ info: 'The MQTT notifier sends messages to an MQTT broker. The message is sent to the topic specified in the configuration. ',
+ options: [
+ {
+ element: 'input',
+ inputType: 'text',
+ label: 'Broker URL',
+ description: 'The URL of the MQTT broker.',
+ placeholder: 'tcp://localhost:1883',
+ propertyName: 'brokerUrl',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: true,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'input',
+ inputType: 'text',
+ label: 'Topic',
+ description: 'The topic to which the message will be sent.',
+ placeholder: 'grafana/alerts',
+ propertyName: 'topic',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: true,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'select',
+ inputType: 'text',
+ label: 'Message format',
+ description:
+ "The format of the message to be sent. If set to 'json', the message will be sent as a JSON object. If set to 'text', the message will be sent as a plain text string. By default json is used.",
+ placeholder: 'json',
+ propertyName: 'messageFormat',
+ selectOptions: [
+ {
+ value: 'json',
+ label: 'json',
+ },
+ {
+ value: 'text',
+ label: 'text',
+ },
+ ],
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'input',
+ inputType: 'text',
+ label: 'Client ID',
+ description: 'The client ID to use when connecting to the MQTT broker. If blank, a random client ID is used.',
+ placeholder: '',
+ propertyName: 'clientId',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'textarea',
+ inputType: '',
+ label: 'Message',
+ description: '',
+ placeholder: '{{ template "default.message" . }}',
+ propertyName: 'message',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'input',
+ inputType: 'text',
+ label: 'Username',
+ description: 'The username to use when connecting to the MQTT broker.',
+ placeholder: '',
+ propertyName: 'username',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'input',
+ inputType: 'text',
+ label: 'Password',
+ description: 'The password to use when connecting to the MQTT broker.',
+ placeholder: '',
+ propertyName: 'password',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: true,
+ dependsOn: '',
+ },
+ {
+ element: 'select',
+ inputType: '',
+ label: 'QoS',
+ description: 'The quality of service to use when sending the message.',
+ placeholder: '',
+ propertyName: 'qos',
+ selectOptions: [
+ {
+ value: '0',
+ label: 'At most once (0)',
+ },
+ {
+ value: '1',
+ label: 'At least once (1)',
+ },
+ {
+ value: '2',
+ label: 'Exactly once (2)',
+ },
+ ],
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'checkbox',
+ inputType: '',
+ label: 'Retain',
+ description: 'If set to true, the message will be retained by the broker.',
+ placeholder: '',
+ propertyName: 'retain',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'subform',
+ inputType: '',
+ label: 'TLS',
+ description: 'TLS configuration options',
+ placeholder: '',
+ propertyName: 'tlsConfig',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ subformOptions: [
+ {
+ element: 'checkbox',
+ inputType: '',
+ label: 'Disable certificate verification',
+ description: "Do not verify the broker's certificate chain and host name.",
+ placeholder: '',
+ propertyName: 'insecureSkipVerify',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: false,
+ dependsOn: '',
+ },
+ {
+ element: 'textarea',
+ inputType: 'text',
+ label: 'CA Certificate',
+ description: "Certificate in PEM format to use when verifying the broker's certificate chain.",
+ placeholder: '',
+ propertyName: 'caCertificate',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: true,
+ dependsOn: '',
+ },
+ {
+ element: 'textarea',
+ inputType: 'text',
+ label: 'Client Certificate',
+ description: 'Client certificate in PEM format to use when connecting to the broker.',
+ placeholder: '',
+ propertyName: 'clientCertificate',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: true,
+ dependsOn: '',
+ },
+ {
+ element: 'textarea',
+ inputType: 'text',
+ label: 'Client Key',
+ description: 'Client key in PEM format to use when connecting to the broker.',
+ placeholder: '',
+ propertyName: 'clientKey',
+ selectOptions: null,
+ showWhen: {
+ field: '',
+ is: '',
+ },
+ required: false,
+ validationRule: '',
+ secure: true,
+ dependsOn: '',
+ },
+ ],
+ },
+ ],
+ },
{
type: 'opsgenie',
name: 'OpsGenie',
diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts
index db47b049265..bfd4ec5467a 100644
--- a/public/app/features/alerting/unified/mocks/server/configure.ts
+++ b/public/app/features/alerting/unified/mocks/server/configure.ts
@@ -17,6 +17,7 @@ import {
getPluginMissingHandler,
} from 'app/features/alerting/unified/mocks/server/handlers/plugins';
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
+import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
import { AlertManagerCortexConfig, AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO } from 'app/types';
@@ -126,6 +127,7 @@ export const removePlugin = (pluginId: string) => {
/** Make a plugin respond with `enabled: false`, as if its installed but disabled */
export const disablePlugin = (pluginId: SupportedPlugin) => {
+ clearPluginSettingsCache(pluginId);
server.use(getDisabledPluginHandler(pluginId));
};
diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
index 8983b0171b0..9963678011a 100644
--- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
+++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts
@@ -43,7 +43,7 @@ const createNamespacedReceiverHandler = () =>
http.post<{ namespace: string }>(
`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/receivers`,
async ({ request }) => {
- const body = await request.json();
+ const body = await request.clone().json();
return HttpResponse.json(body);
}
);
diff --git a/public/app/features/alerting/unified/plugins/useAlertingHomePageExtensions.ts b/public/app/features/alerting/unified/plugins/useAlertingHomePageExtensions.ts
index 5afd7cef697..f25dd4ae68a 100644
--- a/public/app/features/alerting/unified/plugins/useAlertingHomePageExtensions.ts
+++ b/public/app/features/alerting/unified/plugins/useAlertingHomePageExtensions.ts
@@ -1,8 +1,8 @@
import { PluginExtensionPoints } from '@grafana/data';
-import { usePluginComponentExtensions } from '@grafana/runtime';
+import { usePluginComponents } from '@grafana/runtime';
export function useAlertingHomePageExtensions() {
- return usePluginComponentExtensions({
+ return usePluginComponents({
extensionPointId: PluginExtensionPoints.AlertingHomePage,
limitPerPlugin: 1,
});
diff --git a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts
index 237dbbe8154..874bba6f8be 100644
--- a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts
+++ b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { PluginExtensionPoints } from '@grafana/data';
-import { usePluginLinkExtensions } from '@grafana/runtime';
+import { usePluginLinks } from '@grafana/runtime';
import { CombinedRule } from 'app/types/unified-alerting';
import { PromRuleType } from 'app/types/unified-alerting-dto';
@@ -23,7 +23,7 @@ export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext
export function useRulePluginLinkExtension(rule: CombinedRule) {
const ruleExtensionPoint = useRuleExtensionPoint(rule);
- const { extensions } = usePluginLinkExtensions(ruleExtensionPoint);
+ const { links } = usePluginLinks(ruleExtensionPoint);
const ruleOrigin = getRulePluginOrigin(rule);
const ruleType = rule.promRule?.type;
@@ -33,7 +33,7 @@ export function useRulePluginLinkExtension(rule: CombinedRule) {
const { pluginId } = ruleOrigin;
- return extensions.filter((extension) => extension.pluginId === pluginId);
+ return links.filter((link) => link.pluginId === pluginId);
}
export interface PluginRuleExtensionParam {
diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts
index c786c087fae..93d92565750 100644
--- a/public/app/features/alerting/unified/testSetup/plugins.ts
+++ b/public/app/features/alerting/unified/testSetup/plugins.ts
@@ -1,5 +1,5 @@
import { PluginMeta, PluginType } from '@grafana/data';
-import { setPluginExtensionsHook } from '@grafana/runtime';
+import { setPluginComponentsHook, setPluginExtensionsHook } from '@grafana/runtime';
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
import { mockPluginLinkExtension } from '../mocks';
@@ -15,6 +15,10 @@ export function setupPluginsExtensionsHook() {
),
isLoading: false,
}));
+ setPluginComponentsHook(() => ({
+ components: [],
+ isLoading: false,
+ }));
}
export const plugins: PluginMeta[] = [
diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts
index bc857d79d25..0f0a0a9b2d4 100644
--- a/public/app/features/alerting/unified/utils/receiver-form.ts
+++ b/public/app/features/alerting/unified/utils/receiver-form.ts
@@ -1,4 +1,4 @@
-import { isArray, omit, pick, isNil, omitBy } from 'lodash';
+import { get, has, isArray, isNil, omit, omitBy, reduce } from 'lodash';
import {
AlertManagerCortexConfig,
@@ -8,7 +8,7 @@ import {
Receiver,
Route,
} from 'app/plugins/datasource/alertmanager/types';
-import { CloudNotifierType, NotifierDTO, NotifierType } from 'app/types';
+import { CloudNotifierType, NotificationChannelOption, NotifierDTO, NotifierType } from 'app/types';
import {
CloudChannelConfig,
@@ -210,21 +210,39 @@ function grafanaChannelConfigToFormChannelValues(
disableResolveMessage: channel.disableResolveMessage,
};
- // work around https://github.com/grafana/alerting-squad/issues/100
- notifier?.options.forEach((option) => {
- if (option.secure && values.secureSettings[option.propertyName]) {
- delete values.settings[option.propertyName];
- values.secureFields[option.propertyName] = true;
- }
- if (option.secure && values.settings[option.propertyName]) {
- values.secureSettings[option.propertyName] = values.settings[option.propertyName];
- delete values.settings[option.propertyName];
- }
- });
-
return values;
}
+/**
+ * Recursively find all keys that should be marked a secure fields, using JSONpath for nested fields.
+ */
+export function getSecureFieldNames(notifier: NotifierDTO): string[] {
+ // eg. ['foo', 'bar.baz']
+ const secureFieldPaths: string[] = [];
+
+ // we'll pass in the prefix for each iteration so we can track the JSON path
+ function findSecureOptions(options: NotificationChannelOption[], prefix?: string) {
+ for (const option of options) {
+ const key = prefix ? `${prefix}.${option.propertyName}` : option.propertyName;
+
+ // if the field is a subform, recurse
+ if (option.subformOptions) {
+ findSecureOptions(option.subformOptions, key);
+ continue;
+ }
+
+ if (option.secure) {
+ secureFieldPaths.push(key);
+ continue;
+ }
+ }
+ }
+
+ findSecureOptions(notifier.options);
+
+ return secureFieldPaths;
+}
+
export function formChannelValuesToGrafanaChannelConfig(
values: GrafanaChannelValues,
defaults: GrafanaChannelValues,
@@ -245,13 +263,21 @@ export function formChannelValuesToGrafanaChannelConfig(
};
// find all secure field definitions
- const secureFieldNames: string[] =
- notifier?.options.filter((option) => option.secure).map((option) => option.propertyName) ?? [];
+ const secureFieldNames = notifier ? getSecureFieldNames(notifier) : [];
// we make sure all fields that are marked as "secure" will be moved to "SecureSettings" instead of "settings"
- const shouldBeSecure = pick(channel.settings, secureFieldNames);
+ const secureSettings = reduce(
+ secureFieldNames,
+ (acc: Record
= {}, key) => {
+ // the value for secure settings can come from either the "settings" (accidental) or "secureFields" if editing an existing receiver
+ acc[key] = get(channel.settings, key) ?? get(values.secureFields, key);
+ return acc;
+ },
+ {}
+ );
+
channel.secureSettings = {
- ...shouldBeSecure,
+ ...secureSettings,
...channel.secureSettings,
};
@@ -291,7 +317,7 @@ export function omitEmptyValues(obj: T): T {
// Will remove empty ('', null, undefined) object properties unless they were previously defined.
// existing is a map of property names that were previously defined.
export function omitEmptyUnlessExisting(settings = {}, existing = {}): Record {
- return omitBy(settings, (value, key) => isUnacceptableValue(value) && !(key in existing));
+ return omitBy(settings, (value, key) => isUnacceptableValue(value) && !has(existing, key));
}
export function omitTemporaryIdentifiers(object: Readonly): T {
diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx
index 9f0860bda70..560c1a3a79a 100644
--- a/public/app/features/auth-config/AuthProvidersListPage.tsx
+++ b/public/app/features/auth-config/AuthProvidersListPage.tsx
@@ -52,11 +52,15 @@ export const AuthConfigPageUnconnected = ({
reportInteraction('authentication_ui_provider_clicked', { provider: providerType, enabled });
};
- // filter out saml and ldap from sso providers because it is already included in availableProviders
+ // filter out saml from sso providers because it is already included in availableProviders
providers = providers.filter((p) => p.provider !== 'saml');
- // temporarily remove LDAP until its configuration form is ready
- providers = providers.filter((p) => p.provider !== 'ldap');
+ providers = providers.map((p) => {
+ if (p.provider === 'ldap') {
+ p.settings.type = p.provider;
+ }
+ return p;
+ });
const providerList = availableProviders.length
? [
diff --git a/public/app/features/auth-config/index.ts b/public/app/features/auth-config/index.ts
index 015d65622e0..cca2b7208af 100644
--- a/public/app/features/auth-config/index.ts
+++ b/public/app/features/auth-config/index.ts
@@ -1,3 +1,4 @@
+import config from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { AccessControlAction, Settings, SettingsSection } from 'app/types';
@@ -51,6 +52,11 @@ export async function getAuthProviderStatus(providerId: string): Promise {
border?: LineConfig;
connections?: CanvasConnection[];
links?: DataLink[];
+ actions?: Action[];
oneClickMode?: OneClickMode;
}
diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx
index 34083445bd4..1ce638c47c6 100644
--- a/public/app/features/canvas/runtime/element.tsx
+++ b/public/app/features/canvas/runtime/element.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
import { CSSProperties } from 'react';
import { OnDrag, OnResize, OnRotate } from 'react-moveable/declaration/types';
-import { FieldType, getLinksSupplier, LinkModel, OneClickMode, ValueLinkConfig } from '@grafana/data';
+import { FieldType, getLinksSupplier, LinkModel, OneClickMode, ScopedVars, ValueLinkConfig } from '@grafana/data';
import { LayerElement } from 'app/core/components/Layers/types';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions';
@@ -15,6 +15,7 @@ import {
} from 'app/plugins/panel/canvas/panelcfg.gen';
import { getConnectionsByTarget, getRowIndex, isConnectionTarget } from 'app/plugins/panel/canvas/utils';
+import { getActions, getActionsDefaultField } from '../../actions/utils';
import { CanvasElementItem, CanvasElementOptions } from '../element';
import { canvasElementRegistry } from '../registry';
@@ -379,7 +380,7 @@ export class ElementState implements LayerElement {
const defaultField = {
name: 'Default field',
type: FieldType.string,
- config: { links: this.options.links ?? [] },
+ config: { links: this.options.links ?? [], actions: this.options.actions ?? [] },
values: [],
};
@@ -597,12 +598,22 @@ export class ElementState implements LayerElement {
const shouldHandleOneClickLink =
this.options.oneClickMode === OneClickMode.Link && this.options.links && this.options.links.length > 0;
+
+ const shouldHandleOneClickAction =
+ this.options.oneClickMode === OneClickMode.Action && this.options.actions && this.options.actions.length > 0;
+
if (shouldHandleOneClickLink && this.div) {
const primaryDataLink = this.getPrimaryDataLink();
if (primaryDataLink) {
this.div.style.cursor = 'pointer';
this.div.title = `Navigate to ${primaryDataLink.title === '' ? 'data link' : primaryDataLink.title}`;
}
+ } else if (shouldHandleOneClickAction && this.div) {
+ const primaryAction = this.getPrimaryAction();
+ if (primaryAction) {
+ this.div.style.cursor = 'pointer';
+ this.div.title = primaryAction.title;
+ }
}
};
@@ -615,6 +626,38 @@ export class ElementState implements LayerElement {
return undefined;
};
+ getPrimaryAction = () => {
+ const config: ValueLinkConfig = { valueRowIndex: getRowIndex(this.data.field, this.getScene()!) };
+ const actionsDefaultFieldConfig = { links: this.options.links ?? [], actions: this.options.actions ?? [] };
+ const frames = this.getScene()?.data?.series;
+
+ if (frames) {
+ const defaultField = getActionsDefaultField(actionsDefaultFieldConfig.links, actionsDefaultFieldConfig.actions);
+ const scopedVars: ScopedVars = {
+ __dataContext: {
+ value: {
+ data: frames,
+ field: defaultField,
+ frame: frames[0],
+ frameIndex: 0,
+ },
+ },
+ };
+
+ const actions = getActions(
+ frames[0],
+ defaultField,
+ scopedVars,
+ this.getScene()?.panel.props.replaceVariables!,
+ actionsDefaultFieldConfig.actions,
+ config
+ );
+ return actions[0];
+ }
+
+ return undefined;
+ };
+
handleTooltip = (event: React.MouseEvent) => {
const scene = this.getScene();
if (scene?.tooltipCallback) {
@@ -646,6 +689,11 @@ export class ElementState implements LayerElement {
if (primaryDataLink) {
window.open(primaryDataLink.href, primaryDataLink.target);
}
+ } else if (this.options.oneClickMode === OneClickMode.Action) {
+ let primaryAction = this.getPrimaryAction();
+ if (primaryAction && primaryAction.onClick) {
+ primaryAction.onClick(event);
+ }
} else {
this.handleTooltip(event);
this.onTooltipCallback();
diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx
index 6784acd8636..902cc24640e 100644
--- a/public/app/features/canvas/runtime/scene.tsx
+++ b/public/app/features/canvas/runtime/scene.tsx
@@ -281,9 +281,10 @@ export class Scene {
};
render() {
- const isTooltipValid =
- (this.tooltip?.element?.getLinks && this.tooltip?.element?.getLinks({}).length > 0) ||
- this.tooltip?.element?.data?.field;
+ const hasDataLinks = this.tooltip?.element?.getLinks && this.tooltip.element.getLinks({}).length > 0;
+ const hasActions = this.tooltip?.element?.options.actions && this.tooltip.element.options.actions.length > 0;
+
+ const isTooltipValid = hasDataLinks || hasActions || this.tooltip?.element?.data?.field;
const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid;
const sceneDiv = (
diff --git a/public/app/features/commandPalette/actions/useExtensionActions.ts b/public/app/features/commandPalette/actions/useExtensionActions.ts
index 3861b685793..14b0c19b8cf 100644
--- a/public/app/features/commandPalette/actions/useExtensionActions.ts
+++ b/public/app/features/commandPalette/actions/useExtensionActions.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { PluginExtensionCommandPaletteContext, PluginExtensionPoints } from '@grafana/data';
-import { usePluginLinkExtensions } from '@grafana/runtime';
+import { usePluginLinks } from '@grafana/runtime';
import { CommandPaletteAction } from '../types';
import { EXTENSIONS_PRIORITY } from '../values';
@@ -10,20 +10,20 @@ import { EXTENSIONS_PRIORITY } from '../values';
const context: PluginExtensionCommandPaletteContext = {};
export default function useExtensionActions(): CommandPaletteAction[] {
- const { extensions } = usePluginLinkExtensions({
+ const { links } = usePluginLinks({
extensionPointId: PluginExtensionPoints.CommandPalette,
context,
limitPerPlugin: 3,
});
return useMemo(() => {
- return extensions.map((extension) => ({
- section: extension.category ?? 'Extensions',
+ return links.map((link) => ({
+ section: link.category ?? 'Extensions',
priority: EXTENSIONS_PRIORITY,
- id: extension.id,
- name: extension.title,
- target: extension.path,
- perform: () => extension.onClick && extension.onClick(),
+ id: link.id,
+ name: link.title,
+ target: link.path,
+ perform: () => link.onClick && link.onClick(),
}));
- }, [extensions]);
+ }, [links]);
}
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
index e7524d1ceeb..11146de7f4d 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
@@ -220,7 +220,7 @@ describe('DashboardScenePage', () => {
describe('empty state', () => {
it('Shows empty state when dashboard is empty', async () => {
- loadDashboardMock.mockResolvedValue({ dashboard: { panels: [] }, meta: {} });
+ loadDashboardMock.mockResolvedValue({ dashboard: { uid: 'my-dash-uid', panels: [] }, meta: {} });
setup();
expect(await screen.findByText('Start your new dashboard by adding a visualization')).toBeInTheDocument();
@@ -299,7 +299,7 @@ describe('DashboardScenePage', () => {
it('should show controls', async () => {
getDashboardScenePageStateManager().clearDashboardCache();
loadDashboardMock.mockClear();
- loadDashboardMock.mockResolvedValue({ dashboard: { panels: [] }, meta: {} });
+ loadDashboardMock.mockResolvedValue({ dashboard: { uid: 'my-dash-uid', panels: [] }, meta: {} });
setup();
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
index 2a368ffd91f..9d004d346f1 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
@@ -78,7 +78,7 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props
// Do not render anything when transitioning from one dashboard to another
if (
match.params.type !== 'snapshot' &&
- dashboard.state.uid &&
+ match.params.uid &&
dashboard.state.uid !== match.params.uid &&
route.routeName !== DashboardRoutes.Home
) {
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx
index bfc51341d6c..c315a73bcc9 100644
--- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx
+++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx
@@ -5,7 +5,7 @@ import { byTestId } from 'testing-library-selector';
import { DataSourceApi } from '@grafana/data';
import { PromOptions, PrometheusDatasource } from '@grafana/prometheus';
-import { locationService, setDataSourceSrv, setPluginExtensionsHook } from '@grafana/runtime';
+import { locationService, setDataSourceSrv, setPluginLinksHook } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import * as ruler from 'app/features/alerting/unified/api/ruler';
import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
@@ -50,8 +50,8 @@ jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false);
jest.spyOn(ruler, 'rulerUrlBuilder');
jest.spyOn(alertingAbilities, 'useAlertRuleAbility');
-setPluginExtensionsHook(() => ({
- extensions: [],
+setPluginLinksHook(() => ({
+ links: [],
isLoading: false,
}));
diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.test.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.test.ts
index 474907a347f..80dbb4e3cb3 100644
--- a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.test.ts
+++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.test.ts
@@ -25,11 +25,11 @@ describe('DashboardSceneChangeTracker', () => {
() =>
({
terminate,
- }) as any
+ }) as unknown as CorsWorker
);
const changeTracker = new DashboardSceneChangeTracker({
subscribeToEvent: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
- } as any);
+ } as unknown as DashboardScene);
changeTracker.startTrackingChanges();
expect(changeTracker['_changesWorker']).not.toBeUndefined();
diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx
index 1241fffc6f0..9232b5e9c68 100644
--- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx
+++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx
@@ -1,5 +1,5 @@
import debounce from 'debounce-promise';
-import { ChangeEvent } from 'react';
+import { ChangeEvent, useState } from 'react';
import { UseFormSetValue, useForm } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
@@ -47,6 +47,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
const { state, onSaveDashboard } = useSaveDashboard(false);
+ const [contentSent, setContentSent] = useState<{ title?: string; folderUid?: string }>({});
const onSave = async (overwrite: boolean) => {
const data = getValues();
@@ -55,6 +56,11 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
if (result.status === 'success') {
dashboard.closeModal();
+ } else {
+ setContentSent({
+ title: data.title,
+ folderUid: data.folder.uid,
+ });
}
};
@@ -69,15 +75,16 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
);
function renderFooter(error?: Error) {
- if (isNameExistsError(error)) {
+ const formValuesMatchContentSent =
+ formValues.title.trim() === contentSent.title && formValues.folder.uid === contentSent.folderUid;
+ if (isNameExistsError(error) && formValuesMatchContentSent) {
return ;
}
-
return (
<>
- {error && (
+ {error && formValuesMatchContentSent && (
- {error.message}
+ {error.message && {error.message}
}
)}
@@ -118,7 +125,9 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
setValue('folder', { uid, title })}
+ onChange={(uid: string | undefined, title: string | undefined) => {
+ setValue('folder', { uid, title });
+ }}
// Old folder picker fields
value={formValues.folder?.uid}
initialTitle={defaultValues!.folder!.title}
diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx
index 4f6c19b4860..ce5d1f04606 100644
--- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx
@@ -569,6 +569,87 @@ describe('DashboardDatasourceBehaviour', () => {
expect(spy).toHaveBeenCalledTimes(1);
});
+
+ it('should wait for library panel to load before running queries', async () => {
+ const libPanelBehavior = new LibraryPanelBehavior({
+ isLoaded: false,
+ title: 'Panel title',
+ uid: 'fdcvggvfy2qdca',
+ name: 'My Library Panel',
+ _loadedPanel: undefined,
+ });
+
+ const sourcePanel = new VizPanel({
+ key: 'panel-1',
+ title: 'Panel A',
+ pluginId: 'table',
+ $behaviors: [libPanelBehavior],
+ $data: new SceneQueryRunner({
+ datasource: { uid: 'grafana' },
+ queries: [{ refId: 'A', queryType: 'randomWalk' }],
+ }),
+ });
+
+ // query references inexistent panel
+ const dashboardDSPanel = new VizPanel({
+ title: 'Panel B',
+ pluginId: 'table',
+ key: 'panel-2',
+ $data: new SceneQueryRunner({
+ datasource: { uid: SHARED_DASHBOARD_QUERY },
+ queries: [{ refId: 'A', panelId: 1 }],
+ $behaviors: [new DashboardDatasourceBehaviour({})],
+ }),
+ });
+
+ const scene = new DashboardScene({
+ title: 'hello',
+ uid: 'dash-1',
+ meta: {
+ canEdit: true,
+ },
+ body: new SceneGridLayout({
+ children: [
+ new DashboardGridItem({
+ key: 'griditem-1',
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 12,
+ body: sourcePanel,
+ }),
+ new DashboardGridItem({
+ key: 'griditem-2',
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 12,
+ body: dashboardDSPanel,
+ }),
+ ],
+ }),
+ });
+
+ activateFullSceneTree(scene);
+
+ // spy on runQueries
+ const spyRunQueries = jest.spyOn(dashboardDSPanel.state.$data as SceneQueryRunner, 'runQueries');
+
+ await new Promise((r) => setTimeout(r, 1));
+
+ expect(spyRunQueries).not.toHaveBeenCalled();
+
+ // Simulate library panel being loaded
+ libPanelBehavior.setState({
+ isLoaded: true,
+ title: 'Panel title',
+ uid: 'fdcvggvfy2qdca',
+ name: 'My Library Panel',
+ _loadedPanel: undefined,
+ });
+
+ expect(spyRunQueries).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx
index 05880cecc19..59e992a91a1 100644
--- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx
@@ -1,9 +1,18 @@
+import { Unsubscribable } from 'rxjs';
+
import { SceneObjectBase, SceneObjectState, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
-import { findVizPanelByKey, getDashboardSceneFor, getQueryRunnerFor, getVizPanelKeyForPanelId } from '../utils/utils';
+import {
+ findVizPanelByKey,
+ getDashboardSceneFor,
+ getLibraryPanelBehavior,
+ getQueryRunnerFor,
+ getVizPanelKeyForPanelId,
+} from '../utils/utils';
import { DashboardScene } from './DashboardScene';
+import { LibraryPanelBehaviorState } from './LibraryPanelBehavior';
interface DashboardDatasourceBehaviourState extends SceneObjectState {}
@@ -16,48 +25,77 @@ export class DashboardDatasourceBehaviour extends SceneObjectBase query.panelId !== undefined);
+ const dashboardQuery = dashboardDsQueryRunner.state.queries.find((query) => query.panelId !== undefined);
if (!dashboardQuery) {
return;
}
+ // find the source panel referenced in the the dashboard ds query
const panelId = dashboardQuery.panelId;
const vizKey = getVizPanelKeyForPanelId(panelId);
- const panel = findVizPanelByKey(dashboard, vizKey);
+ const sourcePanel = findVizPanelByKey(dashboard, vizKey);
- if (!(panel instanceof VizPanel)) {
+ if (!(sourcePanel instanceof VizPanel)) {
return;
}
- const sourcePanelQueryRunner = getQueryRunnerFor(panel);
+ //check if the source panel is a library panel and wait for it to load
+ const libraryPanelBehaviour = getLibraryPanelBehavior(sourcePanel);
+ if (libraryPanelBehaviour && !libraryPanelBehaviour.state.isLoaded) {
+ libraryPanelSub = libraryPanelBehaviour.subscribeToState((newLibPanel) => {
+ this.handleLibPanelStateUpdates(newLibPanel, dashboardDsQueryRunner, sourcePanel);
+ });
+ return;
+ }
+
+ const sourcePanelQueryRunner = getQueryRunnerFor(sourcePanel);
if (!sourcePanelQueryRunner) {
throw new Error('Could not find SceneQueryRunner for panel');
}
if (this.prevRequestId && this.prevRequestId !== sourcePanelQueryRunner.state.data?.request?.requestId) {
- queryRunner.runQueries();
+ dashboardDsQueryRunner.runQueries();
}
return () => {
this.prevRequestId = sourcePanelQueryRunner?.state.data?.request?.requestId;
+ if (libraryPanelSub) {
+ libraryPanelSub.unsubscribe();
+ }
};
}
+
+ private handleLibPanelStateUpdates(
+ newLibPanel: LibraryPanelBehaviorState,
+ dashboardDsQueryRunner: SceneQueryRunner,
+ sourcePanel: VizPanel
+ ) {
+ if (newLibPanel && newLibPanel?.isLoaded) {
+ const libPanelQueryRunner = getQueryRunnerFor(sourcePanel);
+
+ if (!(libPanelQueryRunner instanceof SceneQueryRunner)) {
+ throw new Error('Could not find SceneQueryRunner for library panel');
+ }
+ dashboardDsQueryRunner.runQueries();
+ }
+ }
}
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
index e1874dfb15d..48424ea2b1a 100644
--- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
@@ -935,7 +935,9 @@ export class DashboardVariableDependency implements SceneVariableDependencyConfi
for (const behavior of child.state.$behaviors) {
if (behavior instanceof RowRepeaterBehavior) {
if (behavior.isWaitingForVariables || (behavior.state.variableName === variable.state.name && hasChanged)) {
- behavior.performRepeat();
+ behavior.performRepeat(true);
+ } else if (!behavior.isWaitingForVariables && behavior.state.variableName === variable.state.name) {
+ behavior.notifyRepeatedPanelsWaitingForVariables(variable);
}
}
}
diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
index 522d28e25af..49571a3ffa8 100644
--- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
@@ -132,7 +132,7 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number | undefined) {
label: 'canvas-content',
display: 'flex',
flexDirection: 'column',
- padding: theme.spacing(0, 2),
+ padding: theme.spacing(0.5, 2),
flexBasis: '100%',
gridArea: 'panels',
flexGrow: 1,
diff --git a/public/app/features/dashboard-scene/scene/DashboardScopesFacade.ts b/public/app/features/dashboard-scene/scene/DashboardScopesFacade.ts
new file mode 100644
index 00000000000..0b6267cde58
--- /dev/null
+++ b/public/app/features/dashboard-scene/scene/DashboardScopesFacade.ts
@@ -0,0 +1,22 @@
+import { locationService } from '@grafana/runtime';
+import { sceneGraph } from '@grafana/scenes';
+import { ScopesFacade } from 'app/features/scopes';
+
+export interface DashboardScopesFacadeState {
+ reloadOnScopesChange?: boolean;
+ uid?: string;
+}
+
+export class DashboardScopesFacade extends ScopesFacade {
+ constructor({ reloadOnScopesChange, uid }: DashboardScopesFacadeState) {
+ super({
+ handler: (facade) => {
+ if (reloadOnScopesChange && uid) {
+ locationService.reload();
+ } else {
+ sceneGraph.getTimeRange(facade).onRefresh();
+ }
+ },
+ });
+ }
+}
diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx
index 501f38e527f..d3848d1fccf 100644
--- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx
+++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx
@@ -10,7 +10,7 @@ import { createPanelDataProvider } from '../utils/createPanelDataProvider';
import { DashboardGridItem } from './DashboardGridItem';
-interface LibraryPanelBehaviorState extends SceneObjectState {
+export interface LibraryPanelBehaviorState extends SceneObjectState {
// Library panels use title from dashboard JSON's panel model, not from library panel definition, hence we pass it.
title?: string;
uid: string;
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx
index af7d4d4a858..22c2acde4fa 100644
--- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx
@@ -28,7 +28,7 @@ jest.mock('app/features/playlist/PlaylistSrv', () => ({
}));
jest.mock('@grafana/runtime', () => ({
- ...jest.requireActual>('@grafana/runtime'),
+ ...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
get: jest.fn(),
getInstanceSettings: jest.fn().mockReturnValue({
diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx
index bddb841cc14..f9cd350a9a7 100644
--- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx
+++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx
@@ -1,3 +1,4 @@
+import { VariableRefresh } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import {
@@ -144,23 +145,25 @@ describe('RowRepeaterBehavior', () => {
expect(gridStateUpdates.length).toBe(1);
});
- });
- describe('Should not repeat row', () => {
- it('Should ignore repeat process if the variable is not a multi select variable', async () => {
- const { scene, grid, repeatBehavior } = buildScene({ variableQueryTime: 0 }, undefined, { isMulti: false });
- const gridStateUpdates = [];
- grid.subscribeToState((state) => gridStateUpdates.push(state));
+ it('Should update panels on refresh if variables load on time range change', async () => {
+ const { scene, repeatBehavior } = buildScene({
+ variableQueryTime: 0,
+ variableRefresh: VariableRefresh.onTimeRangeChanged,
+ });
+
+ const notifyPanelsSpy = jest.spyOn(repeatBehavior, 'notifyRepeatedPanelsWaitingForVariables');
activateFullSceneTree(scene);
- await new Promise((r) => setTimeout(r, 1));
- // trigger another repeat cycle by changing the variable
- repeatBehavior.performRepeat();
+ expect(notifyPanelsSpy).toHaveBeenCalledTimes(0);
- await new Promise((r) => setTimeout(r, 1));
+ scene.state.$timeRange?.onRefresh();
- expect(gridStateUpdates.length).toBe(0);
+ //make sure notifier is called
+ expect(notifyPanelsSpy).toHaveBeenCalledTimes(1);
+
+ notifyPanelsSpy.mockRestore();
});
});
@@ -251,6 +254,7 @@ interface SceneOptions {
maxPerRow?: number;
itemHeight?: number;
repeatDirection?: RepeatDirection;
+ variableRefresh?: VariableRefresh;
}
function buildScene(
@@ -334,6 +338,7 @@ function buildScene(
isMulti: true,
includeAll: true,
delayMs: options.variableQueryTime,
+ refresh: options.variableRefresh,
optionsToReturn: variableOptions ?? [
{ label: 'A', value: 'A1' },
{ label: 'B', value: 'B1' },
diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts
index 663315e5d04..cf28361b3e5 100644
--- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts
+++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts
@@ -9,13 +9,14 @@ import {
SceneGridRow,
SceneObjectBase,
SceneObjectState,
+ SceneVariable,
SceneVariableSet,
VariableDependencyConfig,
VariableValueSingle,
VizPanelMenu,
} from '@grafana/scenes';
-import { getMultiVariableValues } from '../utils/utils';
+import { getMultiVariableValues, getQueryRunnerFor } from '../utils/utils';
import { DashboardGridItem } from './DashboardGridItem';
import { repeatPanelMenuBehavior } from './PanelMenuBehavior';
@@ -37,6 +38,7 @@ export class RowRepeaterBehavior extends SceneObjectBase this._activationHandler());
}
+ public notifyRepeatedPanelsWaitingForVariables(variable: SceneVariable) {
+ const allRows = [this._getRow(), ...(this._clonedRows ?? [])];
+
+ for (const row of allRows) {
+ for (const gridItem of row.state.children) {
+ if (!(gridItem instanceof DashboardGridItem)) {
+ continue;
+ }
+
+ const queryRunner = getQueryRunnerFor(gridItem.state.body);
+ if (queryRunner) {
+ queryRunner.variableDependency?.variableUpdateCompleted(variable, false);
+ }
+ }
+ }
+ }
+
private _activationHandler() {
this.performRepeat();
@@ -122,12 +141,7 @@ export class RowRepeaterBehavior extends SceneObjectBase {
describe('Repeating rows', () => {
it('Should build correct scene model', () => {
- const scene = transformSaveModelToScene({ dashboard: repeatingRowsAndPanelsDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({
+ dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
+ meta: {},
+ });
const body = scene.state.body as SceneGridLayout;
const row2 = body.state.children[1] as SceneGridRow;
@@ -762,7 +765,7 @@ describe('transformSaveModelToScene', () => {
describe('Annotation queries', () => {
it('Should build correct scene model', () => {
- const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as DashboardDataDTO, meta: {} });
expect(scene.state.$data).toBeInstanceOf(DashboardDataLayerSet);
expect(scene.state.controls!.state.variableControls[1]).toBeInstanceOf(SceneDataLayerControls);
@@ -790,7 +793,7 @@ describe('transformSaveModelToScene', () => {
describe('Alerting data layer', () => {
it('Should add alert states data layer if unified alerting enabled', () => {
config.unifiedAlertingEnabled = true;
- const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as DashboardDataDTO, meta: {} });
expect(scene.state.$data).toBeInstanceOf(DashboardDataLayerSet);
expect(scene.state.controls!.state.variableControls[1]).toBeInstanceOf(SceneDataLayerControls);
@@ -803,7 +806,7 @@ describe('transformSaveModelToScene', () => {
config.unifiedAlertingEnabled = false;
const dashboard = { ...dashboard_to_load1 } as unknown as DashboardDataDTO;
dashboard.panels![0].alert = {};
- const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as DashboardDataDTO, meta: {} });
expect(scene.state.$data).toBeInstanceOf(DashboardDataLayerSet);
expect(scene.state.controls!.state.variableControls[1]).toBeInstanceOf(SceneDataLayerControls);
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
index 5f761ad10ab..0195625f24b 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
@@ -19,10 +19,8 @@ import {
SceneDataLayerProvider,
SceneDataLayerControls,
UserActionEvent,
- sceneGraph,
} from '@grafana/scenes';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
-import { ScopesFacade } from 'app/features/scopes';
import { DashboardDTO, DashboardDataDTO } from 'app/types';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
@@ -32,6 +30,7 @@ import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardGridItem, RepeatDirection } from '../scene/DashboardGridItem';
import { registerDashboardMacro } from '../scene/DashboardMacro';
import { DashboardScene } from '../scene/DashboardScene';
+import { DashboardScopesFacade } from '../scene/DashboardScopesFacade';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
@@ -245,8 +244,9 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
- new ScopesFacade({
- handler: (facade) => sceneGraph.getTimeRange(facade).onRefresh(),
+ new DashboardScopesFacade({
+ reloadOnScopesChange: oldModel.meta.reloadOnScopesChange,
+ uid: oldModel.uid,
}),
],
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
index 94497918e64..7f051e9924f 100644
--- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
@@ -28,6 +28,7 @@ import { PanelModel } from 'app/features/dashboard/state';
import { getTimeRange } from 'app/features/dashboard/utils/timeRange';
import { reduceTransformRegistryItem } from 'app/features/transformers/editors/ReduceTransformerEditor';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
+import { DashboardDataDTO } from 'app/types';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
@@ -192,7 +193,7 @@ describe('transformSceneToSaveModel', () => {
},
links: [{ ...NEW_LINK, title: 'Link 1' }],
};
- const scene = transformSaveModelToScene({ dashboard: dashboardWithCustomSettings as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: dashboardWithCustomSettings as DashboardDataDTO, meta: {} });
const saveModel = transformSceneToSaveModel(scene);
expect(saveModel).toMatchSnapshot();
@@ -201,7 +202,7 @@ describe('transformSceneToSaveModel', () => {
describe('Given a simple scene with variables', () => {
it('Should transform back to persisted model', () => {
- const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as DashboardDataDTO, meta: {} });
const saveModel = transformSceneToSaveModel(scene);
expect(saveModel).toMatchSnapshot();
@@ -210,7 +211,10 @@ describe('transformSceneToSaveModel', () => {
describe('Given a scene with rows', () => {
it('Should transform back to persisted model', () => {
- const scene = transformSaveModelToScene({ dashboard: repeatingRowsAndPanelsDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({
+ dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
+ meta: {},
+ });
const saveModel = transformSceneToSaveModel(scene);
@@ -222,7 +226,10 @@ describe('transformSceneToSaveModel', () => {
});
it('Should remove repeated rows in save model', () => {
- const scene = transformSaveModelToScene({ dashboard: repeatingRowsAndPanelsDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({
+ dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
+ meta: {},
+ });
const variable = scene.state.$variables?.state.variables[0] as MultiValueVariable;
variable.changeValueTo(['a', 'b', 'c']);
@@ -427,7 +434,7 @@ describe('transformSceneToSaveModel', () => {
describe('Annotations', () => {
it('should transform annotations to save model', () => {
- const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as DashboardDataDTO, meta: {} });
const saveModel = transformSceneToSaveModel(scene);
expect(saveModel.annotations?.list?.length).toBe(4);
@@ -435,7 +442,7 @@ describe('transformSceneToSaveModel', () => {
});
it('should transform annotations to save model after state changes', () => {
- const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as DashboardDataDTO, meta: {} });
const layers = (scene.state.$data as DashboardDataLayerSet)?.state.annotationLayers;
const enabledLayer = layers[1];
@@ -678,7 +685,7 @@ describe('transformSceneToSaveModel', () => {
});
it('attaches snapshot data to panels using Grafana snapshot query', async () => {
- const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as DashboardDataDTO, meta: {} });
activateFullSceneTree(scene);
@@ -733,7 +740,10 @@ describe('transformSceneToSaveModel', () => {
});
it('handles basic rows', async () => {
- const scene = transformSaveModelToScene({ dashboard: snapshotableWithRowsDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({
+ dashboard: snapshotableWithRowsDashboardJson as DashboardDataDTO,
+ meta: {},
+ });
activateFullSceneTree(scene);
@@ -940,7 +950,7 @@ describe('transformSceneToSaveModel', () => {
let snapshot: Dashboard = {} as Dashboard;
beforeEach(() => {
- const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as DashboardDataDTO, meta: {} });
activateFullSceneTree(scene);
snapshot = transformSceneToSaveModel(scene, true);
});
@@ -1004,7 +1014,7 @@ describe('transformSceneToSaveModel', () => {
});
it('should remove links', async () => {
- const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as DashboardDataDTO, meta: {} });
activateFullSceneTree(scene);
const snapshot = transformSceneToSaveModel(scene, true);
expect(snapshot.links?.length).toBe(1);
@@ -1104,7 +1114,10 @@ describe('transformSceneToSaveModel', () => {
describe('Given a scene with repeated panels and non-repeated panels', () => {
it('should save repeated panels itemHeight as height', () => {
- const scene = transformSaveModelToScene({ dashboard: repeatingRowsAndPanelsDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({
+ dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
+ meta: {},
+ });
const gridItem = sceneGraph.findByKey(scene, 'grid-item-2') as DashboardGridItem;
expect(gridItem).toBeInstanceOf(DashboardGridItem);
expect(gridItem.state.height).toBe(10);
@@ -1117,7 +1130,10 @@ describe('transformSceneToSaveModel', () => {
});
it('should not save non-repeated panels itemHeight as height', () => {
- const scene = transformSaveModelToScene({ dashboard: repeatingRowsAndPanelsDashboardJson as any, meta: {} });
+ const scene = transformSaveModelToScene({
+ dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
+ meta: {},
+ });
const gridItem = sceneGraph.findByKey(scene, 'grid-item-15') as DashboardGridItem;
expect(gridItem).toBeInstanceOf(DashboardGridItem);
expect(gridItem.state.height).toBe(2);
diff --git a/public/app/features/dashboard-scene/settings/annotations/AngularEditorLoader.tsx b/public/app/features/dashboard-scene/settings/annotations/AngularEditorLoader.tsx
index edd42f4b7ff..5f24391fbbb 100644
--- a/public/app/features/dashboard-scene/settings/annotations/AngularEditorLoader.tsx
+++ b/public/app/features/dashboard-scene/settings/annotations/AngularEditorLoader.tsx
@@ -1,7 +1,7 @@
import { PureComponent } from 'react';
import { AnnotationQuery, DataSourceApi } from '@grafana/data';
-import { AngularComponent, getAngularLoader } from '@grafana/runtime';
+import { AngularComponent, config, getAngularLoader } from '@grafana/runtime';
export interface Props {
annotation: AnnotationQuery;
@@ -29,7 +29,9 @@ export class AngularEditorLoader extends PureComponent {
}
componentDidMount() {
- if (this.ref) {
+ // check if angular support is enabled in the instance
+ const isAngularEnabled = config.angularSupportEnabled;
+ if (this.ref && isAngularEnabled) {
this.loadAngular();
}
}
diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx
index 0130441e843..a4819435141 100644
--- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx
+++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsJson.tsx
@@ -42,7 +42,7 @@ function ExportAsJsonRenderer({ model }: SceneComponentProps) {
const switchLabel = t('export.json.export-externally-label', 'Export the dashboard to use in another instance');
return (
- <>
+
Copy or download a JSON file containing the JSON of your dashboard
@@ -78,7 +78,7 @@ function ExportAsJsonRenderer({ model }: SceneComponentProps) {
}}
-
);
}
function getStyles(theme: GrafanaTheme2) {
return {
+ container: css({
+ height: '100%',
+ }),
codeEditorBox: css({
- margin: `${theme.spacing(2)} 0`,
+ margin: `${theme.spacing(2, 0)}`,
height: '75%',
}),
- container: css({
+ buttonsContainer: css({
paddingBottom: theme.spacing(2),
}),
};
diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx
index 3735dd068c8..b56c817c62c 100644
--- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx
+++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx
@@ -24,10 +24,14 @@ export interface ShareDrawerMenuItem {
onClick: (d: DashboardScene) => void;
}
-const customShareDrawerItem: ShareDrawerMenuItem[] = [];
+let customShareDrawerItems: ShareDrawerMenuItem[] = [];
export function addDashboardShareDrawerItem(item: ShareDrawerMenuItem) {
- customShareDrawerItem.push(item);
+ customShareDrawerItems.push(item);
+}
+
+export function resetDashboardShareDrawerItems() {
+ customShareDrawerItems = [];
}
export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardScene; panel?: VizPanel }) {
@@ -59,7 +63,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
},
});
- customShareDrawerItem.forEach((d) => menuItems.push(d));
+ customShareDrawerItems.forEach((d) => menuItems.push(d));
menuItems.push({
shareId: shareDashboardType.snapshot,
@@ -88,7 +92,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc