{
+ const pluginContext = usePluginContext();
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
const { activePluginId } = useSidecar();
+ const { extensionPointId, context, limitPerPlugin } = options;
const { extensions } = useMemo(() => {
+ // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
+ const enableRestrictions = isGrafanaDevMode() && pluginContext !== null;
+ const pluginId = pluginContext?.meta.id ?? '';
+
if (!addedLinksRegistry && !addedComponentsRegistry) {
return { extensions: [], isLoading: false };
}
+ if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
+ logWarning(
+ `Extension point usePluginExtensions("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").`
+ );
+ return {
+ isLoading: false,
+ extensions: [],
+ };
+ }
+
+ if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
+ logWarning(
+ `Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"`
+ );
+ return {
+ isLoading: false,
+ extensions: [],
+ };
+ }
+
return getPluginExtensions({
- extensionPointId: options.extensionPointId,
- context: options.context,
- limitPerPlugin: options.limitPerPlugin,
+ extensionPointId,
+ context,
+ limitPerPlugin,
addedComponentsRegistry,
addedLinksRegistry,
});
@@ -36,10 +64,11 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
}, [
addedLinksRegistry,
addedComponentsRegistry,
- options.extensionPointId,
- options.context,
- options.limitPerPlugin,
+ extensionPointId,
+ context,
+ limitPerPlugin,
activePluginId,
+ pluginContext,
]);
return { extensions, isLoading: false };
diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
index c061932ac73..d4d0cc6a843 100644
--- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
@@ -1,10 +1,13 @@
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
+import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
+
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { setupPluginExtensionRegistries } from './registry/setup';
import { PluginExtensionRegistries } from './registry/types';
import { usePluginLinks } from './usePluginLinks';
+import { isGrafanaDevMode } from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({
@@ -17,15 +20,66 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
}),
}));
+jest.mock('./utils', () => ({
+ ...jest.requireActual('./utils'),
+
+ // Manually set the dev mode to false
+ // (to make sure that by default we are testing a production scneario)
+ isGrafanaDevMode: jest.fn().mockReturnValue(false),
+}));
+
describe('usePluginLinks()', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
+ let pluginMeta: PluginMeta;
+ let consoleWarnSpy: jest.SpyInstance;
+ const pluginId = 'myorg-extensions-app';
+ const extensionPointId = `${pluginId}/extension-point/v1`;
beforeEach(() => {
+ jest.mocked(isGrafanaDevMode).mockReturnValue(false);
registries = setupPluginExtensionRegistries();
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ pluginMeta = {
+ id: pluginId,
+ name: 'Extensions App',
+ type: PluginType.app,
+ module: '',
+ baseUrl: '',
+ info: {
+ author: {
+ name: 'MyOrg',
+ },
+ description: 'App for testing extensions',
+ links: [],
+ logos: {
+ large: '',
+ small: '',
+ },
+ screenshots: [],
+ updated: '2023-10-26T18:25:01Z',
+ version: '1.0.0',
+ },
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ };
wrapper = ({ children }: { children: React.ReactNode }) => (
- {children}
+
+ {children}
+
);
});
@@ -42,9 +96,6 @@ describe('usePluginLinks()', () => {
});
it('should only return the link extensions for the given extension point ids', async () => {
- const extensionPointId = 'plugins/foo/bar/v1';
- const pluginId = 'my-app-plugin';
-
registries.addedLinksRegistry.register({
pluginId,
configs: [
@@ -77,8 +128,6 @@ describe('usePluginLinks()', () => {
});
it('should dynamically update the extensions registered for a certain extension point', () => {
- const extensionPointId = 'plugins/foo/bar/v1';
- const pluginId = 'my-app-plugin';
let { result, rerender } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
// No extensions yet
@@ -112,4 +161,179 @@ describe('usePluginLinks()', () => {
expect(result.current.links[0].title).toBe('1');
expect(result.current.links[1].title).toBe('2');
});
+
+ it('should not validate the extension point meta-info in production mode', () => {
+ // Empty list of extension points in the plugin meta (from plugin.json)
+ wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ registries.addedLinksRegistry.register({
+ pluginId,
+ configs: [
+ {
+ targets: extensionPointId,
+ title: '1',
+ description: '1',
+ path: `/a/${pluginId}/2`,
+ },
+ ],
+ });
+
+ // Trying to render an extension point that is not defined in the plugin meta
+ // (No restrictions due to isGrafanaDevMode() = false)
+ let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
+ expect(result.current.links.length).toBe(1);
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not validate the extension point id in production mode', () => {
+ // Empty list of extension points in the plugin meta (from plugin.json)
+ wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ // Trying to render an extension point that is not defined in the plugin meta
+ // (No restrictions due to isGrafanaDevMode() = false)
+ let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper });
+ expect(result.current.links.length).toBe(0);
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => {
+ // Imitate running in dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ // No plugin context -> used in Grafana core
+ wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ // Adding an extension to the extension point
+ registries.addedLinksRegistry.register({
+ pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context
+ configs: [
+ {
+ targets: 'grafana/extension-point/v1',
+ title: '1',
+ description: '1',
+ path: `/a/grafana/${pluginId}/2`,
+ },
+ ],
+ });
+
+ let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'grafana/extension-point/v1' }), { wrapper });
+ expect(result.current.links.length).toBe(1);
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not validate the extension point id if used in Grafana core (no plugin context)', () => {
+ // Imitate running in dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ // No plugin context -> used in Grafana core
+ wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper });
+ expect(result.current.links.length).toBe(0);
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
+ // Imitate running in dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ // Empty list of extension points in the plugin meta (from plugin.json)
+ wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ // Adding an extension to the extension point - it should not be returned later
+ registries.addedLinksRegistry.register({
+ pluginId,
+ configs: [
+ {
+ targets: extensionPointId,
+ title: '1',
+ description: '1',
+ path: `/a/${pluginId}/2`,
+ },
+ ],
+ });
+
+ // Trying to render an extension point that is not defined in the plugin meta
+ let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
+ expect(result.current.links.length).toBe(0);
+ expect(consoleWarnSpy).toHaveBeenCalled();
+ });
+
+ it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
+ // Imitate running in dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ // Empty list of extension points in the plugin meta (from plugin.json)
+ wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ // Adding an extension to the extension point - it should not be returned later
+ registries.addedLinksRegistry.register({
+ pluginId,
+ configs: [
+ {
+ targets: extensionPointId,
+ title: '1',
+ description: '1',
+ path: `/a/${pluginId}/2`,
+ },
+ ],
+ });
+
+ // Trying to render an extension point that is not defined in the plugin meta
+ let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
+ expect(result.current.links.length).toBe(0);
+ expect(consoleWarnSpy).toHaveBeenCalled();
+ });
});
diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx
index 723ea6cd9bf..1f868b01c77 100644
--- a/public/app/features/plugins/extensions/usePluginLinks.tsx
+++ b/public/app/features/plugins/extensions/usePluginLinks.tsx
@@ -2,7 +2,7 @@ import { isString } from 'lodash';
import { useMemo } from 'react';
import { useObservable } from 'react-use';
-import { PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
+import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data';
import {
UsePluginLinksOptions,
UsePluginLinksResult,
@@ -15,7 +15,11 @@ import {
getLinkExtensionOverrides,
getLinkExtensionPathWithTracking,
getReadOnlyProxy,
+ isExtensionPointMetaInfoMissing,
+ isGrafanaDevMode,
+ logWarning,
} from './utils';
+import { isExtensionPointIdValid } from './validators';
// Returns an array of component extensions for the given extension point
export function usePluginLinks({
@@ -24,9 +28,34 @@ export function usePluginLinks({
context,
}: UsePluginLinksOptions): UsePluginLinksResult {
const registry = useAddedLinksRegistry();
+ const pluginContext = usePluginContext();
const registryState = useObservable(registry.asObservable());
return useMemo(() => {
+ // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
+ const enableRestrictions = isGrafanaDevMode() && pluginContext !== null;
+ const pluginId = pluginContext?.meta.id ?? '';
+
+ if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
+ logWarning(
+ `Extension point usePluginLinks("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").`
+ );
+ return {
+ isLoading: false,
+ links: [],
+ };
+ }
+
+ if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
+ logWarning(
+ `Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"`
+ );
+ return {
+ isLoading: false,
+ links: [],
+ };
+ }
+
if (!registryState || !registryState[extensionPointId]) {
return {
isLoading: false,
@@ -80,5 +109,5 @@ export function usePluginLinks({
isLoading: false,
links: extensions,
};
- }, [context, extensionPointId, limitPerPlugin, registryState]);
+ }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext]);
}
diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx
index aca0f0797cf..3697fe0b71f 100644
--- a/public/app/features/plugins/extensions/utils.test.tsx
+++ b/public/app/features/plugins/extensions/utils.test.tsx
@@ -1,7 +1,15 @@
import { render, screen } from '@testing-library/react';
import { type Unsubscribable } from 'rxjs';
-import { dateTime, usePluginContext } from '@grafana/data';
+import {
+ dateTime,
+ PluginContextType,
+ PluginExtensionPoints,
+ PluginLoadingStrategy,
+ PluginType,
+ usePluginContext,
+} from '@grafana/data';
+import { config } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events';
@@ -11,6 +19,11 @@ import {
getReadOnlyProxy,
createOpenModalFunction,
wrapWithPluginContext,
+ isAddedLinkMetaInfoMissing,
+ isAddedComponentMetaInfoMissing,
+ isExposedComponentMetaInfoMissing,
+ isExposedComponentDependencyMissing,
+ isExtensionPointMetaInfoMissing,
} from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({
@@ -396,7 +409,7 @@ describe('Plugin Extensions / Utils', () => {
const ModalContent = () => {
const context = usePluginContext();
- return Version: {context.meta.info.version}
;
+ return Version: {context!.meta.info.version}
;
};
openModal({
@@ -415,13 +428,13 @@ describe('Plugin Extensions / Utils', () => {
};
const ExampleComponent = (props: ExampleComponentProps) => {
- const { meta } = usePluginContext();
+ const pluginContext = usePluginContext();
const audience = props.audience || 'Grafana';
return (
-
Hello {audience}!
Version: {meta.info.version}
+ Hello {audience}!
Version: {pluginContext!.meta.info.version}
);
};
@@ -446,4 +459,446 @@ describe('Plugin Extensions / Utils', () => {
expect(screen.getByText('Version: 1.0.0')).toBeVisible();
});
});
+
+ describe('isAddedLinkMetaInfoMissing()', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+ const originalApps = config.apps;
+ const pluginId = 'myorg-extensions-app';
+ const appPluginConfig = {
+ id: pluginId,
+ path: '',
+ version: '',
+ preload: false,
+ angular: {
+ detected: false,
+ hideDeprecation: false,
+ },
+ loadingStrategy: PluginLoadingStrategy.fetch,
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ };
+ const extensionConfig = {
+ targets: [PluginExtensionPoints.DashboardPanelMenu],
+ title: 'Link title',
+ description: 'Link description',
+ };
+
+ beforeEach(() => {
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ config.apps = {
+ [pluginId]: appPluginConfig,
+ };
+ });
+
+ afterEach(() => {
+ config.apps = originalApps;
+ });
+
+ it('should return FALSE if the meta-info in the plugin.json is correct', () => {
+ config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
+
+ const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig);
+
+ expect(returnValue).toBe(false);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it('should return TRUE and log a warning if the app config is not found', () => {
+ delete config.apps[pluginId];
+
+ const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin");
+ });
+
+ it('should return TRUE and log a warning if the link has no meta-info in the plugin.json', () => {
+ config.apps[pluginId].extensions.addedLinks = [];
+
+ const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json');
+ });
+
+ it('should return TRUE and log a warning if the "targets" do not match', () => {
+ config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
+
+ const returnValue = isAddedLinkMetaInfoMissing(pluginId, {
+ ...extensionConfig,
+ targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction],
+ });
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"targets" don\'t match');
+ });
+
+ it('should return TRUE and log a warning if the "description" does not match', () => {
+ config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
+
+ const returnValue = isAddedLinkMetaInfoMissing(pluginId, {
+ ...extensionConfig,
+ description: 'Link description UPDATED',
+ });
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match');
+ });
+ });
+
+ describe('isAddedComponentMetaInfoMissing()', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+ const originalApps = config.apps;
+ const pluginId = 'myorg-extensions-app';
+ const appPluginConfig = {
+ id: pluginId,
+ path: '',
+ version: '',
+ preload: false,
+ angular: {
+ detected: false,
+ hideDeprecation: false,
+ },
+ loadingStrategy: PluginLoadingStrategy.fetch,
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ };
+ const extensionConfig = {
+ targets: [PluginExtensionPoints.DashboardPanelMenu],
+ title: 'Component title',
+ description: 'Component description',
+ component: () => Component content
,
+ };
+
+ beforeEach(() => {
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ config.apps = {
+ [pluginId]: appPluginConfig,
+ };
+ });
+
+ afterEach(() => {
+ config.apps = originalApps;
+ });
+
+ it('should return FALSE if the meta-info in the plugin.json is correct', () => {
+ config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
+
+ const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig);
+
+ expect(returnValue).toBe(false);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it('should return TRUE and log a warning if the app config is not found', () => {
+ delete config.apps[pluginId];
+
+ const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin");
+ });
+
+ it('should return TRUE and log a warning if the Component has no meta-info in the plugin.json', () => {
+ config.apps[pluginId].extensions.addedComponents = [];
+
+ const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json');
+ });
+
+ it('should return TRUE and log a warning if the "targets" do not match', () => {
+ config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
+
+ const returnValue = isAddedComponentMetaInfoMissing(pluginId, {
+ ...extensionConfig,
+ targets: [PluginExtensionPoints.ExploreToolbarAction],
+ });
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"targets" don\'t match');
+ });
+
+ it('should return TRUE and log a warning if the "description" does not match', () => {
+ config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
+
+ const returnValue = isAddedComponentMetaInfoMissing(pluginId, {
+ ...extensionConfig,
+ description: 'UPDATED',
+ });
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match');
+ });
+ });
+
+ describe('isExposedComponentMetaInfoMissing()', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+ const originalApps = config.apps;
+ const pluginId = 'myorg-extensions-app';
+ const appPluginConfig = {
+ id: pluginId,
+ path: '',
+ version: '',
+ preload: false,
+ angular: {
+ detected: false,
+ hideDeprecation: false,
+ },
+ loadingStrategy: PluginLoadingStrategy.fetch,
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ };
+ const exposedComponentConfig = {
+ id: `${pluginId}/component/v1`,
+ title: 'Exposed component',
+ description: 'Exposed component description',
+ component: () => Component content
,
+ };
+
+ beforeEach(() => {
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ config.apps = {
+ [pluginId]: appPluginConfig,
+ };
+ });
+
+ afterEach(() => {
+ config.apps = originalApps;
+ });
+
+ it('should return FALSE if the meta-info in the plugin.json is correct', () => {
+ config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
+
+ const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig);
+
+ expect(returnValue).toBe(false);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it('should return TRUE and log a warning if the app config is not found', () => {
+ delete config.apps[pluginId];
+
+ const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin");
+ });
+
+ it('should return TRUE and log a warning if the exposed component has no meta-info in the plugin.json', () => {
+ config.apps[pluginId].extensions.exposedComponents = [];
+
+ const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json');
+ });
+
+ it('should return TRUE and log a warning if the title does not match', () => {
+ config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
+
+ const returnValue = isExposedComponentMetaInfoMissing(pluginId, {
+ ...exposedComponentConfig,
+ title: 'UPDATED',
+ });
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"title" doesn\'t match');
+ });
+
+ it('should return TRUE and log a warning if the "description" does not match', () => {
+ config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
+
+ const returnValue = isExposedComponentMetaInfoMissing(pluginId, {
+ ...exposedComponentConfig,
+ description: 'UPDATED',
+ });
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match');
+ });
+ });
+
+ describe('isExposedComponentDependencyMissing()', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+ let pluginContext: PluginContextType;
+ const pluginId = 'myorg-extensions-app';
+ const exposedComponentId = `${pluginId}/component/v1`;
+
+ beforeEach(() => {
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ pluginContext = {
+ meta: {
+ id: pluginId,
+ name: 'Extensions App',
+ type: PluginType.app,
+ module: '',
+ baseUrl: '',
+ info: {
+ author: {
+ name: 'MyOrg',
+ },
+ description: 'App for testing extensions',
+ links: [],
+ logos: {
+ large: '',
+ small: '',
+ },
+ screenshots: [],
+ updated: '2023-10-26T18:25:01Z',
+ version: '1.0.0',
+ },
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ },
+ };
+ });
+
+ it('should return FALSE if the meta-info in the plugin.json is correct', () => {
+ pluginContext.meta.dependencies?.extensions.exposedComponents.push(exposedComponentId);
+
+ const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
+
+ expect(returnValue).toBe(false);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it('should return TRUE and log a warning if the dependencies are missing', () => {
+ delete pluginContext.meta.dependencies;
+
+ const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`);
+ });
+
+ it('should return TRUE and log a warning if the exposed component id is not specified in the list of dependencies', () => {
+ const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`);
+ });
+ });
+
+ describe('isExtensionPointMetaInfoMissing()', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+ let pluginContext: PluginContextType;
+ const pluginId = 'myorg-extensions-app';
+ const extensionPointId = `${pluginId}/extension-point/v1`;
+ const extensionPointConfig = {
+ id: extensionPointId,
+ title: 'Extension point title',
+ description: 'Extension point description',
+ };
+
+ beforeEach(() => {
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ pluginContext = {
+ meta: {
+ id: pluginId,
+ name: 'Extensions App',
+ type: PluginType.app,
+ module: '',
+ baseUrl: '',
+ info: {
+ author: {
+ name: 'MyOrg',
+ },
+ description: 'App for testing extensions',
+ links: [],
+ logos: {
+ large: '',
+ small: '',
+ },
+ screenshots: [],
+ updated: '2023-10-26T18:25:01Z',
+ version: '1.0.0',
+ },
+ extensions: {
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ },
+ };
+ });
+
+ it('should return FALSE if the meta-info in the plugin.json is correct', () => {
+ pluginContext.meta.extensions?.extensionPoints.push(extensionPointConfig);
+
+ const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
+
+ expect(returnValue).toBe(false);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it('should return TRUE and log a warning if the extension point id is not recorded in the plugin.json', () => {
+ const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
+
+ expect(returnValue).toBe(true);
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Extension point "${extensionPointId}"`);
+ });
+ });
});
diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx
index ade21ad0962..0938c1df666 100644
--- a/public/app/features/plugins/extensions/utils.tsx
+++ b/public/app/features/plugins/extensions/utils.tsx
@@ -16,8 +16,11 @@ import {
PanelMenuItem,
PluginExtensionAddedLinkConfig,
urlUtil,
+ PluginContextType,
+ PluginExtensionExposedComponentConfig,
+ PluginExtensionAddedComponentConfig,
} from '@grafana/data';
-import { reportInteraction } from '@grafana/runtime';
+import { reportInteraction, config } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
// TODO: instead of depending on the service as a singleton, inject it as an argument from the React context
@@ -408,3 +411,145 @@ export const openAppInSideview = (pluginId: string) => sidecarService.openApp(pl
export const closeAppInSideview = (pluginId: string) => sidecarService.closeApp(pluginId);
export const isAppOpened = (pluginId: string) => sidecarService.isAppOpened(pluginId);
+
+// Comes from the `app_mode` setting in the Grafana config (defaults to "development")
+// Can be set with the `GF_DEFAULT_APP_MODE` environment variable
+export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
+
+// Checks if the meta information is missing from the plugin's plugin.json file
+export const isExtensionPointMetaInfoMissing = (extensionPointId: string, pluginContext: PluginContextType) => {
+ const pluginId = pluginContext.meta?.id;
+ const extensionPoints = pluginContext.meta?.extensions?.extensionPoints;
+
+ if (!extensionPoints || !extensionPoints.some((ep) => ep.id === extensionPointId)) {
+ logWarning(
+ `Extension point "${extensionPointId}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "extensions.extensionPoints[]".`
+ );
+ return true;
+ }
+
+ return false;
+};
+
+// Checks if an exposed component that the plugin is depending on is missing from the `dependencies` in the plugin.json file
+export const isExposedComponentDependencyMissing = (id: string, pluginContext: PluginContextType) => {
+ const pluginId = pluginContext.meta?.id;
+ const exposedComponentsDependencies = pluginContext.meta?.dependencies?.extensions?.exposedComponents;
+
+ if (!exposedComponentsDependencies || !exposedComponentsDependencies.includes(id)) {
+ logWarning(
+ `Using exposed component "${id}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "dependencies.extensions.exposedComponents[]".`
+ );
+ return true;
+ }
+
+ return false;
+};
+
+export const isAddedLinkMetaInfoMissing = (pluginId: string, metaInfo: PluginExtensionAddedLinkConfig) => {
+ const app = config.apps[pluginId];
+ const logPrefix = `Added-link "${metaInfo.title}" from "${pluginId}" -`;
+ const pluginJsonMetaInfo = app ? app.extensions.addedLinks.find(({ title }) => title === metaInfo.title) : null;
+
+ if (!app) {
+ logWarning(`${logPrefix} couldn't find app plugin "${pluginId}"`);
+ return true;
+ }
+
+ if (!pluginJsonMetaInfo) {
+ logWarning(`${logPrefix} not registered in the plugin.json under "extensions.addedLinks[]".`);
+
+ return true;
+ }
+
+ const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
+ if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
+ logWarning(`${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedLinks[]".`);
+
+ return true;
+ }
+
+ if (pluginJsonMetaInfo.description !== metaInfo.description) {
+ logWarning(
+ `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.addedLinks[]".`
+ );
+
+ return true;
+ }
+
+ return false;
+};
+
+export const isAddedComponentMetaInfoMissing = (pluginId: string, metaInfo: PluginExtensionAddedComponentConfig) => {
+ const app = config.apps[pluginId];
+ const logPrefix = `Added component "${metaInfo.title}" -`;
+ const pluginJsonMetaInfo = app ? app.extensions.addedComponents.find(({ title }) => title === metaInfo.title) : null;
+
+ if (!app) {
+ logWarning(`${logPrefix} couldn't find app plugin "${pluginId}"`);
+ return true;
+ }
+
+ if (!pluginJsonMetaInfo) {
+ logWarning(`${logPrefix} not registered in the plugin.json under "extensions.addedComponents[]".`);
+
+ return true;
+ }
+
+ const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
+ if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
+ logWarning(
+ `${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedComponents[]".`
+ );
+
+ return true;
+ }
+
+ if (pluginJsonMetaInfo.description !== metaInfo.description) {
+ logWarning(
+ `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.addedComponents[]".`
+ );
+
+ return true;
+ }
+
+ return false;
+};
+
+export const isExposedComponentMetaInfoMissing = (
+ pluginId: string,
+ metaInfo: PluginExtensionExposedComponentConfig
+) => {
+ const app = config.apps[pluginId];
+ const logPrefix = `Exposed component "${metaInfo.id}" -`;
+ const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.find(({ id }) => id === metaInfo.id) : null;
+
+ if (!app) {
+ logWarning(`${logPrefix} couldn't find app plugin: "${pluginId}"`);
+ return true;
+ }
+
+ if (!pluginJsonMetaInfo) {
+ logWarning(`${logPrefix} not registered in the plugin.json under "extensions.exposedComponents[]".`);
+
+ return true;
+ }
+
+ if (pluginJsonMetaInfo.title !== metaInfo.title) {
+ logWarning(
+ `${logPrefix} the "title" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".`
+ );
+
+ return true;
+ }
+
+ if (pluginJsonMetaInfo.description !== metaInfo.description) {
+ logWarning(
+ `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".`
+ );
+
+ return true;
+ }
+
+ return false;
+};
diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx
index efb329af5cf..2ce296c7210 100644
--- a/public/app/features/plugins/extensions/validators.test.tsx
+++ b/public/app/features/plugins/extensions/validators.test.tsx
@@ -6,6 +6,7 @@ import {
assertConfigureIsValid,
assertLinkPathIsValid,
assertStringProps,
+ isExtensionPointIdValid,
isGrafanaCoreExtensionPoint,
isReactComponent,
} from './validators';
@@ -184,4 +185,50 @@ describe('Plugin Extension Validators', () => {
expect(isGrafanaCoreExtensionPoint('grafana/dashboard/alertingrule/action')).toBe(false);
});
});
+
+ describe('isExtensionPointIdValid()', () => {
+ test.each([
+ // We (for now allow core Grafana extension points to run without a version)
+ ['grafana/extension-point', ''],
+ ['grafana/extension-point', 'grafana'],
+ ['myorg-extensions-app/extension-point', 'myorg-extensions-app'],
+ ['myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
+ ['plugins/myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
+ ['plugins/myorg-basic-app/start', 'myorg-basic-app'],
+ ['myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
+ ['plugins/myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
+ ['plugins/grafana-app-observability-app/service/action', 'grafana-app-observability-app'],
+ ['plugins/grafana-k8s-app/cluster/action', 'grafana-k8s-app'],
+ ['plugins/grafana-oncall-app/alert-group/action', 'grafana-oncall-app'],
+ ['plugins/grafana-oncall-app/alert-group/action/v1', 'grafana-oncall-app'],
+ ['plugins/grafana-oncall-app/alert-group/action/v1.0.0', 'grafana-oncall-app'],
+ ])('should return TRUE if the extension point id is valid ("%s", "%s")', (extensionPointId, pluginId) => {
+ expect(
+ isExtensionPointIdValid({
+ extensionPointId,
+ pluginId,
+ })
+ ).toBe(true);
+ });
+
+ test.each([
+ [
+ // Plugin id mismatch
+ 'myorg-extensions-app/extension-point/v1',
+ 'myorgs-other-app',
+ ],
+ [
+ // Missing plugin id prefix
+ 'extension-point/v1',
+ 'myorgs-extensions-app',
+ ],
+ ])('should return FALSE if the extension point id is invalid ("%s", "%s")', (extensionPointId, pluginId) => {
+ expect(
+ isExtensionPointIdValid({
+ extensionPointId,
+ pluginId,
+ })
+ ).toBe(false);
+ });
+ });
});
diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts
index 96970860973..b03df733d83 100644
--- a/public/app/features/plugins/extensions/validators.ts
+++ b/public/app/features/plugins/extensions/validators.ts
@@ -53,12 +53,18 @@ export function isLinkPathValid(pluginId: string, path: string) {
return Boolean(typeof path === 'string' && path.length > 0 && path.startsWith(`/a/${pluginId}/`));
}
-export function isExtensionPointIdValid(pluginId: string, extensionPointId: string) {
- return Boolean(
- extensionPointId.startsWith('grafana/') ||
- extensionPointId?.startsWith('plugins/') ||
- extensionPointId?.startsWith(pluginId)
- );
+export function isExtensionPointIdValid({
+ extensionPointId,
+ pluginId,
+}: {
+ extensionPointId: string;
+ pluginId: string;
+}) {
+ if (extensionPointId.startsWith('grafana/')) {
+ return true;
+ }
+
+ return Boolean(extensionPointId.startsWith(`plugins/${pluginId}/`) || extensionPointId.startsWith(`${pluginId}/`));
}
export function extensionPointEndsWithVersion(extensionPointId: string) {
diff --git a/public/app/features/query-library/api/mappers.ts b/public/app/features/query-library/api/mappers.ts
index 6c0af6c8539..9dfb2fe4439 100644
--- a/public/app/features/query-library/api/mappers.ts
+++ b/public/app/features/query-library/api/mappers.ts
@@ -1,3 +1,5 @@
+import { v4 as uuidv4 } from 'uuid';
+
import { AddQueryTemplateCommand, QueryTemplate } from '../types';
import { API_VERSION, QueryTemplateKinds } from './query';
@@ -48,7 +50,11 @@ export const convertAddQueryTemplateCommandToDataQuerySpec = (
apiVersion: API_VERSION,
kind: QueryTemplateKinds.QueryTemplate,
metadata: {
- generateName: 'A' + title.replaceAll(' ', '-'),
+ /**
+ * Server will append to whatever is passed here, but just to be safe we generate a uuid
+ * More info https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#idempotency
+ */
+ generateName: uuidv4(),
},
spec: {
title: title,
diff --git a/public/app/plugins/datasource/elasticsearch/plugin.json b/public/app/plugins/datasource/elasticsearch/plugin.json
index 5d1d43c4c84..96b0d4323fa 100644
--- a/public/app/plugins/datasource/elasticsearch/plugin.json
+++ b/public/app/plugins/datasource/elasticsearch/plugin.json
@@ -3,27 +3,29 @@
"name": "Elasticsearch",
"id": "elasticsearch",
"category": "logging",
-
"info": {
"description": "Open source logging & analytics database",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
- "keywords": ["elasticsearch"],
+ "keywords": ["elasticsearch", "datasource", "database", "logs", "nosql", "traces"],
"logos": {
"small": "img/elasticsearch.svg",
"large": "img/elasticsearch.svg"
},
- "links": [{ "name": "Learn more", "url": "https://grafana.com/docs/features/datasources/elasticsearch/" }]
+ "links": [
+ {
+ "name": "Learn more",
+ "url": "https://grafana.com/docs/features/datasources/elasticsearch/"
+ }
+ ]
},
-
"alerting": true,
"annotations": true,
"metrics": true,
"logs": true,
"backend": true,
-
"queryOptions": {
"minInterval": true
}
diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json
index 61c7f4d98d9..d2faff4ca30 100644
--- a/public/locales/de-DE/grafana.json
+++ b/public/locales/de-DE/grafana.json
@@ -329,6 +329,9 @@
"moving": "Verschieben...",
"new-folder-name-required-phrase": "Ordnername ist erforderlich."
},
+ "actions": {
+ "button-to-recently-deleted": ""
+ },
"counts": {
"alertRule_one": "{{count}} Alarmierungsregel",
"alertRule_other": "{{count}} Alarmierungsregeln",
@@ -1090,6 +1093,13 @@
},
"title": "Shortcuts"
},
+ "help-wizard": {
+ "download-snapshot": "",
+ "github-comment": "",
+ "preview-snapshot": "",
+ "support-bundle": "",
+ "troubleshooting-help": ""
+ },
"inspector": {
"query": {
"collapse-all": "Alle einklappen",
@@ -1855,6 +1865,7 @@
"close": "Menü schließen",
"dock": "Menü andocken",
"list-label": "Navigation",
+ "open": "",
"undock": "Menü abdocken"
},
"toolbar": {
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 54c75bc0f9e..b03939b26b6 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -329,6 +329,9 @@
"moving": "Moving...",
"new-folder-name-required-phrase": "Folder name is required."
},
+ "actions": {
+ "button-to-recently-deleted": "Recently deleted"
+ },
"counts": {
"alertRule_one": "{{count}} alert rule",
"alertRule_other": "{{count}} alert rules",
@@ -1090,6 +1093,13 @@
},
"title": "Shortcuts"
},
+ "help-wizard": {
+ "download-snapshot": "Download snapshot",
+ "github-comment": "Copy Github comment",
+ "preview-snapshot": "Preview snapshot",
+ "support-bundle": "You can also retrieve a support bundle containing information concerning your Grafana instance and configured datasources in the <1>support bundles section1>.",
+ "troubleshooting-help": "To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The snapshot contains query response data and panel settings."
+ },
"inspector": {
"query": {
"collapse-all": "Collapse all",
@@ -1175,7 +1185,7 @@
},
"misc-section": {
"allow-sign-up-descrition": "If not enabled, only existing Grafana users can log in using LDAP",
- "allow-sign-up-label": "Allow sign up",
+ "allow-sign-up-label": "Allow sign-up",
"label": "Misc",
"port-description": "Default port is 389 without SSL or 636 with SSL",
"port-label": "Port",
diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json
index 561c1a99a52..2eb022cd6ac 100644
--- a/public/locales/es-ES/grafana.json
+++ b/public/locales/es-ES/grafana.json
@@ -329,6 +329,9 @@
"moving": "Moviendo...",
"new-folder-name-required-phrase": "Se requiere el nombre de la carpeta."
},
+ "actions": {
+ "button-to-recently-deleted": ""
+ },
"counts": {
"alertRule_one": "{{count}} regla de alerta",
"alertRule_other": "{{count}} reglas de alerta",
@@ -1090,6 +1093,13 @@
},
"title": "Accesos directos"
},
+ "help-wizard": {
+ "download-snapshot": "",
+ "github-comment": "",
+ "preview-snapshot": "",
+ "support-bundle": "",
+ "troubleshooting-help": ""
+ },
"inspector": {
"query": {
"collapse-all": "Contraer todo",
@@ -1855,6 +1865,7 @@
"close": "Cerrar menú",
"dock": "Anclar el menú",
"list-label": "Navegación",
+ "open": "",
"undock": "Desanclar el menú"
},
"toolbar": {
diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json
index 7d1ad94293c..b30b2ff11e9 100644
--- a/public/locales/fr-FR/grafana.json
+++ b/public/locales/fr-FR/grafana.json
@@ -329,6 +329,9 @@
"moving": "Déplacement...",
"new-folder-name-required-phrase": "Vous devez saisir le nom du dossier."
},
+ "actions": {
+ "button-to-recently-deleted": ""
+ },
"counts": {
"alertRule_one": "{{count}} règle d'alerte",
"alertRule_other": "{{count}} règles d'alerte",
@@ -1090,6 +1093,13 @@
},
"title": "Raccourcis"
},
+ "help-wizard": {
+ "download-snapshot": "",
+ "github-comment": "",
+ "preview-snapshot": "",
+ "support-bundle": "",
+ "troubleshooting-help": ""
+ },
"inspector": {
"query": {
"collapse-all": "Tout réduire",
@@ -1855,6 +1865,7 @@
"close": "Fermer le menu",
"dock": "Ancrer le menu",
"list-label": "Navigation",
+ "open": "",
"undock": "Ancrer le menu"
},
"toolbar": {
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 10e72721f09..820267b08dc 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -329,6 +329,9 @@
"moving": "Mővįʼnģ...",
"new-folder-name-required-phrase": "Főľđęř ʼnämę įş řęqūįřęđ."
},
+ "actions": {
+ "button-to-recently-deleted": "Ŗęčęʼnŧľy đęľęŧęđ"
+ },
"counts": {
"alertRule_one": "{{count}} äľęřŧ řūľę",
"alertRule_other": "{{count}} äľęřŧ řūľęş",
@@ -1090,6 +1093,13 @@
},
"title": "Ŝĥőřŧčūŧş"
},
+ "help-wizard": {
+ "download-snapshot": "Đőŵʼnľőäđ şʼnäpşĥőŧ",
+ "github-comment": "Cőpy Ğįŧĥūþ čőmmęʼnŧ",
+ "preview-snapshot": "Přęvįęŵ şʼnäpşĥőŧ",
+ "support-bundle": "Ÿőū čäʼn äľşő řęŧřįęvę ä şūppőřŧ þūʼnđľę čőʼnŧäįʼnįʼnģ įʼnƒőřmäŧįőʼn čőʼnčęřʼnįʼnģ yőūř Ğřäƒäʼnä įʼnşŧäʼnčę äʼnđ čőʼnƒįģūřęđ đäŧäşőūřčęş įʼn ŧĥę <1>şūppőřŧ þūʼnđľęş şęčŧįőʼn1>.",
+ "troubleshooting-help": "Ŧő řęqūęşŧ ŧřőūþľęşĥőőŧįʼnģ ĥęľp, şęʼnđ ä şʼnäpşĥőŧ őƒ ŧĥįş päʼnęľ ŧő Ğřäƒäʼnä Ŀäþş Ŧęčĥʼnįčäľ Ŝūppőřŧ. Ŧĥę şʼnäpşĥőŧ čőʼnŧäįʼnş qūęřy řęşpőʼnşę đäŧä äʼnđ päʼnęľ şęŧŧįʼnģş."
+ },
"inspector": {
"query": {
"collapse-all": "Cőľľäpşę äľľ",
@@ -1175,7 +1185,7 @@
},
"misc-section": {
"allow-sign-up-descrition": "Ĩƒ ʼnőŧ ęʼnäþľęđ, őʼnľy ęχįşŧįʼnģ Ğřäƒäʼnä ūşęřş čäʼn ľőģ įʼn ūşįʼnģ ĿĐÅP",
- "allow-sign-up-label": "Åľľőŵ şįģʼn ūp",
+ "allow-sign-up-label": "Åľľőŵ şįģʼn-ūp",
"label": "Mįşč",
"port-description": "Đęƒäūľŧ pőřŧ įş 389 ŵįŧĥőūŧ ŜŜĿ őř 636 ŵįŧĥ ŜŜĿ",
"port-label": "Pőřŧ",
diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json
index b36da1c5e60..d863d06e3e9 100644
--- a/public/locales/pt-BR/grafana.json
+++ b/public/locales/pt-BR/grafana.json
@@ -329,6 +329,9 @@
"moving": "Movendo...",
"new-folder-name-required-phrase": "O nome da pasta é obrigatório."
},
+ "actions": {
+ "button-to-recently-deleted": ""
+ },
"counts": {
"alertRule_one": "{{count}} regra de alerta",
"alertRule_other": "{{count}} regras de alerta",
@@ -1090,6 +1093,13 @@
},
"title": "Atalhos"
},
+ "help-wizard": {
+ "download-snapshot": "",
+ "github-comment": "",
+ "preview-snapshot": "",
+ "support-bundle": "",
+ "troubleshooting-help": ""
+ },
"inspector": {
"query": {
"collapse-all": "Recolher tudo",
@@ -1855,6 +1865,7 @@
"close": "Fechar menu",
"dock": "Menu da dock",
"list-label": "Navegação",
+ "open": "",
"undock": "Desacoplar menu"
},
"toolbar": {
diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json
index 7302ac50a23..8d63012a79d 100644
--- a/public/locales/zh-Hans/grafana.json
+++ b/public/locales/zh-Hans/grafana.json
@@ -325,6 +325,9 @@
"moving": "正在移动...",
"new-folder-name-required-phrase": "文件夹名称是必需项。"
},
+ "actions": {
+ "button-to-recently-deleted": ""
+ },
"counts": {
"alertRule_other": "{{count}} 条警告规则",
"dashboard_other": "{{count}} 个仪表板",
@@ -1081,6 +1084,13 @@
},
"title": "快捷键"
},
+ "help-wizard": {
+ "download-snapshot": "",
+ "github-comment": "",
+ "preview-snapshot": "",
+ "support-bundle": "",
+ "troubleshooting-help": ""
+ },
"inspector": {
"query": {
"collapse-all": "折叠全部",
@@ -1845,6 +1855,7 @@
"close": "关闭菜单",
"dock": "停靠菜单",
"list-label": "导航",
+ "open": "",
"undock": "取消停靠菜单"
},
"toolbar": {
diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini
index 5d09d40ac63..95499fabb85 100644
--- a/scripts/grafana-server/custom.ini
+++ b/scripts/grafana-server/custom.ini
@@ -7,7 +7,7 @@ content_security_policy_template = """require-trusted-types-for 'script'; script
enable = publicDashboards
[plugins]
-allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,
+allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,grafana-extensionexample3-app
[database]
type=sqlite3