PluginExtension: Added debug log (#94146)

* 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 <erik.sundell87@gmail.com>

* 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 <balogh.levente.hu@gmail.com>

* 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 <erik.sundell87@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Marcus Andersson
2024-10-10 09:27:57 +03:00
committed by GitHub
co-authored by Erik Sundell Levente Balogh Torkel Ödegaard
parent 97249d15d1
commit bc7386e815
33 changed files with 1643 additions and 263 deletions
+1
View File
@@ -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:*",
+12
View File
@@ -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",
@@ -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 [];
}
}
@@ -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 () => {
@@ -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);
@@ -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<string>;
extensionPointIds?: Set<string>;
severity?: Set<string>;
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<SelectableValue<string>>) => {
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<SelectableValue<string>>) => {
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<SelectableValue<string>>) => {
const update = {
...filter,
severity: mapToSet(values),
};
if (isEmpty(filter.pluginIds) && isEmpty(filter.extensionPointIds)) {
update.initial = isEmpty(values) ? undefined : 'severity';
}
onChange(update);
};
return (
<InlineFieldRow>
<InlineField label="Plugin Id">
<MultiSelect options={pluginIds} onChange={onChangePluginIds} />
</InlineField>
<InlineField label="Extension">
<MultiSelect options={extensionPointIds} onChange={onChangeExtensionPointIds} />
</InlineField>
<InlineField label="Severity">
<MultiSelect options={severity} onChange={onChangeSeverity} />
</InlineField>
</InlineFieldRow>
);
}
export type FilterConfig = {
fieldName: string;
config: MatcherConfig;
};
type LogFilterOptions = {
pluginIds: Array<SelectableValue<string>>;
extensionPointIds: Array<SelectableValue<string>>;
severity: Array<SelectableValue<string>>;
};
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<SelectableValue<string>>): Set<string> | undefined {
if (selected.length <= 0) {
return undefined;
}
return selected.reduce((set, selectable) => {
if (selectable.value) {
set.add(selectable.value);
}
return set;
}, new Set<string>());
}
function toSelectableArray(source: Set<string>): Array<SelectableValue<string>> {
return Array.from(source).reduce((all: Array<SelectableValue<string>>, 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<string>(severity.fields[severityIndex].values);
const pluginIds = new Set<string>(pluginId.fields[pluginIdIndex].values);
const extensionPointIds = new Set<string>(extensionPointId.fields[extensionPointIdIndex].values);
return {
severity: toSelectableArray(severities),
pluginIds: toSelectableArray(pluginIds),
extensionPointIds: toSelectableArray(extensionPointIds),
};
}
@@ -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 (
<SceneContextProvider>
<LogViewScene />
</SceneContextProvider>
);
}
function LogViewScene(): ReactElement | null {
const [filter, setFilter] = useState<LogFilter>({});
const data = useQueryRunner({
datasource: DATASOURCE_REF,
queries: [{ refId: 'A' }],
liveStreaming: true,
});
const filteredData = useDataTransformer({
transformations: [createFilterTransformation(filter)],
data: data,
});
return (
<Page
navId="extensions"
actions={<LogViewFilters provider={data} filteredProvider={filteredData} filter={filter} onChange={setFilter} />}
>
<VizGridLayout>
<VizPanel title="" viz={logsViz} dataProvider={filteredData} />
</VizGridLayout>
</Page>
);
}
@@ -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(),
};
}
@@ -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<DataQueryResponse> {
const [query] = request.targets;
return this.extensionsLog.asObservable().pipe(
scan<ExtensionsLogItem, DataQueryResponse>(
(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<TestDataSourceResponse> {
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],
},
],
});
}
@@ -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<string>(['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<string>(['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<string>(['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<string>(['grafana-k8s-app']),
severity: new Set<string>(['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<string>(['grafana-k8s-app', 'grafana']),
severity: new Set<string>(['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<string>(['grafana-k8s-app', 'grafana']),
severity: new Set<string>(['info']),
extensionPointIds: new Set<string>(['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<DataFrame[]> {
const transformation = createFilterTransformation(filter);
const operator = transformation({ interpolate: () => '' });
return lastValueFrom(
new Observable<DataFrame[]>((sub) => {
sub.next(frames);
sub.complete();
}).pipe(operator)
);
}
@@ -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<DataFrame[]>) {
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);
}
}
@@ -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<ExtensionsLogItem> | undefined;
private channel: BroadcastChannel;
constructor(baseLabels?: Labels, subject?: ReplaySubject<ExtensionsLogItem>, 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<ExtensionsLogItem> {
if (!this.subject) {
// Lazily create the subject on first subscription to prevent
// to create buffers when no subscribers exists
this.subject = new ReplaySubject<ExtensionsLogItem>(1000, 1000 * 60 * 10);
this.channel.onmessage = (msg: MessageEvent<ExtensionsLogItem>) => 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();
@@ -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();
}
@@ -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();
});
});
@@ -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 {
@@ -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();
});
});
@@ -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<AddedLinkRegistryItem[], Plugin
for (const config of configs) {
const { path, title, description, configure, onClick, targets } = config;
const configLog = this.logger.child({
path: path ?? '',
description,
title,
pluginId,
onClick: typeof onClick,
});
if (!title) {
logWarning(`Could not register added link with title '${title}'. Reason: Title is missing.`);
configLog.error(`Could not register added link. Reason: Title is missing.`);
continue;
}
if (!description) {
logWarning(`Could not register added link with title '${title}'. Reason: Description is missing.`);
configLog.error(`Could not register added link. Reason: Description is missing.`);
continue;
}
if (!isConfigureFnValid(configure)) {
logWarning(`Could not register added link with title '${title}'. Reason: configure is not a function.`);
configLog.error(`Could not register added link. Reason: configure is not a function.`);
continue;
}
if (!path && !onClick) {
logWarning(
`Could not register added link with title '${title}'. Reason: Either "path" or "onClick" is required.`
);
configLog.error(`Could not register added. Reason: Either "path" or "onClick" is required.`);
continue;
}
if (path && !isLinkPathValid(pluginId, path)) {
logWarning(
`Could not register added link with title '${title}'. Reason: The "path" is required and should start with "/a/${pluginId}/" (currently: "${path}"). Skipping the extension.`
configLog.error(
`Could not register added link. Reason: The "path" is required and should start with "/a/${pluginId}/".`
);
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedLinkMetaInfoMissing(pluginId, config)) {
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedLinkMetaInfoMissing(pluginId, config, configLog)) {
configLog.warning(`Did not register links from plugin ${pluginId} due to missing meta information.`);
continue;
}
const extensionPointIds = Array.isArray(targets) ? targets : [targets];
for (const extensionPointId of extensionPointIds) {
const pointIdLog = configLog.child({ extensionPointId });
if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) {
logWarning(
pointIdLog.warning(
`Added link "${config.title}: it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
);
}
@@ -90,6 +100,8 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
registry[extensionPointId] = [];
}
pointIdLog.debug(`Added link from '${pluginId}' to '${extensionPointId}'`);
registry[extensionPointId].push({ ...registryItem, pluginId, extensionPointId });
}
}
@@ -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 { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
@@ -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('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();
});
});
@@ -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 };
}
@@ -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<TRegistryValue, TMapType> {
private isReadOnly: boolean;
// This is the subject that receives extension configs for a loaded plugin.
private resultSubject: Subject<PluginExtensionConfigs<TMapType>>;
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<RegistryType<TRegistryValue>>;
@@ -26,8 +28,10 @@ export abstract class Registry<TRegistryValue, TMapType> {
constructor(options: {
registrySubject?: ReplaySubject<RegistryType<TRegistryValue>>;
initialState?: RegistryType<TRegistryValue>;
log?: ExtensionsLog;
}) {
this.resultSubject = new Subject<PluginExtensionConfigs<TMapType>>();
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<TRegistryValue, TMapType> {
this.registrySubject = new ReplaySubject<RegistryType<TRegistryValue>>(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))
@@ -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();
});
});
@@ -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<Props extends object = {}>(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<Props extends object = {}>(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]);
}
@@ -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();
});
});
@@ -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<Props extends object = {}>({
const components: Array<React.ComponentType<Props>> = [];
const extensionsByPlugin: Record<string, number> = {};
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<Props extends object = {}>({
};
}
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 {
@@ -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 {
@@ -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();
});
});
@@ -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,
@@ -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(<Component />);
@@ -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(<Component audience="folks" />);
@@ -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}"`);
});
});
});
@@ -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<ModalWrapperProps>(pluginId, getModalWrapper({ title, body, width, height })),
component: wrapWithPluginContext<ModalWrapperProps>(
pluginId,
getModalWrapper({ title, body, width, height }),
log
),
})
);
};
@@ -69,7 +70,7 @@ type ModalWrapperProps = {
onDismiss: () => void;
};
export const wrapWithPluginContext = <T,>(pluginId: string, Component: React.ComponentType<T>) => {
export const wrapWithPluginContext = <T,>(pluginId: string, Component: React.ComponentType<T>, log: ExtensionsLog) => {
const WrappedExtensionComponent = (props: T & React.JSX.IntrinsicAttributes) => {
const {
error,
@@ -82,12 +83,15 @@ export const wrapWithPluginContext = <T,>(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[]".`
);
@@ -19,7 +19,6 @@ export const TestStuffPage = () => {
<Page navModel={{ node: node, main: node }}>
<Stack>
<LinkToBasicApp extensionPointId="grafana/sandbox/testing" />
<Button onClick={() => notifyApp.success('Success toast', 'some more text goes here')} variant="primary">
Success
</Button>
+10
View File
@@ -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: () => <NavLandingPage navId="cfg/plugins" />,
},
{
path: '/admin/extensions',
navId: 'extensions',
component: isDevEnv
? SafeDynamicImport(
() => import(/* webpackChunkName: "PluginExtensionsLog" */ 'app/features/plugins/extensions/logs/LogViewer')
)
: () => <Navigate replace to="/admin" />,
},
{
path: '/admin/access',
component: () => <NavLandingPage navId="cfg/access" />,
+9
View File
@@ -117,3 +117,12 @@ global.ResizeObserver = class ResizeObserver {
disconnect() {}
unobserve() {}
};
global.BroadcastChannel = class BroadcastChannel {
onmessage() {}
onmessageerror() {}
postMessage(data: unknown) {}
close() {}
addEventListener() {}
removeEventListener() {}
};
+45 -1
View File
@@ -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"