From bc7386e8155654907f5d7b06ab23795b0aa9542b Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Thu, 10 Oct 2024 08:27:57 +0200 Subject: [PATCH] PluginExtension: Added debug log (#94146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * add simple scenes object with logs panel * return hardcoded log message from runtime ds * simplify log entry * use log in links registry * wired the log together. * wip * Connected the extensions log to the runtime datasource to steam logs * wired the other registies. * implemented child function. * set right field type on labels * set meta type * using the logger in various places. * added type of onclick. * removed time picker. * removed imports. * passing log to functions where they are needed. * moved scene into admin page. * minor improvement to the message. * added possibility to update query with values based on the data. * added filter suppoert. * wip * wip * fixed so extension points are displayed. * use log level from grafana data * fixed bugs with the filtering. * Fixed some logs. * only register extensions page in development mode. * fixed filtering. * added on click debug log. * PluginExtensions: Add debug log to Grafana (Rewrite to scenes-react) (#93954) * refactoring. * simplify it even more. * Update public/app/features/plugins/extensions/logs/LogViewer.tsx Co-authored-by: Erik Sundell * used VizGridLayout instead of VizGrid component. * Fixed feedback and fixed bug in filtering logic. * fixed another nit. * empty string instead of title. * Added tests and fixed error. * added test file. * regenerated yarn.lock * Update public/app/features/plugins/extensions/logs/filterTransformation.test.ts Co-authored-by: Levente Balogh * fixed nit. * more nits. * added more test cases. * simplified filtering logic. * removed unused dep. * defined broadcast channel in jest setup. * added tests for datasource. * fixed failed tests. * fixed tests. * fixing go lint issue. * silent go lint. * fixed lint issue. --------- Co-authored-by: Erik Sundell Co-authored-by: Torkel Ödegaard Co-authored-by: Levente Balogh --- package.json | 1 + pkg/services/navtree/navtreeimpl/admin.go | 12 + .../extensions/getExploreExtensionConfigs.tsx | 5 +- .../extensions/getPluginExtensions.test.tsx | 44 ++- .../plugins/extensions/getPluginExtensions.ts | 28 +- .../extensions/logs/LogViewFilters.tsx | 199 ++++++++++ .../plugins/extensions/logs/LogViewer.tsx | 62 +++ .../extensions/logs/dataSource.test.ts | 127 ++++++ .../plugins/extensions/logs/dataSource.ts | 104 +++++ .../logs/filterTransformation.test.ts | 361 ++++++++++++++++++ .../extensions/logs/filterTransformation.ts | 86 +++++ .../features/plugins/extensions/logs/log.ts | 94 +++++ .../plugins/extensions/logs/testUtils.ts | 31 ++ .../registry/AddedComponentsRegistry.test.ts | 38 +- .../registry/AddedComponentsRegistry.ts | 32 +- .../registry/AddedLinksRegistry.test.ts | 30 +- .../extensions/registry/AddedLinksRegistry.ts | 34 +- .../ExportedComponentsRegistry.test.ts | 48 ++- .../registry/ExposedComponentsRegistry.ts | 32 +- .../plugins/extensions/registry/Registry.ts | 6 +- .../extensions/usePluginComponent.test.tsx | 23 +- .../plugins/extensions/usePluginComponent.tsx | 30 +- .../extensions/usePluginComponents.test.tsx | 27 +- .../extensions/usePluginComponents.tsx | 13 +- .../extensions/usePluginExtensions.tsx | 13 +- .../extensions/usePluginLinks.test.tsx | 27 +- .../plugins/extensions/usePluginLinks.tsx | 22 +- .../plugins/extensions/utils.test.tsx | 206 +++++----- .../app/features/plugins/extensions/utils.tsx | 105 +++-- public/app/features/sandbox/TestStuffPage.tsx | 1 - public/app/routes/routes.tsx | 10 + public/test/jest-setup.ts | 9 + yarn.lock | 46 ++- 33 files changed, 1643 insertions(+), 263 deletions(-) create mode 100644 public/app/features/plugins/extensions/logs/LogViewFilters.tsx create mode 100644 public/app/features/plugins/extensions/logs/LogViewer.tsx create mode 100644 public/app/features/plugins/extensions/logs/dataSource.test.ts create mode 100644 public/app/features/plugins/extensions/logs/dataSource.ts create mode 100644 public/app/features/plugins/extensions/logs/filterTransformation.test.ts create mode 100644 public/app/features/plugins/extensions/logs/filterTransformation.ts create mode 100644 public/app/features/plugins/extensions/logs/log.ts create mode 100644 public/app/features/plugins/extensions/logs/testUtils.ts diff --git a/package.json b/package.json index bd16396820c..589fa327342 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,7 @@ "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", "@grafana/scenes": "5.19.1", + "@grafana/scenes-react": "5.19.1", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 5a4d9cd2335..18a71f24dde 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -11,8 +11,10 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/setting" ) +// nolint: gocyclo func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink, error) { var configNodes []*navtree.NavLink ctx := c.Req.Context() @@ -103,6 +105,16 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink }) } + if s.cfg.Env == setting.Dev { + pluginsNodeLinks = append(pluginsNodeLinks, &navtree.NavLink{ + Text: "Extensions", + Icon: "plug", + SubTitle: "Extend the UI of plugins and Grafana", + Id: "extensions", + Url: s.cfg.AppSubURL + "/admin/extensions", + }) + } + pluginsNode := &navtree.NavLink{ Text: "Plugins and data", SubTitle: "Install plugins and define the relationships between data", diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx index d842e451aa0..887e446c36d 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx @@ -3,7 +3,8 @@ import { contextSrv } from 'app/core/core'; import { dispatch } from 'app/store/store'; import { AccessControlAction } from 'app/types'; -import { createAddedLinkConfig, logWarning } from '../../plugins/extensions/utils'; +import { log } from '../../plugins/extensions/logs/log'; +import { createAddedLinkConfig } from '../../plugins/extensions/utils'; import { changeCorrelationEditorDetails } from '../state/main'; import { runQueries } from '../state/query'; @@ -54,7 +55,7 @@ export function getExploreExtensionConfigs(): PluginExtensionAddedLinkConfig[] { }), ]; } catch (error) { - logWarning(`Could not configure extensions for Explore due to: "${error}"`); + log.warning(`Could not configure extensions for Explore due to: "${error}"`); return []; } } diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index 23d44c6f540..925b0cfbd7b 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -4,6 +4,8 @@ import { PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig } f import { reportInteraction } from '@grafana/runtime'; import { getPluginExtensions } from './getPluginExtensions'; +import { log } from './logs/log'; +import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { isReadOnlyProxy } from './utils'; @@ -16,6 +18,16 @@ jest.mock('@grafana/runtime', () => { }; }); +jest.mock('./logs/log', () => { + const { createLogMock } = jest.requireActual('./logs/testUtils'); + const original = jest.requireActual('./logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + async function createRegistries( preloadResults: Array<{ pluginId: string; @@ -77,8 +89,8 @@ describe('getPluginExtensions()', () => { }, }; - global.console.warn = jest.fn(); jest.mocked(reportInteraction).mockReset(); + resetLogMock(log); }); test('should return the extensions for the given placement', async () => { @@ -279,7 +291,7 @@ describe('getPluginExtensions()', () => { expect(context.title).toBe('New title from the context!'); }); - test('should catch errors in the configure() function and log them as warnings', async () => { + test('should catch errors in the configure() function and log them as error', async () => { link2.configure = jest.fn().mockImplementation(() => { throw new Error('Something went wrong!'); }); @@ -291,8 +303,11 @@ describe('getPluginExtensions()', () => { }).not.toThrow(); expect(link2.configure).toHaveBeenCalledTimes(1); - expect(global.console.warn).toHaveBeenCalledTimes(1); - expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!'); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith('Failed to configure link with title "Link 2"', { + message: 'Something went wrong!', + stack: expect.stringContaining('Error: Something went wrong!'), + }); }); test('should skip the link extension if the configure() function returns with an invalid path', async () => { @@ -320,7 +335,7 @@ describe('getPluginExtensions()', () => { expect(link1.configure).toHaveBeenCalledTimes(1); expect(link2.configure).toHaveBeenCalledTimes(1); - expect(global.console.warn).toHaveBeenCalledTimes(2); + expect(log.error).toHaveBeenCalledTimes(2); }); test('should skip the extension if any of the updated props returned by the configure() function are invalid', async () => { @@ -336,7 +351,7 @@ describe('getPluginExtensions()', () => { expect(extensions).toHaveLength(0); expect(link2.configure).toHaveBeenCalledTimes(1); - expect(global.console.warn).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledTimes(1); }); test('should skip the extension if the configure() function returns a promise', async () => { @@ -347,7 +362,7 @@ describe('getPluginExtensions()', () => { expect(extensions).toHaveLength(0); expect(link2.configure).toHaveBeenCalledTimes(1); - expect(global.console.warn).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledTimes(1); }); test('should skip (hide) the extension if the configure() function returns undefined', async () => { @@ -357,7 +372,7 @@ describe('getPluginExtensions()', () => { const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); - expect(global.console.warn).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged + expect(log.warning).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged }); test('should pass event, context and helper to extension onClick()', async () => { @@ -386,7 +401,7 @@ describe('getPluginExtensions()', () => { ); }); - test('should catch errors in async/promise-based onClick function and log them as warnings', async () => { + test('should catch errors in async/promise-based onClick function and log them as errors', async () => { link2.path = undefined; link2.onClick = jest.fn().mockRejectedValue(new Error('testing')); @@ -400,10 +415,10 @@ describe('getPluginExtensions()', () => { expect(extensions).toHaveLength(1); expect(link2.onClick).toHaveBeenCalledTimes(1); - expect(global.console.warn).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledTimes(1); }); - test('should catch errors in the onClick() function and log them as warnings', async () => { + test('should catch errors in the onClick() function and log them as errors', async () => { link2.path = undefined; link2.onClick = jest.fn().mockImplementation(() => { throw new Error('Something went wrong!'); @@ -417,8 +432,11 @@ describe('getPluginExtensions()', () => { extension.onClick?.({} as React.MouseEvent); expect(link2.onClick).toHaveBeenCalledTimes(1); - expect(global.console.warn).toHaveBeenCalledTimes(1); - expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!'); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith('Something went wrong!', { + message: 'Something went wrong!', + stack: expect.stringContaining('Error: Something went wrong!'), + }); }); test('should pass a read only context to the onClick() function', async () => { diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index bd7d55d6e0d..acaaf8d97ac 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -8,13 +8,13 @@ import { } from '@grafana/data'; import { GetPluginExtensions } from '@grafana/runtime'; +import { log } from './logs/log'; import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; import { RegistryType } from './registry/Registry'; import type { PluginExtensionRegistries } from './registry/types'; import { getReadOnlyProxy, - logWarning, generateExtensionId, wrapWithPluginContext, getLinkExtensionOnClick, @@ -78,8 +78,16 @@ export const getPluginExtensions: GetExtensions = ({ extensionsByPlugin[pluginId] = 0; } + const linkLog = log.child({ + pluginId, + extensionPointId, + path: addedLink.path ?? '', + title: addedLink.title, + description: addedLink.description, + onClick: typeof addedLink.onClick, + }); // Run the configure() function with the current context, and apply the ovverides - const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext); + const overrides = getLinkExtensionOverrides(pluginId, addedLink, linkLog, frozenContext); // configure() returned an `undefined` -> hide the extension if (addedLink.configure && overrides === undefined) { @@ -91,7 +99,7 @@ export const getPluginExtensions: GetExtensions = ({ id: generateExtensionId(pluginId, extensionPointId, addedLink.title), type: PluginExtensionTypes.link, pluginId: pluginId, - onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), + onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, linkLog, frozenContext), // Configurable properties icon: overrides?.icon || addedLink.icon, @@ -105,7 +113,10 @@ export const getPluginExtensions: GetExtensions = ({ extensionsByPlugin[pluginId] += 1; } catch (error) { if (error instanceof Error) { - logWarning(error.message); + log.error(error.message, { + stack: error.stack ?? '', + message: error.message, + }); } } } @@ -120,13 +131,20 @@ export const getPluginExtensions: GetExtensions = ({ if (extensionsByPlugin[addedComponent.pluginId] === undefined) { extensionsByPlugin[addedComponent.pluginId] = 0; } + + const componentLog = log.child({ + title: addedComponent.title, + description: addedComponent.description, + pluginId: addedComponent.pluginId, + }); + const extension: PluginExtensionComponent = { id: generateExtensionId(addedComponent.pluginId, extensionPointId, addedComponent.title), type: PluginExtensionTypes.component, pluginId: addedComponent.pluginId, title: addedComponent.title, description: addedComponent.description, - component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component), + component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component, componentLog), }; extensions.push(extension); diff --git a/public/app/features/plugins/extensions/logs/LogViewFilters.tsx b/public/app/features/plugins/extensions/logs/LogViewFilters.tsx new file mode 100644 index 00000000000..549af8fcbee --- /dev/null +++ b/public/app/features/plugins/extensions/logs/LogViewFilters.tsx @@ -0,0 +1,199 @@ +import { isEmpty } from 'lodash'; +import { ReactElement, useMemo } from 'react'; + +import { DataFrame, MatcherConfig, SelectableValue } from '@grafana/data'; +import { SceneDataProvider } from '@grafana/scenes'; +import { InlineField, InlineFieldRow, MultiSelect } from '@grafana/ui'; + +export type LogFilter = { + pluginIds?: Set; + extensionPointIds?: Set; + severity?: Set; + initial?: string; +}; + +type LogViewFiltersProps = { + provider: SceneDataProvider; + filteredProvider: SceneDataProvider; + filter: LogFilter; + onChange: (filter: LogFilter) => void; +}; + +export function LogViewFilters({ provider, filteredProvider, filter, onChange }: LogViewFiltersProps): ReactElement { + const { pluginIds, extensionPointIds, severity } = useLogFilters(provider, filteredProvider, filter); + + const onChangePluginIds = (values: Array>) => { + const update = { + ...filter, + pluginIds: mapToSet(values), + }; + + if (isEmpty(filter.extensionPointIds) && isEmpty(filter.severity)) { + update.initial = isEmpty(values) ? undefined : 'pluginId'; + } + + onChange(update); + }; + + const onChangeExtensionPointIds = (values: Array>) => { + const update = { + ...filter, + extensionPointIds: mapToSet(values), + }; + + if (isEmpty(filter.pluginIds) && isEmpty(filter.severity)) { + update.initial = isEmpty(values) ? undefined : 'extensionPointId'; + } + + onChange(update); + }; + + const onChangeSeverity = (values: Array>) => { + const update = { + ...filter, + severity: mapToSet(values), + }; + + if (isEmpty(filter.pluginIds) && isEmpty(filter.extensionPointIds)) { + update.initial = isEmpty(values) ? undefined : 'severity'; + } + + onChange(update); + }; + + return ( + + + + + + + + + + + + ); +} + +export type FilterConfig = { + fieldName: string; + config: MatcherConfig; +}; + +type LogFilterOptions = { + pluginIds: Array>; + extensionPointIds: Array>; + severity: Array>; +}; + +function useLogFilters( + provider: SceneDataProvider, + filteredProvider: SceneDataProvider, + filter: LogFilter +): LogFilterOptions { + const { data } = provider.useState(); + const { data: filteredData } = filteredProvider.useState(); + + return useMemo(() => { + if (data && data?.series.length > 1) { + console.warn('LogViewFilter does not support multiple series in query result.'); + } + + const frame = data?.series[0]; + const filteredFrame = filteredData?.series[0]; + + if (!frame) { + return { + pluginIds: [], + extensionPointIds: [], + severity: [], + }; + } + + if (!filteredFrame) { + return toFilterOptions({ + severity: frame, + pluginId: frame, + extensionPointId: frame, + }); + } + + switch (filter.initial) { + case 'extensionPointId': + return toFilterOptions({ + severity: filteredFrame, + pluginId: filteredFrame, + extensionPointId: frame, + }); + + case 'severity': + return toFilterOptions({ + severity: frame, + pluginId: filteredFrame, + extensionPointId: filteredFrame, + }); + + case 'pluginId': + return toFilterOptions({ + severity: filteredFrame, + pluginId: frame, + extensionPointId: filteredFrame, + }); + + default: + return toFilterOptions({ + severity: frame, + pluginId: frame, + extensionPointId: frame, + }); + } + }, [data, filteredData, filter]); +} + +function mapToSet(selected: Array>): Set | undefined { + if (selected.length <= 0) { + return undefined; + } + + return selected.reduce((set, selectable) => { + if (selectable.value) { + set.add(selectable.value); + } + return set; + }, new Set()); +} + +function toSelectableArray(source: Set): Array> { + return Array.from(source).reduce((all: Array>, current) => { + if (!current) { + return all; + } + all.push({ + value: current, + label: current, + }); + return all; + }, []); +} + +function toFilterOptions(sources: { + severity: DataFrame; + pluginId: DataFrame; + extensionPointId: DataFrame; +}): LogFilterOptions { + const { severity, pluginId, extensionPointId } = sources; + const severityIndex = severity.fields.findIndex((f) => f.name === 'severity'); + const pluginIdIndex = pluginId.fields.findIndex((f) => f.name === 'pluginId'); + const extensionPointIdIndex = extensionPointId.fields.findIndex((f) => f.name === 'extensionPointId'); + + const severities = new Set(severity.fields[severityIndex].values); + const pluginIds = new Set(pluginId.fields[pluginIdIndex].values); + const extensionPointIds = new Set(extensionPointId.fields[extensionPointIdIndex].values); + + return { + severity: toSelectableArray(severities), + pluginIds: toSelectableArray(pluginIds), + extensionPointIds: toSelectableArray(extensionPointIds), + }; +} diff --git a/public/app/features/plugins/extensions/logs/LogViewer.tsx b/public/app/features/plugins/extensions/logs/LogViewer.tsx new file mode 100644 index 00000000000..155a97611d5 --- /dev/null +++ b/public/app/features/plugins/extensions/logs/LogViewer.tsx @@ -0,0 +1,62 @@ +import { nanoid } from 'nanoid'; +import { ReactElement, useState } from 'react'; + +import { sceneUtils, VizConfigBuilders } from '@grafana/scenes'; +import { + SceneContextProvider, + useDataTransformer, + useQueryRunner, + VizGridLayout, + VizPanel, +} from '@grafana/scenes-react'; +import { Page } from 'app/core/components/Page/Page'; + +import { LogFilter, LogViewFilters } from './LogViewFilters'; +import { ExtensionsLogDataSource } from './dataSource'; +import { createFilterTransformation } from './filterTransformation'; +import { log } from './log'; + +const DATASOURCE_REF = { + uid: nanoid(), + type: 'grafana-extensionslog-datasource', +}; + +const logsViz = VizConfigBuilders.logs().build(); + +sceneUtils.registerRuntimeDataSource({ + dataSource: new ExtensionsLogDataSource(DATASOURCE_REF.type, DATASOURCE_REF.uid, log), +}); + +export default function LogViewer(): ReactElement { + return ( + + + + ); +} + +function LogViewScene(): ReactElement | null { + const [filter, setFilter] = useState({}); + + const data = useQueryRunner({ + datasource: DATASOURCE_REF, + queries: [{ refId: 'A' }], + liveStreaming: true, + }); + + const filteredData = useDataTransformer({ + transformations: [createFilterTransformation(filter)], + data: data, + }); + + return ( + } + > + + + + + ); +} diff --git a/public/app/features/plugins/extensions/logs/dataSource.test.ts b/public/app/features/plugins/extensions/logs/dataSource.test.ts new file mode 100644 index 00000000000..e6b2db93a2c --- /dev/null +++ b/public/app/features/plugins/extensions/logs/dataSource.test.ts @@ -0,0 +1,127 @@ +import { nanoid } from 'nanoid'; +import { lastValueFrom, of } from 'rxjs'; + +import { DataQueryRequest, dateTime, LoadingState } from '@grafana/data'; + +import { ExtensionsLogDataSource } from './dataSource'; +import { log } from './log'; + +jest.mock('./log', () => { + const original = jest.requireActual('./log'); + return { + ...original, + log: { + asObservable: () => + of( + { + level: 'info', + labels: { + test: 'test', + }, + timestamp: Date.now(), + id: nanoid(), + message: 'a message', + pluginId: 'grafana-k8-app', + extensionPointId: 'grafana/dashboards/panel/menu', + }, + { + level: 'debug', + labels: { + title: 'a link', + onClick: 'function', + }, + timestamp: Date.now(), + id: nanoid(), + message: 'another message', + } + ), + }, + }; +}); + +describe('ExtensionsLogDataSource', () => { + const dataSource = new ExtensionsLogDataSource('pluginId', 'ds-uid', log); + + it('should return a stream when querying for data', async () => { + const response = await lastValueFrom(dataSource.query(createRequest())); + expect(response.state).toBe(LoadingState.Streaming); + }); + + it('should return logs as data frames when querying for data', async () => { + const { data } = await lastValueFrom(dataSource.query(createRequest())); + expect(data).toStrictEqual([ + { + refId: 'A', + meta: { + type: 'log-lines', + }, + length: 2, + fields: [ + { + config: expect.any(Object), + name: 'timestamp', + type: 'time', + values: [expect.any(Number), expect.any(Number)], + }, + { + config: expect.any(Object), + name: 'body', + type: 'string', + values: ['another message', 'a message'], + }, + { + config: expect.any(Object), + name: 'severity', + type: 'string', + values: ['debug', 'info'], + }, + { + config: expect.any(Object), + name: 'id', + type: 'string', + values: [expect.any(String), expect.any(String)], + }, + { + config: expect.any(Object), + name: 'labels', + type: 'other', + values: [{ onClick: 'function', title: 'a link' }, { test: 'test' }], + }, + { + config: expect.any(Object), + name: 'pluginId', + type: 'string', + values: [null, 'grafana-k8-app'], + }, + { + config: expect.any(Object), + name: 'extensionPointId', + type: 'string', + values: [null, 'grafana/dashboards/panel/menu'], + }, + ], + }, + ]); + }); +}); + +function createRequest(): DataQueryRequest { + return { + requestId: '', + interval: '', + intervalMs: 0, + range: { + from: dateTime(), + to: dateTime(), + raw: { + from: '', + to: '', + }, + }, + scopedVars: {}, + targets: [{ refId: 'A' }], + timezone: '', + app: '', + startTime: Date.now(), + }; +} diff --git a/public/app/features/plugins/extensions/logs/dataSource.ts b/public/app/features/plugins/extensions/logs/dataSource.ts new file mode 100644 index 00000000000..8b5ba2a4df1 --- /dev/null +++ b/public/app/features/plugins/extensions/logs/dataSource.ts @@ -0,0 +1,104 @@ +import { Observable, scan } from 'rxjs'; + +import { + createDataFrame, + DataFrame, + DataFrameType, + DataQueryRequest, + DataQueryResponse, + FieldType, + LoadingState, + TestDataSourceResponse, +} from '@grafana/data'; +import { RuntimeDataSource, SceneDataQuery } from '@grafana/scenes'; + +import { ExtensionsLog, ExtensionsLogItem } from './log'; + +export class ExtensionsLogDataSource extends RuntimeDataSource { + constructor( + public readonly pluginId: string, + public readonly uid: string, + private readonly extensionsLog: ExtensionsLog + ) { + super(pluginId, uid); + } + + query(request: DataQueryRequest): Observable { + const [query] = request.targets; + + return this.extensionsLog.asObservable().pipe( + scan( + (response, item) => { + const [existing] = response.data; + + return { + data: [createFrame(query, item, existing)], + key: query.key ?? query.refId, + state: LoadingState.Streaming, + }; + }, + { + data: [], + key: query.key ?? query.refId, + state: LoadingState.Streaming, + } + ) + ); + } + + testDatasource(): Promise { + return Promise.resolve({ status: 'success', message: 'OK' }); + } +} + +function createFrame(query: SceneDataQuery, item: ExtensionsLogItem, existing?: DataFrame): DataFrame { + const timestamps = existing?.fields?.[0]?.values ?? []; + const messages = existing?.fields?.[1]?.values ?? []; + const levels = existing?.fields?.[2]?.values ?? []; + const ids = existing?.fields?.[3]?.values ?? []; + const labels = existing?.fields?.[4]?.values ?? []; + const pluginIds = existing?.fields?.[5]?.values ?? []; + const extensionPointIds = existing?.fields?.[6]?.values ?? []; + + return createDataFrame({ + refId: query.refId, + meta: { type: DataFrameType.LogLines }, + fields: [ + { + name: 'timestamp', + type: FieldType.time, + values: [item.timestamp, ...timestamps], + }, + { + name: 'body', + type: FieldType.string, + values: [item.message, ...messages], + }, + { + name: 'severity', + type: FieldType.string, + values: [item.level, ...levels], + }, + { + name: 'id', + type: FieldType.string, + values: [item.id, ...ids], + }, + { + name: 'labels', + type: FieldType.other, + values: [item.labels, ...labels], + }, + { + name: 'pluginId', + type: FieldType.string, + values: [item.pluginId ?? null, ...pluginIds], + }, + { + name: 'extensionPointId', + type: FieldType.string, + values: [item.extensionPointId ?? null, ...extensionPointIds], + }, + ], + }); +} diff --git a/public/app/features/plugins/extensions/logs/filterTransformation.test.ts b/public/app/features/plugins/extensions/logs/filterTransformation.test.ts new file mode 100644 index 00000000000..17ed1800535 --- /dev/null +++ b/public/app/features/plugins/extensions/logs/filterTransformation.test.ts @@ -0,0 +1,361 @@ +import { lastValueFrom, Observable } from 'rxjs'; + +import { DataFrame, FieldType, toDataFrame } from '@grafana/data'; + +import { LogFilter } from './LogViewFilters'; +import { createFilterTransformation } from './filterTransformation'; + +const data = [ + toDataFrame({ + name: 'A', + length: 3, + fields: [ + { name: 'pluginId', type: FieldType.string, values: ['grafana-k8s-app', 'grafana', 'mckn-funnel-panel'] }, + { + name: 'extensionPointId', + type: FieldType.string, + values: [ + 'grafana/explore/toolbar/actions', + 'grafana-k8s-app/clusters/view/v1', + 'grafana/dashboards/panel/menu/v1', + ], + }, + { name: 'severity', type: FieldType.string, values: ['info', 'info', 'info'] }, + ], + }), + toDataFrame({ + name: 'B', + length: 3, + fields: [ + { name: 'pluginId', type: FieldType.string, values: ['grafana-k8s-app', 'grafana', 'mckn-funnel-panel'] }, + { + name: 'extensionPointId', + type: FieldType.string, + values: [ + 'grafana-k8s-app/clusters/view/v1', + 'grafana/dashboards/panel/menu/v1', + 'grafana/explore/toolbar/actions', + ], + }, + { name: 'severity', type: FieldType.string, values: ['debug', 'warning', 'error'] }, + ], + }), +]; + +describe('Transform data frames by filtering', () => { + it('should keep all rows when no filter is applied', async () => { + const [a, b] = await runTransformationWithFilter({}, data); + expect(a.length).toBe(data[0].length); + expect(b.length).toBe(data[1].length); + }); + + it('should exclude all rows not matching pluginId', async () => { + const filter = { + pluginIds: new Set(['grafana-k8s-app']), + }; + const [a, b] = await runTransformationWithFilter(filter, data); + + expect(a.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: ['grafana-k8s-app'], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: ['grafana/explore/toolbar/actions'], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: ['info'], + }, + ]); + + expect(b.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: ['grafana-k8s-app'], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: ['grafana-k8s-app/clusters/view/v1'], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: ['debug'], + }, + ]); + }); + + it('should exclude all rows not matching severity', async () => { + const filter = { + severity: new Set(['debug']), + }; + const [a, b] = await runTransformationWithFilter(filter, data); + + expect(a.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: [], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: [], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: [], + }, + ]); + + expect(b.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: ['grafana-k8s-app'], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: ['grafana-k8s-app/clusters/view/v1'], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: ['debug'], + }, + ]); + }); + + it('should exclude all rows not matching extensionPointId', async () => { + const filter = { + extensionPointIds: new Set(['grafana/dashboards/panel/menu/v1']), + }; + const [a, b] = await runTransformationWithFilter(filter, data); + + expect(a.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: ['mckn-funnel-panel'], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: ['grafana/dashboards/panel/menu/v1'], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: ['info'], + }, + ]); + + expect(b.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: ['grafana'], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: ['grafana/dashboards/panel/menu/v1'], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: ['warning'], + }, + ]); + }); + + it('should exclude all rows not matching pluginId and severity', async () => { + const filter = { + pluginIds: new Set(['grafana-k8s-app']), + severity: new Set(['debug']), + }; + const [a, b] = await runTransformationWithFilter(filter, data); + + expect(a.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: [], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: [], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: [], + }, + ]); + + expect(b.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: ['grafana-k8s-app'], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: ['grafana-k8s-app/clusters/view/v1'], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: ['debug'], + }, + ]); + }); + + it('should exclude all rows not matching pluginId and severity', async () => { + const filter = { + pluginIds: new Set(['grafana-k8s-app', 'grafana']), + severity: new Set(['info']), + }; + const [a, b] = await runTransformationWithFilter(filter, data); + + expect(a.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: ['grafana-k8s-app', 'grafana'], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: ['grafana/explore/toolbar/actions', 'grafana-k8s-app/clusters/view/v1'], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: ['info', 'info'], + }, + ]); + + expect(b.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: [], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: [], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: [], + }, + ]); + }); + + it('should exclude all rows not matching one of pluginId with severity and extensionPointId', async () => { + const filter = { + pluginIds: new Set(['grafana-k8s-app', 'grafana']), + severity: new Set(['info']), + extensionPointIds: new Set(['grafana/explore/toolbar/actions']), + }; + const [a, b] = await runTransformationWithFilter(filter, data); + + expect(a.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: ['grafana-k8s-app'], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: ['grafana/explore/toolbar/actions'], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: ['info'], + }, + ]); + + expect(b.fields).toStrictEqual([ + { + config: {}, + name: 'pluginId', + type: FieldType.string, + values: [], + }, + { + config: {}, + name: 'extensionPointId', + type: FieldType.string, + values: [], + }, + { + config: {}, + name: 'severity', + type: FieldType.string, + values: [], + }, + ]); + }); +}); + +function runTransformationWithFilter(filter: LogFilter, frames: DataFrame[]): Promise { + const transformation = createFilterTransformation(filter); + const operator = transformation({ interpolate: () => '' }); + + return lastValueFrom( + new Observable((sub) => { + sub.next(frames); + sub.complete(); + }).pipe(operator) + ); +} diff --git a/public/app/features/plugins/extensions/logs/filterTransformation.ts b/public/app/features/plugins/extensions/logs/filterTransformation.ts new file mode 100644 index 00000000000..32852c38bdc --- /dev/null +++ b/public/app/features/plugins/extensions/logs/filterTransformation.ts @@ -0,0 +1,86 @@ +import { isEmpty } from 'lodash'; +import { Observable, scan } from 'rxjs'; + +import { createDataFrame, CustomTransformOperator, DataFrame, PartialDataFrame } from '@grafana/data'; + +import { LogFilter } from './LogViewFilters'; + +export function createFilterTransformation(filter: LogFilter): CustomTransformOperator { + return function cascadingFilterTransformation() { + return function (source: Observable) { + return source.pipe( + scan((filtered: DataFrame[], current) => { + if (isEmpty(filter.extensionPointIds) && isEmpty(filter.pluginIds) && isEmpty(filter.severity)) { + return current; + } + + for (const frame of current) { + const pluginIdIndex = frame.fields.findIndex((f) => f.name === 'pluginId'); + const extensionPointIdIndex = frame.fields.findIndex((f) => f.name === 'extensionPointId'); + const severityIndex = frame.fields.findIndex((f) => f.name === 'severity'); + + if (pluginIdIndex === -1 && !isEmpty(filter.pluginIds)) { + continue; + } + + if (extensionPointIdIndex === -1 && !isEmpty(filter.extensionPointIds)) { + continue; + } + + if (severityIndex === -1 && !isEmpty(filter.severity)) { + continue; + } + + const target: PartialDataFrame = { + ...frame, + fields: frame.fields.map((f) => ({ + ...f, + values: [], + })), + }; + + for (let index = 0; index < frame.length; index++) { + const pluginId = frame.fields[pluginIdIndex].values[index]; + const extensionPointId = frame.fields[extensionPointIdIndex].values[index]; + const severity = frame.fields[severityIndex].values[index]; + + if (!isEmpty(filter.pluginIds) && !filter.pluginIds?.has(pluginId)) { + continue; + } + + if (!isEmpty(filter.extensionPointIds) && !filter.extensionPointIds?.has(extensionPointId)) { + continue; + } + + if (!isEmpty(filter.severity) && !filter.severity?.has(severity)) { + continue; + } + + copyRow(frame, target, index); + } + + filtered.push(createDataFrame(target)); + } + + return filtered; + }, []) + ); + }; + }; +} + +function copyRow(source: DataFrame, target: PartialDataFrame, rowIndex: number) { + for (let index = 0; index < source.fields.length; index++) { + const field = source.fields[index]; + + if (!target.fields[index]) { + target.fields[index] = { + ...field, + values: [], + }; + } + + const value = source.fields[index].values[rowIndex]; + target.fields[index].values?.push(value); + } +} diff --git a/public/app/features/plugins/extensions/logs/log.ts b/public/app/features/plugins/extensions/logs/log.ts new file mode 100644 index 00000000000..a78d42ccea0 --- /dev/null +++ b/public/app/features/plugins/extensions/logs/log.ts @@ -0,0 +1,94 @@ +import { isString } from 'lodash'; +import { nanoid } from 'nanoid'; +import { Observable, ReplaySubject } from 'rxjs'; + +import { Labels, LogLevel } from '@grafana/data'; + +export type ExtensionsLogItem = { + level: LogLevel; + timestamp: number; + labels: Labels; + message: string; + id: string; + pluginId?: string; + extensionPointId?: string; +}; + +const channelName = 'ui-extension-logs'; + +export class ExtensionsLog { + private baseLabels: Labels | undefined; + private subject: ReplaySubject | undefined; + private channel: BroadcastChannel; + + constructor(baseLabels?: Labels, subject?: ReplaySubject, channel?: BroadcastChannel) { + this.baseLabels = baseLabels; + this.channel = channel ?? new BroadcastChannel(channelName); + this.subject = subject; + } + + info(message: string, labels?: Labels): void { + this.log(LogLevel.info, message, labels); + } + + warning(message: string, labels?: Labels): void { + this.log(LogLevel.warning, message, labels); + } + + error(message: string, labels?: Labels): void { + this.log(LogLevel.error, message, labels); + } + + debug(message: string, labels?: Labels): void { + this.log(LogLevel.debug, message, labels); + } + + trace(message: string, labels?: Labels): void { + this.log(LogLevel.trace, message, labels); + } + + fatal(message: string, labels?: Labels): void { + this.log(LogLevel.fatal, message, labels); + } + + private log(level: LogLevel, message: string, labels?: Labels): void { + const combinedLabels = { ...labels, ...this.baseLabels }; + const { pluginId, extensionPointId } = combinedLabels; + + const item: ExtensionsLogItem = { + level: level, + labels: combinedLabels, + timestamp: Date.now(), + id: nanoid(), + message: message, + pluginId: isString(pluginId) ? pluginId : undefined, + extensionPointId: isString(extensionPointId) ? extensionPointId : undefined, + }; + + this.channel.postMessage(item); + } + + asObservable(): Observable { + if (!this.subject) { + // Lazily create the subject on first subscription to prevent + // to create buffers when no subscribers exists + this.subject = new ReplaySubject(1000, 1000 * 60 * 10); + this.channel.onmessage = (msg: MessageEvent) => this.subject?.next(msg.data); + } + + return this.subject.asObservable(); + } + + child(labels: Labels): ExtensionsLog { + return new ExtensionsLog( + { + ...labels, + ...this.baseLabels, + }, + this.subject, + this.channel + ); + } +} + +export const log = new ExtensionsLog(); diff --git a/public/app/features/plugins/extensions/logs/testUtils.ts b/public/app/features/plugins/extensions/logs/testUtils.ts new file mode 100644 index 00000000000..2877b9075c7 --- /dev/null +++ b/public/app/features/plugins/extensions/logs/testUtils.ts @@ -0,0 +1,31 @@ +import { ExtensionsLog } from './log'; + +export function createLogMock(): ExtensionsLog { + const { log: original } = jest.requireActual('./log'); + + const logMock = { + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + fatal: jest.fn(), + child: jest.fn(), + }; + + logMock.child.mockReturnValue(logMock); + + return { + ...original, + ...logMock, + }; +} + +export function resetLogMock(log: ExtensionsLog): void { + jest.mocked(log.error).mockReset(); + jest.mocked(log.warning).mockReset(); + jest.mocked(log.info).mockReset(); + jest.mocked(log.debug).mockReset(); + jest.mocked(log.trace).mockReset(); + jest.mocked(log.fatal).mockReset(); +} diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index 8e0041d185c..fcf3d4616bb 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -4,6 +4,8 @@ import { firstValueFrom } from 'rxjs'; import { PluginLoadingStrategy } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { log } from '../logs/log'; +import { resetLogMock } from '../logs/testUtils'; import { isGrafanaDevMode } from '../utils'; import { AddedComponentsRegistry } from './AddedComponentsRegistry'; @@ -17,8 +19,17 @@ jest.mock('../utils', () => ({ isGrafanaDevMode: jest.fn().mockReturnValue(false), })); +jest.mock('../logs/log', () => { + const { createLogMock } = jest.requireActual('../logs/testUtils'); + const original = jest.requireActual('../logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + describe('AddedComponentsRegistry', () => { - const consoleWarn = jest.fn(); const originalApps = config.apps; const pluginId = 'grafana-basic-app'; const appPluginConfig = { @@ -47,8 +58,7 @@ describe('AddedComponentsRegistry', () => { }; beforeEach(() => { - global.console.warn = consoleWarn; - consoleWarn.mockReset(); + resetLogMock(log); jest.mocked(isGrafanaDevMode).mockReturnValue(false); config.apps = { [pluginId]: appPluginConfig, @@ -363,8 +373,8 @@ describe('AddedComponentsRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalledWith( - `[Plugin Extensions] Added component "Component 1 title": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.` + expect(log.warning).toHaveBeenCalledWith( + `Added component "Component 1 title": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.` ); const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); @@ -386,8 +396,8 @@ describe('AddedComponentsRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Could not register added component with title 'Component 1 title'. Reason: Description is missing." + expect(log.error).toHaveBeenCalledWith( + "Could not register added component with title 'Component 1 title'. Reason: Description is missing." ); const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(0); @@ -401,7 +411,7 @@ describe('AddedComponentsRegistry', () => { pluginId, configs: [ { - title: 'Component 1 title', + title: '', description: '', targets: [extensionPointId], component: () => React.createElement('div', null, 'Hello World1'), @@ -409,9 +419,7 @@ describe('AddedComponentsRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Could not register added component with title 'Component 1 title'. Reason: Description is missing." - ); + expect(log.error).toHaveBeenCalledWith('Could not register added component. Reason: Title is missing.'); const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(0); @@ -497,7 +505,7 @@ describe('AddedComponentsRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(0); - expect(consoleWarn).toHaveBeenCalled(); + expect(log.warning).toHaveBeenCalled(); }); it('should register a component added by a core Grafana in dev-mode even if the meta-info is missing', async () => { @@ -520,7 +528,7 @@ describe('AddedComponentsRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should register a component added by a plugin in production mode even if the meta-info is missing', async () => { @@ -546,7 +554,7 @@ describe('AddedComponentsRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should register a component added by a plugin in dev-mode if the meta-info is present', async () => { @@ -572,6 +580,6 @@ describe('AddedComponentsRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts index 1760f3d36cf..a57f56c6008 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -2,7 +2,7 @@ import { ReplaySubject } from 'rxjs'; import { PluginExtensionAddedComponentConfig } from '@grafana/data'; -import { isAddedComponentMetaInfoMissing, isGrafanaDevMode, logWarning, wrapWithPluginContext } from '../utils'; +import { isAddedComponentMetaInfoMissing, isGrafanaDevMode, wrapWithPluginContext } from '../utils'; import { extensionPointEndsWithVersion, isGrafanaCoreExtensionPoint, isReactComponent } from '../validators'; import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; @@ -34,42 +34,58 @@ export class AddedComponentsRegistry extends Registry< const { pluginId, configs } = item; for (const config of configs) { + const configLog = this.logger.child({ + description: config.description, + title: config.title, + pluginId, + }); + if (!isReactComponent(config.component)) { - logWarning( - `Could not register added component with title '${config.title}'. Reason: The provided component is not a valid React component.` + configLog.error( + `Could not register added component. Reason: The provided component is not a valid React component.` ); continue; } if (!config.title) { - logWarning(`Could not register added component with title '${config.title}'. Reason: Title is missing.`); + configLog.error(`Could not register added component. Reason: Title is missing.`); continue; } if (!config.description) { - logWarning(`Could not register added component with title '${config.title}'. Reason: Description is missing.`); + configLog.error( + `Could not register added component with title '${config.title}'. Reason: Description is missing.` + ); continue; } - if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedComponentMetaInfoMissing(pluginId, config)) { + if ( + pluginId !== 'grafana' && + isGrafanaDevMode() && + isAddedComponentMetaInfoMissing(pluginId, config, configLog) + ) { continue; } const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets]; for (const extensionPointId of extensionPointIds) { + const pointIdLog = configLog.child({ extensionPointId }); + if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) { - logWarning( + pointIdLog.warning( `Added component "${config.title}": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.` ); } const result = { pluginId, - component: wrapWithPluginContext(pluginId, config.component), + component: wrapWithPluginContext(pluginId, config.component, pointIdLog), description: config.description, title: config.title, }; + pointIdLog.debug(`Added component from '${pluginId}' to '${extensionPointId}'`); + if (!(extensionPointId in registry)) { registry[extensionPointId] = [result]; } else { diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts index 45735806af9..3dd5b7c7f7d 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts @@ -3,6 +3,8 @@ import { firstValueFrom } from 'rxjs'; import { PluginLoadingStrategy } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { log } from '../logs/log'; +import { resetLogMock } from '../logs/testUtils'; import { isGrafanaDevMode } from '../utils'; import { AddedLinksRegistry } from './AddedLinksRegistry'; @@ -16,9 +18,18 @@ jest.mock('../utils', () => ({ isGrafanaDevMode: jest.fn().mockReturnValue(false), })); +jest.mock('../logs/log', () => { + const { createLogMock } = jest.requireActual('../logs/testUtils'); + const original = jest.requireActual('../logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + describe('AddedLinksRegistry', () => { const originalApps = config.apps; - const consoleWarn = jest.fn(); const pluginId = 'grafana-basic-app'; const appPluginConfig = { id: pluginId, @@ -46,8 +57,7 @@ describe('AddedLinksRegistry', () => { }; beforeEach(() => { - global.console.warn = consoleWarn; - consoleWarn.mockReset(); + resetLogMock(log); jest.mocked(isGrafanaDevMode).mockReturnValue(false); config.apps = { [pluginId]: appPluginConfig, @@ -503,7 +513,7 @@ describe('AddedLinksRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalled(); + expect(log.error).toHaveBeenCalled(); observable.subscribe(subscribeCallback); expect(subscribeCallback).toHaveBeenCalledTimes(1); @@ -531,7 +541,7 @@ describe('AddedLinksRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalled(); + expect(log.error).toHaveBeenCalled(); observable.subscribe(subscribeCallback); expect(subscribeCallback).toHaveBeenCalledTimes(1); @@ -559,7 +569,7 @@ describe('AddedLinksRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalled(); + expect(log.error).toHaveBeenCalled(); observable.subscribe(subscribeCallback); expect(subscribeCallback).toHaveBeenCalledTimes(1); @@ -651,7 +661,7 @@ describe('AddedLinksRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(0); - expect(consoleWarn).toHaveBeenCalled(); + expect(log.warning).toHaveBeenCalled(); }); it('should register a link added by core Grafana in dev-mode even if the meta-info is missing', async () => { @@ -675,7 +685,7 @@ describe('AddedLinksRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should register a link added by a plugin in production mode even if the meta-info is missing', async () => { @@ -702,7 +712,7 @@ describe('AddedLinksRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should register a link added by a plugin in dev-mode if the meta-info is present', async () => { @@ -729,6 +739,6 @@ describe('AddedLinksRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts index d2f6207e496..0e978b993c6 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -3,7 +3,7 @@ import { ReplaySubject } from 'rxjs'; import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data'; import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions'; -import { isAddedLinkMetaInfoMissing, isGrafanaDevMode, logWarning } from '../utils'; +import { isAddedLinkMetaInfoMissing, isGrafanaDevMode } from '../utils'; import { extensionPointEndsWithVersion, isConfigureFnValid, @@ -43,43 +43,53 @@ export class AddedLinksRegistry extends Registry ({ isGrafanaDevMode: jest.fn().mockReturnValue(false), })); +jest.mock('../logs/log', () => { + const { createLogMock } = jest.requireActual('../logs/testUtils'); + const original = jest.requireActual('../logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + describe('ExposedComponentsRegistry', () => { - const consoleWarn = jest.fn(); const originalApps = config.apps; const pluginId = 'grafana-basic-app'; const appPluginConfig = { @@ -47,8 +58,7 @@ describe('ExposedComponentsRegistry', () => { }; beforeEach(() => { - global.console.warn = consoleWarn; - consoleWarn.mockReset(); + resetLogMock(log); jest.mocked(isGrafanaDevMode).mockReturnValue(false); config.apps = { [pluginId]: appPluginConfig, @@ -282,7 +292,7 @@ describe('ExposedComponentsRegistry', () => { }); registry.register({ - pluginId: 'grafana-basic-app2', + pluginId: 'grafana-basic-app1', configs: [ { id: 'grafana-basic-app1/hello-world/v1', // incorrectly scoped @@ -293,8 +303,8 @@ describe('ExposedComponentsRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Could not register exposed component with id 'grafana-basic-app1/hello-world/v1'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'." + expect(log.error).toHaveBeenCalledWith( + "Could not register exposed component with 'grafana-basic-app1/hello-world/v1'. Reason: An exposed component with the same id already exists." ); const currentState2 = await registry.getState(); expect(Object.keys(currentState2)).toHaveLength(1); @@ -314,14 +324,14 @@ describe('ExposedComponentsRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Could not register exposed component with id 'hello-world/v1'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'." + expect(log.error).toHaveBeenCalledWith( + "Could not register exposed component with 'hello-world/v1'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'." ); const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(0); }); - it('should log a warning when exposed component id is not suffixed with component version', async () => { + it('should log a error when exposed component id is not suffixed with component version', async () => { const registry = new ExposedComponentsRegistry(); registry.register({ pluginId: 'grafana-basic-app1', @@ -335,8 +345,8 @@ describe('ExposedComponentsRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Exposed component with id 'grafana-basic-app1/hello-world' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'." + expect(log.error).toHaveBeenCalledWith( + "Exposed component does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'." ); const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); @@ -357,8 +367,8 @@ describe('ExposedComponentsRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Could not register exposed component with id 'grafana-basic-app/hello-world/v1'. Reason: Description is missing." + expect(log.error).toHaveBeenCalledWith( + "Could not register exposed component with id 'grafana-basic-app/hello-world/v1'. Reason: Description is missing." ); const currentState = await registry.getState(); @@ -380,8 +390,8 @@ describe('ExposedComponentsRegistry', () => { ], }); - expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Could not register exposed component with id 'grafana-basic-app/hello-world/v1'. Reason: Title is missing." + expect(log.error).toHaveBeenCalledWith( + "Could not register exposed component with id 'grafana-basic-app/hello-world/v1'. Reason: Title is missing." ); const currentState = await registry.getState(); @@ -468,7 +478,7 @@ describe('ExposedComponentsRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(0); - expect(consoleWarn).toHaveBeenCalled(); + expect(log.warning).toHaveBeenCalled(); }); it('should register an exposed component added by a core Grafana in dev-mode even if the meta-info is missing', async () => { @@ -491,7 +501,7 @@ describe('ExposedComponentsRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should register an exposed component added by a plugin in production mode even if the meta-info is missing', async () => { @@ -517,7 +527,7 @@ describe('ExposedComponentsRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should register an exposed component added by a plugin in dev-mode if the meta-info is present', async () => { @@ -543,6 +553,6 @@ describe('ExposedComponentsRegistry', () => { const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); - expect(consoleWarn).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); }); diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts index c84c89ab199..85f3776f5b9 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts @@ -2,7 +2,7 @@ import { ReplaySubject } from 'rxjs'; import { PluginExtensionExposedComponentConfig } from '@grafana/data'; -import { isExposedComponentMetaInfoMissing, isGrafanaDevMode, logWarning } from '../utils'; +import { isExposedComponentMetaInfoMissing, isGrafanaDevMode } from '../utils'; import { extensionPointEndsWithVersion } from '../validators'; import { Registry, RegistryType, PluginExtensionConfigs } from './Registry'; @@ -37,41 +37,53 @@ export class ExposedComponentsRegistry extends Registry< for (const config of configs) { const { id, description, title } = config; + const pointIdLog = this.logger.child({ + extensionPointId: id, + description, + title, + pluginId, + }); if (!id.startsWith(pluginId)) { - logWarning( - `Could not register exposed component with id '${id}'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'.` + pointIdLog.error( + `Could not register exposed component with '${id}'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'.` ); continue; } if (!extensionPointEndsWithVersion(id)) { - logWarning( - `Exposed component with id '${id}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.` + pointIdLog.error( + `Exposed component does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.` ); } if (registry[id]) { - logWarning( - `Could not register exposed component with id '${id}'. Reason: An exposed component with the same id already exists.` + pointIdLog.error( + `Could not register exposed component with '${id}'. Reason: An exposed component with the same id already exists.` ); continue; } if (!title) { - logWarning(`Could not register exposed component with id '${id}'. Reason: Title is missing.`); + pointIdLog.error(`Could not register exposed component with id '${id}'. Reason: Title is missing.`); continue; } if (!description) { - logWarning(`Could not register exposed component with id '${id}'. Reason: Description is missing.`); + pointIdLog.error(`Could not register exposed component with id '${id}'. Reason: Description is missing.`); continue; } - if (pluginId !== 'grafana' && isGrafanaDevMode() && isExposedComponentMetaInfoMissing(pluginId, config)) { + if ( + pluginId !== 'grafana' && + isGrafanaDevMode() && + isExposedComponentMetaInfoMissing(pluginId, config, pointIdLog) + ) { continue; } + pointIdLog.debug(`Exposed component from '${pluginId}' to '${id}'`); + registry[id] = { ...config, pluginId }; } diff --git a/public/app/features/plugins/extensions/registry/Registry.ts b/public/app/features/plugins/extensions/registry/Registry.ts index 33470990e37..b2fbfbc09d8 100644 --- a/public/app/features/plugins/extensions/registry/Registry.ts +++ b/public/app/features/plugins/extensions/registry/Registry.ts @@ -1,5 +1,6 @@ import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs'; +import { ExtensionsLog, log } from '../logs/log'; import { deepFreeze } from '../utils'; export const MSG_CANNOT_REGISTER_READ_ONLY = 'Cannot register to a read-only registry'; @@ -19,6 +20,7 @@ export abstract class Registry { private isReadOnly: boolean; // This is the subject that receives extension configs for a loaded plugin. private resultSubject: Subject>; + protected logger: ExtensionsLog; // This is the subject that we expose. // (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.) protected registrySubject: ReplaySubject>; @@ -26,8 +28,10 @@ export abstract class Registry { constructor(options: { registrySubject?: ReplaySubject>; initialState?: RegistryType; + log?: ExtensionsLog; }) { this.resultSubject = new Subject>(); + this.logger = options.log ?? log; this.isReadOnly = false; // If the registry subject (observable) is provided, it means that all the registry updates are taken care of outside of this class -> it is read-only. @@ -41,7 +45,7 @@ export abstract class Registry { this.registrySubject = new ReplaySubject>(1); this.resultSubject .pipe( - scan(this.mapToRegistry, options.initialState ?? {}), + scan(this.mapToRegistry.bind(this), options.initialState ?? {}), // Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values) startWith(options.initialState ?? {}), map((registry) => deepFreeze(registry)) diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 7cf467b5e38..365dd520e91 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -5,6 +5,8 @@ import { PluginContextProvider, PluginLoadingStrategy, PluginMeta, PluginType } import { config } from '@grafana/runtime'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; +import { log } from './logs/log'; +import { resetLogMock } from './logs/testUtils'; import { setupPluginExtensionRegistries } from './registry/setup'; import { PluginExtensionRegistries } from './registry/types'; import { usePluginComponent } from './usePluginComponent'; @@ -30,11 +32,20 @@ jest.mock('./utils', () => ({ wrapWithPluginContext: jest.fn().mockImplementation((_, component: React.ReactNode) => component), })); +jest.mock('./logs/log', () => { + const { createLogMock } = jest.requireActual('./logs/testUtils'); + const original = jest.requireActual('./logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + describe('usePluginComponent()', () => { let registries: PluginExtensionRegistries; let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; let pluginMeta: PluginMeta; - let consoleWarnSpy: jest.SpyInstance; const originalApps = config.apps; const pluginId = 'myorg-extensions-app'; const exposedComponentId = `${pluginId}/exposed-component/v1`; @@ -74,7 +85,7 @@ describe('usePluginComponent()', () => { beforeEach(() => { registries = setupPluginExtensionRegistries(); jest.mocked(isGrafanaDevMode).mockReturnValue(false); - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + resetLogMock(log); jest.mocked(wrapWithPluginContext).mockClear(); @@ -221,7 +232,7 @@ describe('usePluginComponent()', () => { // (No restrictions due to isGrafanaDevMode() = false) let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); expect(result.current.component).not.toBe(null); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should not validate the meta-info in core Grafana', () => { @@ -245,7 +256,7 @@ describe('usePluginComponent()', () => { }); expect(result.current.component).not.toBe(null); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should validate the meta-info in dev mode and if inside a plugin', () => { @@ -277,7 +288,7 @@ describe('usePluginComponent()', () => { // Shouldn't return the component, as it's not present in the plugin.json dependencies let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); expect(result.current.component).toBe(null); - expect(consoleWarnSpy).toHaveBeenCalled(); + expect(log.warning).toHaveBeenCalled(); }); it('should return the exposed component if the meta-info is correct and in dev mode', () => { @@ -307,6 +318,6 @@ describe('usePluginComponent()', () => { let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); expect(result.current.component).not.toBe(null); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); }); diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 9ae43c27bbf..a32b6aa974c 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -2,9 +2,10 @@ import { useMemo } from 'react'; import { useObservable } from 'react-use'; import { usePluginContext } from '@grafana/data'; -import { logWarning, UsePluginComponentResult } from '@grafana/runtime'; +import { UsePluginComponentResult } from '@grafana/runtime'; import { useExposedComponentsRegistry } from './ExtensionRegistriesContext'; +import { log } from './logs/log'; import { isExposedComponentDependencyMissing, isGrafanaDevMode, wrapWithPluginContext } from './utils'; // Returns a component exposed by a plugin. @@ -18,16 +19,6 @@ export function usePluginComponent(id: string): UsePl // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. const enableRestrictions = isGrafanaDevMode() && pluginContext; - if (enableRestrictions && isExposedComponentDependencyMissing(id, pluginContext)) { - logWarning( - `usePluginComponent("${id}") - The exposed component ("${id}") is missing from the dependencies[] in the "plugin.json" file.` - ); - return { - isLoading: false, - component: null, - }; - } - if (!registryState?.[id]) { return { isLoading: false, @@ -36,10 +27,25 @@ export function usePluginComponent(id: string): UsePl } const registryItem = registryState[id]; + const componentLog = log.child({ + title: registryItem.title, + description: registryItem.description, + pluginId: registryItem.pluginId, + }); + + if (enableRestrictions && isExposedComponentDependencyMissing(id, pluginContext, componentLog)) { + componentLog.warning( + `usePluginComponent("${id}") - The exposed component ("${id}") is missing from the dependencies[] in the "plugin.json" file.` + ); + return { + isLoading: false, + component: null, + }; + } return { isLoading: false, - component: wrapWithPluginContext(registryItem.pluginId, registryItem.component), + component: wrapWithPluginContext(registryItem.pluginId, registryItem.component, componentLog), }; }, [id, pluginContext, registryState]); } diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index 0c37e90e549..d174a871cc7 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -4,6 +4,8 @@ import { renderHook } from '@testing-library/react-hooks'; import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; +import { log } from './logs/log'; +import { resetLogMock } from './logs/testUtils'; import { setupPluginExtensionRegistries } from './registry/setup'; import { PluginExtensionRegistries } from './registry/types'; import { usePluginComponents } from './usePluginComponents'; @@ -29,18 +31,27 @@ jest.mock('./utils', () => ({ wrapWithPluginContext: jest.fn().mockImplementation((_, component: React.ReactNode) => component), })); +jest.mock('./logs/log', () => { + const { createLogMock } = jest.requireActual('./logs/testUtils'); + const original = jest.requireActual('./logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + describe('usePluginComponents()', () => { 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); + resetLogMock(log); registries = setupPluginExtensionRegistries(); - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); jest.mocked(wrapWithPluginContext).mockClear(); @@ -251,7 +262,7 @@ describe('usePluginComponents()', () => { // (No restrictions due to isGrafanaDevMode() = false) let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); expect(result.current.components.length).toBe(1); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should not validate the extension point id in production mode', () => { @@ -276,7 +287,7 @@ describe('usePluginComponents()', () => { wrapper, }); expect(result.current.components.length).toBe(0); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => { @@ -305,7 +316,7 @@ describe('usePluginComponents()', () => { wrapper, }); expect(result.current.components.length).toBe(1); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should not validate the extension point id if used in Grafana core (no plugin context)', () => { @@ -321,7 +332,7 @@ describe('usePluginComponents()', () => { wrapper, }); expect(result.current.components.length).toBe(0); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { @@ -359,7 +370,7 @@ describe('usePluginComponents()', () => { // Trying to render an extension point that is not defined in the plugin meta let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); expect(result.current.components.length).toBe(0); - expect(consoleWarnSpy).toHaveBeenCalled(); + expect(log.warning).toHaveBeenCalled(); }); it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { @@ -403,6 +414,6 @@ describe('usePluginComponents()', () => { // Trying to render an extension point that is not defined in the plugin meta let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); expect(result.current.components.length).toBe(0); - expect(consoleWarnSpy).toHaveBeenCalled(); + expect(log.warning).toHaveBeenCalled(); }); }); diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index 92dbc4e676c..45a6c6d9487 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -8,7 +8,8 @@ import { } from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions'; import { useAddedComponentsRegistry } from './ExtensionRegistriesContext'; -import { isExtensionPointMetaInfoMissing, isGrafanaDevMode, logWarning } from './utils'; +import { log } from './logs/log'; +import { isExtensionPointMetaInfoMissing, isGrafanaDevMode } from './utils'; import { isExtensionPointIdValid } from './validators'; // Returns an array of component extensions for the given extension point @@ -26,9 +27,13 @@ export function usePluginComponents({ const components: Array> = []; const extensionsByPlugin: Record = {}; const pluginId = pluginContext?.meta.id ?? ''; + const pointLog = log.child({ + pluginId, + extensionPointId, + }); if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { - logWarning( + pointLog.warning( `Extension point usePluginComponents("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").` ); return { @@ -37,8 +42,8 @@ export function usePluginComponents({ }; } - if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { - logWarning( + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, pointLog)) { + pointLog.warning( `usePluginComponents("${extensionPointId}") - The extension point is missing from the "plugin.json" file.` ); return { diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index 8b5c3e69f37..2ab882108de 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -6,8 +6,9 @@ import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/ import { useSidecar } from 'app/core/context/SidecarContext'; import { getPluginExtensions } from './getPluginExtensions'; +import { log } from './logs/log'; import { PluginExtensionRegistries } from './registry/types'; -import { isExtensionPointMetaInfoMissing, isGrafanaDevMode, logWarning } from './utils'; +import { isExtensionPointMetaInfoMissing, isGrafanaDevMode } from './utils'; import { isExtensionPointIdValid } from './validators'; export function createUsePluginExtensions(registries: PluginExtensionRegistries) { @@ -25,13 +26,17 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries) // 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 ?? ''; + const pointLog = log.child({ + pluginId, + extensionPointId, + }); if (!addedLinksRegistry && !addedComponentsRegistry) { return { extensions: [], isLoading: false }; } if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { - logWarning( + pointLog.warning( `Extension point usePluginExtensions("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").` ); return { @@ -40,8 +45,8 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries) }; } - if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { - logWarning( + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, pointLog)) { + pointLog.warning( `Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"` ); return { diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index d4d0cc6a843..8fd92bd944a 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -4,6 +4,8 @@ import { renderHook } from '@testing-library/react-hooks'; import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; +import { log } from './logs/log'; +import { resetLogMock } from './logs/testUtils'; import { setupPluginExtensionRegistries } from './registry/setup'; import { PluginExtensionRegistries } from './registry/types'; import { usePluginLinks } from './usePluginLinks'; @@ -28,18 +30,27 @@ jest.mock('./utils', () => ({ isGrafanaDevMode: jest.fn().mockReturnValue(false), })); +jest.mock('./logs/log', () => { + const { createLogMock } = jest.requireActual('./logs/testUtils'); + const original = jest.requireActual('./logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + 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(); + resetLogMock(log); pluginMeta = { id: pluginId, @@ -194,7 +205,7 @@ describe('usePluginLinks()', () => { // (No restrictions due to isGrafanaDevMode() = false) let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); expect(result.current.links.length).toBe(1); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should not validate the extension point id in production mode', () => { @@ -217,7 +228,7 @@ describe('usePluginLinks()', () => { // (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(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => { @@ -244,7 +255,7 @@ describe('usePluginLinks()', () => { let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'grafana/extension-point/v1' }), { wrapper }); expect(result.current.links.length).toBe(1); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should not validate the extension point id if used in Grafana core (no plugin context)', () => { @@ -258,7 +269,7 @@ describe('usePluginLinks()', () => { let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper }); expect(result.current.links.length).toBe(0); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(log.warning).not.toHaveBeenCalled(); }); it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { @@ -296,7 +307,7 @@ describe('usePluginLinks()', () => { // 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(); + expect(log.warning).toHaveBeenCalled(); }); it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { @@ -334,6 +345,6 @@ describe('usePluginLinks()', () => { // 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(); + expect(log.warning).toHaveBeenCalled(); }); }); diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 1f868b01c77..bd9bba9bcc9 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -9,6 +9,7 @@ import { } from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions'; import { useAddedLinksRegistry } from './ExtensionRegistriesContext'; +import { log } from './logs/log'; import { generateExtensionId, getLinkExtensionOnClick, @@ -17,7 +18,6 @@ import { getReadOnlyProxy, isExtensionPointMetaInfoMissing, isGrafanaDevMode, - logWarning, } from './utils'; import { isExtensionPointIdValid } from './validators'; @@ -35,9 +35,13 @@ export function usePluginLinks({ // 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 ?? ''; + const pointLog = log.child({ + pluginId, + extensionPointId, + }); if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { - logWarning( + pointLog.warning( `Extension point usePluginLinks("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").` ); return { @@ -46,8 +50,8 @@ export function usePluginLinks({ }; } - if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { - logWarning( + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, pointLog)) { + pointLog.warning( `Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"` ); return { @@ -78,8 +82,14 @@ export function usePluginLinks({ extensionsByPlugin[pluginId] = 0; } + const linkLog = pointLog.child({ + path: addedLink.path ?? '', + title: addedLink.title, + description: addedLink.description, + onClick: typeof addedLink.onClick, + }); // Run the configure() function with the current context, and apply the ovverides - const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext); + const overrides = getLinkExtensionOverrides(pluginId, addedLink, linkLog, frozenContext); // configure() returned an `undefined` -> hide the extension if (addedLink.configure && overrides === undefined) { @@ -91,7 +101,7 @@ export function usePluginLinks({ id: generateExtensionId(pluginId, extensionPointId, addedLink.title), type: PluginExtensionTypes.link, pluginId: pluginId, - onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), + onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, linkLog, frozenContext), // Configurable properties icon: overrides?.icon || addedLink.icon, diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index 3697fe0b71f..c090e2ac232 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -13,6 +13,8 @@ import { config } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; +import { log } from './logs/log'; +import { createLogMock } from './logs/testUtils'; import { deepFreeze, handleErrorsInFn, @@ -441,7 +443,7 @@ describe('Plugin Extensions / Utils', () => { it('should make the plugin context available for the wrapped component', async () => { const pluginId = 'grafana-worldmap-panel'; - const Component = wrapWithPluginContext(pluginId, ExampleComponent); + const Component = wrapWithPluginContext(pluginId, ExampleComponent, log); render(); @@ -451,7 +453,7 @@ describe('Plugin Extensions / Utils', () => { it('should pass the properties into the wrapped component', async () => { const pluginId = 'grafana-worldmap-panel'; - const Component = wrapWithPluginContext(pluginId, ExampleComponent); + const Component = wrapWithPluginContext(pluginId, ExampleComponent, log); render(); @@ -461,7 +463,6 @@ describe('Plugin Extensions / Utils', () => { }); describe('isAddedLinkMetaInfoMissing()', () => { - let consoleWarnSpy: jest.SpyInstance; const originalApps = config.apps; const pluginId = 'myorg-extensions-app'; const appPluginConfig = { @@ -495,7 +496,6 @@ describe('Plugin Extensions / Utils', () => { }; beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); config.apps = { [pluginId]: appPluginConfig, }; @@ -506,63 +506,75 @@ describe('Plugin Extensions / Utils', () => { }); it('should return FALSE if the meta-info in the plugin.json is correct', () => { + const log = createLogMock(); config.apps[pluginId].extensions.addedLinks.push(extensionConfig); - const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig); + const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log); expect(returnValue).toBe(false); - expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + expect(log.warning).toHaveBeenCalledTimes(0); }); it('should return TRUE and log a warning if the app config is not found', () => { + const log = createLogMock(); delete config.apps[pluginId]; - const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig); + const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin"); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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', () => { + const log = createLogMock(); config.apps[pluginId].extensions.addedLinks = []; - const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig); + const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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', () => { + const log = createLogMock(); config.apps[pluginId].extensions.addedLinks.push(extensionConfig); - const returnValue = isAddedLinkMetaInfoMissing(pluginId, { - ...extensionConfig, - targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction], - }); + const returnValue = isAddedLinkMetaInfoMissing( + pluginId, + { + ...extensionConfig, + targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction], + }, + log + ); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"targets" don\'t match'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"targets" don\'t match'); }); it('should return TRUE and log a warning if the "description" does not match', () => { + const log = createLogMock(); config.apps[pluginId].extensions.addedLinks.push(extensionConfig); - const returnValue = isAddedLinkMetaInfoMissing(pluginId, { - ...extensionConfig, - description: 'Link description UPDATED', - }); + const returnValue = isAddedLinkMetaInfoMissing( + pluginId, + { + ...extensionConfig, + description: 'Link description UPDATED', + }, + log + ); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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 = { @@ -597,7 +609,6 @@ describe('Plugin Extensions / Utils', () => { }; beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); config.apps = { [pluginId]: appPluginConfig, }; @@ -608,63 +619,75 @@ describe('Plugin Extensions / Utils', () => { }); it('should return FALSE if the meta-info in the plugin.json is correct', () => { + const log = createLogMock(); config.apps[pluginId].extensions.addedComponents.push(extensionConfig); - const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig); + const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log); expect(returnValue).toBe(false); - expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + expect(log.warning).toHaveBeenCalledTimes(0); }); it('should return TRUE and log a warning if the app config is not found', () => { + const log = createLogMock(); delete config.apps[pluginId]; - const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig); + const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin"); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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', () => { + const log = createLogMock(); config.apps[pluginId].extensions.addedComponents = []; - const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig); + const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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', () => { + const log = createLogMock(); config.apps[pluginId].extensions.addedComponents.push(extensionConfig); - const returnValue = isAddedComponentMetaInfoMissing(pluginId, { - ...extensionConfig, - targets: [PluginExtensionPoints.ExploreToolbarAction], - }); + const returnValue = isAddedComponentMetaInfoMissing( + pluginId, + { + ...extensionConfig, + targets: [PluginExtensionPoints.ExploreToolbarAction], + }, + log + ); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"targets" don\'t match'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"targets" don\'t match'); }); it('should return TRUE and log a warning if the "description" does not match', () => { + const log = createLogMock(); config.apps[pluginId].extensions.addedComponents.push(extensionConfig); - const returnValue = isAddedComponentMetaInfoMissing(pluginId, { - ...extensionConfig, - description: 'UPDATED', - }); + const returnValue = isAddedComponentMetaInfoMissing( + pluginId, + { + ...extensionConfig, + description: 'UPDATED', + }, + log + ); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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 = { @@ -699,7 +722,6 @@ describe('Plugin Extensions / Utils', () => { }; beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); config.apps = { [pluginId]: appPluginConfig, }; @@ -710,69 +732,80 @@ describe('Plugin Extensions / Utils', () => { }); it('should return FALSE if the meta-info in the plugin.json is correct', () => { + const log = createLogMock(); config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig); - const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig); + const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log); expect(returnValue).toBe(false); - expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + expect(log.warning).toHaveBeenCalledTimes(0); }); it('should return TRUE and log a warning if the app config is not found', () => { + const log = createLogMock(); delete config.apps[pluginId]; - const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig); + const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin"); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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', () => { + const log = createLogMock(); config.apps[pluginId].extensions.exposedComponents = []; - const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig); + const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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', () => { + const log = createLogMock(); config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig); - const returnValue = isExposedComponentMetaInfoMissing(pluginId, { - ...exposedComponentConfig, - title: 'UPDATED', - }); + const returnValue = isExposedComponentMetaInfoMissing( + pluginId, + { + ...exposedComponentConfig, + title: 'UPDATED', + }, + log + ); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"title" doesn\'t match'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"title" doesn\'t match'); }); it('should return TRUE and log a warning if the "description" does not match', () => { + const log = createLogMock(); config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig); - const returnValue = isExposedComponentMetaInfoMissing(pluginId, { - ...exposedComponentConfig, - description: 'UPDATED', - }); + const returnValue = isExposedComponentMetaInfoMissing( + pluginId, + { + ...exposedComponentConfig, + description: 'UPDATED', + }, + log + ); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match'); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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, @@ -806,35 +839,37 @@ describe('Plugin Extensions / Utils', () => { }); it('should return FALSE if the meta-info in the plugin.json is correct', () => { + const log = createLogMock(); pluginContext.meta.dependencies?.extensions.exposedComponents.push(exposedComponentId); - const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext); + const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext, log); expect(returnValue).toBe(false); - expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + expect(log.warning).toHaveBeenCalledTimes(0); }); it('should return TRUE and log a warning if the dependencies are missing', () => { + const log = createLogMock(); delete pluginContext.meta.dependencies; - const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext); + const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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); + const log = createLogMock(); + const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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`; @@ -845,7 +880,6 @@ describe('Plugin Extensions / Utils', () => { }; beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); pluginContext = { meta: { id: pluginId, @@ -885,20 +919,22 @@ describe('Plugin Extensions / Utils', () => { }); it('should return FALSE if the meta-info in the plugin.json is correct', () => { + const log = createLogMock(); pluginContext.meta.extensions?.extensionPoints.push(extensionPointConfig); - const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext); + const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, log); expect(returnValue).toBe(false); - expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + expect(log.warning).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); + const log = createLogMock(); + const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, log); expect(returnValue).toBe(true); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Extension point "${extensionPointId}"`); + expect(log.warning).toHaveBeenCalledTimes(1); + expect(jest.mocked(log.warning).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 0938c1df666..5f92e51b7f6 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -28,13 +28,10 @@ import { sidecarService } from 'app/core/services/SidecarService'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { ShowModalReactEvent } from 'app/types/events'; +import { ExtensionsLog, log } from './logs/log'; import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators'; -export function logWarning(message: string) { - console.warn(`[Plugin Extensions] ${message}`); -} - export function isPluginExtensionLinkConfig( extension: PluginExtensionConfig | undefined ): extension is PluginExtensionLinkConfig { @@ -59,7 +56,11 @@ export function createOpenModalFunction(pluginId: string): PluginExtensionEventH appEvents.publish( new ShowModalReactEvent({ - component: wrapWithPluginContext(pluginId, getModalWrapper({ title, body, width, height })), + component: wrapWithPluginContext( + pluginId, + getModalWrapper({ title, body, width, height }), + log + ), }) ); }; @@ -69,7 +70,7 @@ type ModalWrapperProps = { onDismiss: () => void; }; -export const wrapWithPluginContext = (pluginId: string, Component: React.ComponentType) => { +export const wrapWithPluginContext = (pluginId: string, Component: React.ComponentType, log: ExtensionsLog) => { const WrappedExtensionComponent = (props: T & React.JSX.IntrinsicAttributes) => { const { error, @@ -82,12 +83,15 @@ export const wrapWithPluginContext = (pluginId: string, Component: React.Com } if (error) { - logWarning(`Could not fetch plugin meta information for "${pluginId}", aborting. (${error.message})`); + log.error(`Could not fetch plugin meta information for "${pluginId}", aborting. (${error.message})`, { + stack: error.stack ?? '', + message: error.message, + }); return null; } if (!pluginMeta) { - logWarning(`Fetched plugin meta information is empty for "${pluginId}", aborting.`); + log.error(`Fetched plugin meta information is empty for "${pluginId}", aborting.`); return null; } @@ -298,7 +302,12 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel return subMenu; } -export function getLinkExtensionOverrides(pluginId: string, config: AddedLinkRegistryItem, context?: object) { +export function getLinkExtensionOverrides( + pluginId: string, + config: AddedLinkRegistryItem, + log: ExtensionsLog, + context?: object +) { try { const overrides = config.configure?.(context, { isAppOpened: () => isAppOpened(pluginId) }); @@ -325,7 +334,7 @@ export function getLinkExtensionOverrides(pluginId: string, config: AddedLinkReg assertStringProps({ title, description }, ['title', 'description']); if (Object.keys(rest).length > 0) { - logWarning( + log.warning( `Extension "${config.title}", is trying to override restricted properties: ${Object.keys(rest).join( ', ' )} which will be ignored.` @@ -341,7 +350,10 @@ export function getLinkExtensionOverrides(pluginId: string, config: AddedLinkReg }; } catch (error) { if (error instanceof Error) { - logWarning(error.message); + log.error(`Failed to configure link with title "${config.title}"`, { + stack: error.stack ?? '', + message: error.message, + }); } // If there is an error, we hide the extension @@ -354,6 +366,7 @@ export function getLinkExtensionOnClick( pluginId: string, extensionPointId: string, config: AddedLinkRegistryItem, + log: ExtensionsLog, context?: object ): ((event?: React.MouseEvent) => void) | undefined { const { onClick } = config; @@ -379,18 +392,25 @@ export function getLinkExtensionOnClick( closeAppInSideview: () => closeAppInSideview(pluginId), }; + log.debug(`onClick '${config.title}' at '${extensionPointId}'`); const result = onClick(event, helpers); if (isPromise(result)) { - result.catch((e) => { - if (e instanceof Error) { - logWarning(e.message); + result.catch((error) => { + if (error instanceof Error) { + log.error(error.message, { + message: error.message, + stack: error.stack ?? '', + }); } }); } } catch (error) { if (error instanceof Error) { - logWarning(error.message); + log.error(error.message, { + message: error.message, + stack: error.stack ?? '', + }); } } }; @@ -417,12 +437,16 @@ export const isAppOpened = (pluginId: string) => sidecarService.isAppOpened(plug 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) => { +export const isExtensionPointMetaInfoMissing = ( + extensionPointId: string, + pluginContext: PluginContextType, + log: ExtensionsLog +) => { const pluginId = pluginContext.meta?.id; const extensionPoints = pluginContext.meta?.extensions?.extensionPoints; if (!extensionPoints || !extensionPoints.some((ep) => ep.id === extensionPointId)) { - logWarning( + log.warning( `Extension point "${extensionPointId}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "extensions.extensionPoints[]".` ); return true; @@ -432,12 +456,16 @@ export const isExtensionPointMetaInfoMissing = (extensionPointId: string, plugin }; // 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) => { +export const isExposedComponentDependencyMissing = ( + id: string, + pluginContext: PluginContextType, + log: ExtensionsLog +) => { const pluginId = pluginContext.meta?.id; const exposedComponentsDependencies = pluginContext.meta?.dependencies?.extensions?.exposedComponents; if (!exposedComponentsDependencies || !exposedComponentsDependencies.includes(id)) { - logWarning( + log.warning( `Using exposed component "${id}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "dependencies.extensions.exposedComponents[]".` ); return true; @@ -446,31 +474,35 @@ export const isExposedComponentDependencyMissing = (id: string, pluginContext: P return false; }; -export const isAddedLinkMetaInfoMissing = (pluginId: string, metaInfo: PluginExtensionAddedLinkConfig) => { +export const isAddedLinkMetaInfoMissing = ( + pluginId: string, + metaInfo: PluginExtensionAddedLinkConfig, + log: ExtensionsLog +) => { 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}"`); + log.warning(`${logPrefix} couldn't find app plugin "${pluginId}"`); return true; } if (!pluginJsonMetaInfo) { - logWarning(`${logPrefix} not registered in the plugin.json under "extensions.addedLinks[]".`); + log.warning(`${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[]".`); + log.warning(`${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedLinks[]".`); return true; } if (pluginJsonMetaInfo.description !== metaInfo.description) { - logWarning( + log.warning( `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.addedLinks[]".` ); @@ -480,25 +512,29 @@ export const isAddedLinkMetaInfoMissing = (pluginId: string, metaInfo: PluginExt return false; }; -export const isAddedComponentMetaInfoMissing = (pluginId: string, metaInfo: PluginExtensionAddedComponentConfig) => { +export const isAddedComponentMetaInfoMissing = ( + pluginId: string, + metaInfo: PluginExtensionAddedComponentConfig, + log: ExtensionsLog +) => { 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}"`); + log.warning(`${logPrefix} couldn't find app plugin "${pluginId}"`); return true; } if (!pluginJsonMetaInfo) { - logWarning(`${logPrefix} not registered in the plugin.json under "extensions.addedComponents[]".`); + log.warning(`${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( + log.warning( `${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedComponents[]".` ); @@ -506,7 +542,7 @@ export const isAddedComponentMetaInfoMissing = (pluginId: string, metaInfo: Plug } if (pluginJsonMetaInfo.description !== metaInfo.description) { - logWarning( + log.warning( `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.addedComponents[]".` ); @@ -518,25 +554,26 @@ export const isAddedComponentMetaInfoMissing = (pluginId: string, metaInfo: Plug export const isExposedComponentMetaInfoMissing = ( pluginId: string, - metaInfo: PluginExtensionExposedComponentConfig + metaInfo: PluginExtensionExposedComponentConfig, + log: ExtensionsLog ) => { 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}"`); + log.warning(`${logPrefix} couldn't find app plugin: "${pluginId}"`); return true; } if (!pluginJsonMetaInfo) { - logWarning(`${logPrefix} not registered in the plugin.json under "extensions.exposedComponents[]".`); + log.warning(`${logPrefix} not registered in the plugin.json under "extensions.exposedComponents[]".`); return true; } if (pluginJsonMetaInfo.title !== metaInfo.title) { - logWarning( + log.warning( `${logPrefix} the "title" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".` ); @@ -544,7 +581,7 @@ export const isExposedComponentMetaInfoMissing = ( } if (pluginJsonMetaInfo.description !== metaInfo.description) { - logWarning( + log.warning( `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".` ); diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index 7faa71465b7..36368f5b508 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -19,7 +19,6 @@ export const TestStuffPage = () => { - diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 405c76ee35f..e5661244077 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -23,6 +23,7 @@ import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamic import { RouteDescriptor } from '../core/navigation/types'; import { getPublicDashboardRoutes } from '../features/dashboard/routes'; +const isDevEnv = config.buildInfo.env === 'development'; export const extraRoutes: RouteDescriptor[] = []; export function getAppRoutes(): RouteDescriptor[] { @@ -198,6 +199,15 @@ export function getAppRoutes(): RouteDescriptor[] { path: '/admin/plugins', component: () => , }, + { + path: '/admin/extensions', + navId: 'extensions', + component: isDevEnv + ? SafeDynamicImport( + () => import(/* webpackChunkName: "PluginExtensionsLog" */ 'app/features/plugins/extensions/logs/LogViewer') + ) + : () => , + }, { path: '/admin/access', component: () => , diff --git a/public/test/jest-setup.ts b/public/test/jest-setup.ts index f47f43ecb60..17579226432 100644 --- a/public/test/jest-setup.ts +++ b/public/test/jest-setup.ts @@ -117,3 +117,12 @@ global.ResizeObserver = class ResizeObserver { disconnect() {} unobserve() {} }; + +global.BroadcastChannel = class BroadcastChannel { + onmessage() {} + onmessageerror() {} + postMessage(data: unknown) {} + close() {} + addEventListener() {} + removeEventListener() {} +}; diff --git a/yarn.lock b/yarn.lock index 5d25ed259d4..c25e0e29049 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4143,6 +4143,24 @@ __metadata: languageName: unknown linkType: soft +"@grafana/scenes-react@npm:5.19.1": + version: 5.19.1 + resolution: "@grafana/scenes-react@npm:5.19.1" + dependencies: + "@grafana/e2e-selectors": "npm:^11.0.0" + "@grafana/scenes": "npm:5.19.1" + react-use: "npm:17.4.0" + peerDependencies: + "@grafana/data": ^11.0.0 + "@grafana/runtime": ^11.0.0 + "@grafana/schema": ^11.0.0 + "@grafana/ui": ^11.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 10/a4efd256a02ba4d7418ca412e5f03439684ce531188606a762ce7252a5235f8a3dcf14fd51058b30b7c9c4510f5a7c931958f619811170684b97d9db9294dca1 + languageName: node + linkType: hard + "@grafana/scenes@npm:5.19.1": version: 5.19.1 resolution: "@grafana/scenes@npm:5.19.1" @@ -18949,6 +18967,7 @@ __metadata: "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" "@grafana/scenes": "npm:5.19.1" + "@grafana/scenes-react": "npm:5.19.1" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" @@ -24275,7 +24294,7 @@ __metadata: languageName: node linkType: hard -"nano-css@npm:^5.6.1, nano-css@npm:^5.6.2": +"nano-css@npm:^5.3.1, nano-css@npm:^5.6.1, nano-css@npm:^5.6.2": version: 5.6.2 resolution: "nano-css@npm:5.6.2" dependencies: @@ -28029,6 +28048,31 @@ __metadata: languageName: node linkType: hard +"react-use@npm:17.4.0": + version: 17.4.0 + resolution: "react-use@npm:17.4.0" + dependencies: + "@types/js-cookie": "npm:^2.2.6" + "@xobotyi/scrollbar-width": "npm:^1.9.5" + copy-to-clipboard: "npm:^3.3.1" + fast-deep-equal: "npm:^3.1.3" + fast-shallow-equal: "npm:^1.0.0" + js-cookie: "npm:^2.2.1" + nano-css: "npm:^5.3.1" + react-universal-interface: "npm:^0.6.2" + resize-observer-polyfill: "npm:^1.5.1" + screenfull: "npm:^5.1.0" + set-harmonic-interval: "npm:^1.0.1" + throttle-debounce: "npm:^3.0.1" + ts-easing: "npm:^0.2.0" + tslib: "npm:^2.1.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10/98566c4817b00251107824743ea9dff41f167b548bd5f249f6eb9e2ec09388a2de1e89988e4432cead3f8aa83cf706e0255db8a20c0615768c670751973d2761 + languageName: node + linkType: hard + "react-use@npm:17.5.0": version: 17.5.0 resolution: "react-use@npm:17.5.0"