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:
co-authored by
Erik Sundell
Levente Balogh
Torkel Ödegaard
parent
97249d15d1
commit
bc7386e815
@@ -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:*",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
+29
-19
@@ -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>
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
@@ -117,3 +117,12 @@ global.ResizeObserver = class ResizeObserver {
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
};
|
||||
|
||||
global.BroadcastChannel = class BroadcastChannel {
|
||||
onmessage() {}
|
||||
onmessageerror() {}
|
||||
postMessage(data: unknown) {}
|
||||
close() {}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user