Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
@@ -1443,16 +1443,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
|
||||
@@ -719,6 +719,7 @@ embed.go @grafana/grafana-as-code
|
||||
/.github/workflows/community-release.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/detect-breaking-changes-* @grafana/plugins-platform-frontend
|
||||
/.github/workflows/doc-validator.yml @grafana/docs-tooling
|
||||
/.github/workflows/deploy-pr-preview.yml @grafana/docs-tooling
|
||||
/.github/workflows/epic-add-to-platform-ux-parent-project.yml @meanmina
|
||||
/.github/workflows/github-release.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/issue-labeled.yml @armandgrillet
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Deploy pr preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- closed
|
||||
paths:
|
||||
- "docs/sources/**"
|
||||
|
||||
jobs:
|
||||
deploy-pr-preview:
|
||||
uses: grafana/writers-toolkit/.github/workflows/deploy-preview.yml@main
|
||||
with:
|
||||
sha: ${{ github.event.pull_request.head.sha }}
|
||||
branch: ${{ github.head_ref }}
|
||||
event_number: ${{ github.event.number }}
|
||||
title: ${{ github.event.pull_request.title }}
|
||||
repo: grafana
|
||||
website_directory: content/docs/grafana/latest
|
||||
relative_prefix: /docs/grafana/latest/
|
||||
@@ -19,6 +19,11 @@ First, make sure the Docker daemon is running on your machine. Then, follow thes
|
||||
|
||||
If you have the grafana/website repo checked out in the same directory as the grafana repo, then you can run `make docs-local-static` to use local assets (such as images).
|
||||
|
||||
## Deploy preview
|
||||
|
||||
When you open a PR that changes files in the `docs/sources/` directory, CI builds a deploy preview.
|
||||
After the deploy preview has been built, the **Deploy pr preview** workflow comments a link to the preview URL and adds a commit status check .
|
||||
|
||||
---
|
||||
|
||||
## Content guidelines
|
||||
|
||||
@@ -32,4 +32,4 @@ Note that this plugin extends the `@grafana/plugin-configs` configs which is why
|
||||
|
||||
## Run Playwright tests
|
||||
|
||||
- `yarn playwright --project extensions-test-app`
|
||||
- `yarn playwright test --project extensions-test-app`
|
||||
|
||||
+2
-2
@@ -272,8 +272,8 @@
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/saga-icons": "workspace:*",
|
||||
"@grafana/scenes": "5.26.0",
|
||||
"@grafana/scenes-react": "5.26.0",
|
||||
"@grafana/scenes": "5.28.1",
|
||||
"@grafana/scenes-react": "5.28.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { MultiCombobox } from './MultiCombobox';
|
||||
@@ -7,17 +9,34 @@ const meta: Meta<typeof MultiCombobox> = {
|
||||
component: MultiCombobox,
|
||||
};
|
||||
|
||||
const commonArgs = {
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
],
|
||||
value: ['option2'],
|
||||
placeholder: 'Select multiple options...',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof MultiCombobox>;
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
],
|
||||
placeholder: 'Select multiple options...',
|
||||
args: commonArgs,
|
||||
render: (args) => {
|
||||
const [{ value }, setArgs] = useArgs();
|
||||
|
||||
return (
|
||||
<MultiCombobox
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
action('onChange')(val);
|
||||
setArgs({ value: val });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent, { UserEvent } from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
|
||||
|
||||
describe('MultiCombobox', () => {
|
||||
let user: UserEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
});
|
||||
|
||||
it('should render with options', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
render(<MultiCombobox options={options} value={[]} onChange={jest.fn()} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
user.click(input);
|
||||
expect(await screen.findByText('A')).toBeInTheDocument();
|
||||
expect(await screen.findByText('B')).toBeInTheDocument();
|
||||
expect(await screen.findByText('C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with value', () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
render(<MultiCombobox options={options} value={['a']} onChange={jest.fn()} />);
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with placeholder', () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
render(<MultiCombobox options={options} value={[]} onChange={jest.fn()} placeholder="Select" />);
|
||||
expect(screen.getByPlaceholderText('Select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a', 'b', 'c'],
|
||||
[1, 2, 3],
|
||||
])('should call onChange with the correct values', async (first, second, third) => {
|
||||
const options = [
|
||||
{ label: 'A', value: first },
|
||||
{ label: 'B', value: second },
|
||||
{ label: 'C', value: third },
|
||||
];
|
||||
const onChange = jest.fn();
|
||||
|
||||
const ControlledMultiCombobox = (props: MultiComboboxProps<string | number>) => {
|
||||
const [value, setValue] = React.useState<string[] | number[]>([]);
|
||||
return (
|
||||
<MultiCombobox
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
//@ts-expect-error Don't do this for real life use cases
|
||||
setValue(val ?? []);
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<ControlledMultiCombobox options={options} value={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.click(await screen.findByRole('option', { name: 'A' }));
|
||||
|
||||
//Second option
|
||||
await user.click(screen.getByRole('option', { name: 'C' }));
|
||||
|
||||
//Deselect
|
||||
await user.click(screen.getByRole('option', { name: 'A' }));
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, [first]);
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, [first, third]);
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, [third]);
|
||||
});
|
||||
|
||||
it('should be able to render a valie that is not in the options', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
render(<MultiCombobox options={options} value={['a', 'd', 'c']} onChange={jest.fn()} />);
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
expect(await screen.findByText('d')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCombobox, useMultipleSelection } from 'downshift';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
@@ -11,22 +11,34 @@ import { ValuePill } from './ValuePill';
|
||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||
|
||||
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
||||
value?: string | Array<ComboboxOption<T>>;
|
||||
onChange: (items?: Array<ComboboxOption<T>>) => void;
|
||||
value?: T[] | Array<ComboboxOption<T>>;
|
||||
onChange: (items?: T[]) => void;
|
||||
}
|
||||
|
||||
type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
||||
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
||||
|
||||
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
|
||||
const { options, placeholder } = props;
|
||||
const { options, placeholder, onChange, value } = props;
|
||||
const isAsync = typeof options === 'function';
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
if (!value || isAsync) {
|
||||
//TODO handle async
|
||||
return [];
|
||||
}
|
||||
|
||||
return getSelectedItemsFromValue<T>(value, options);
|
||||
}, [value, options, isAsync]);
|
||||
|
||||
const multiStyles = useStyles2(getMultiComboboxStyles);
|
||||
|
||||
const isAsync = typeof options === 'function';
|
||||
|
||||
const [items, _baseSetItems] = useState(isAsync ? [] : options);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Array<ComboboxOption<T>>>([]);
|
||||
|
||||
const isOptionSelected = useCallback(
|
||||
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
|
||||
[selectedItems]
|
||||
);
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
@@ -39,9 +51,10 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||
if (newSelectedItems) {
|
||||
setSelectedItems(newSelectedItems);
|
||||
onChange(getComboboxOptionsValues(newSelectedItems));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -55,26 +68,37 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
//selectedItem,
|
||||
} = useCombobox({
|
||||
isOpen,
|
||||
items,
|
||||
itemToString,
|
||||
inputValue,
|
||||
//defaultHighlightedIndex: 0,
|
||||
selectedItem: null,
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes, type } = actionAndChanges;
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
return {
|
||||
...changes,
|
||||
isOpen: true,
|
||||
defaultHighlightedIndex: 0,
|
||||
};
|
||||
default:
|
||||
return changes;
|
||||
}
|
||||
},
|
||||
|
||||
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
|
||||
switch (type) {
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
if (newSelectedItem) {
|
||||
const isAlreadySelected = selectedItems.some((opt) => opt.value === newSelectedItem.value);
|
||||
if (!isAlreadySelected) {
|
||||
setSelectedItems([...selectedItems, newSelectedItem]);
|
||||
if (!isOptionSelected(newSelectedItem)) {
|
||||
onChange(getComboboxOptionsValues([...selectedItems, newSelectedItem]));
|
||||
break;
|
||||
}
|
||||
removeSelectedItem(newSelectedItem);
|
||||
removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here
|
||||
}
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputBlur:
|
||||
@@ -115,7 +139,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
<div>
|
||||
{items.map((item, index) => {
|
||||
const itemProps = getItemProps({ item, index });
|
||||
const isSelected = selectedItems.some((opt) => opt.value === item.value);
|
||||
const isSelected = isOptionSelected(item);
|
||||
const id = 'multicombobox-option-' + item.value.toString();
|
||||
return (
|
||||
<li
|
||||
key={item.value}
|
||||
@@ -124,8 +149,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
>
|
||||
{' '}
|
||||
{/* Add styling with virtualization */}
|
||||
<Checkbox key={`${item.value}${index}`} value={isSelected} />
|
||||
<OptionListItem option={item} />
|
||||
<Checkbox key={id} value={isSelected} aria-labelledby={id} />
|
||||
<OptionListItem option={item} id={id} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -136,3 +161,44 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getSelectedItemsFromValue<T extends string | number>(
|
||||
value: T[] | Array<ComboboxOption<T>>,
|
||||
options: Array<ComboboxOption<T>>
|
||||
) {
|
||||
if (!isComboboxOptions(value)) {
|
||||
const resultingItems: Array<ComboboxOption<T> | undefined> = [];
|
||||
|
||||
for (const item of options) {
|
||||
for (const [index, val] of value.entries()) {
|
||||
if (val === item.value) {
|
||||
resultingItems[index] = item;
|
||||
}
|
||||
}
|
||||
if (resultingItems.length === value.length && !resultingItems.includes(undefined)) {
|
||||
// We found all items for the values
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle values that are not in options
|
||||
for (const [index, val] of value.entries()) {
|
||||
if (resultingItems[index] === undefined) {
|
||||
resultingItems[index] = { value: val };
|
||||
}
|
||||
}
|
||||
return resultingItems.filter((item) => item !== undefined); // TODO: Not actually needed, but TS complains
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isComboboxOptions<T extends string | number>(
|
||||
value: T[] | Array<ComboboxOption<T>>
|
||||
): value is Array<ComboboxOption<T>> {
|
||||
return typeof value[0] === 'object';
|
||||
}
|
||||
|
||||
function getComboboxOptionsValues<T extends string | number>(optionArray: Array<ComboboxOption<T>>) {
|
||||
return optionArray.map((option) => option.value);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ import { getComboboxStyles } from './getComboboxStyles';
|
||||
|
||||
interface Props {
|
||||
option: ComboboxOption<string | number>;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const OptionListItem = ({ option }: Props) => {
|
||||
export const OptionListItem = ({ option, id }: Props) => {
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
return (
|
||||
<div className={styles.optionBody}>
|
||||
<span className={styles.optionLabel}>{option.label ?? option.value}</span>
|
||||
<span className={styles.optionLabel} id={id}>
|
||||
{option.label ?? option.value}
|
||||
</span>
|
||||
{option.description && <span className={styles.optionDescription}>{option.description}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ func NewFakePluginInstaller() *fakePluginInstaller {
|
||||
return &fakePluginInstaller{plugins: map[string]fakePlugin{}}
|
||||
}
|
||||
|
||||
func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string, _ plugins.CompatOpts) error {
|
||||
func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string, _ plugins.AddOpts) error {
|
||||
pm.plugins[pluginID] = fakePlugin{
|
||||
pluginID: pluginID,
|
||||
version: version,
|
||||
|
||||
+1
-1
@@ -467,7 +467,7 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons
|
||||
}
|
||||
}
|
||||
|
||||
compatOpts := plugins.NewCompatOpts(hs.Cfg.BuildVersion, runtime.GOOS, runtime.GOARCH)
|
||||
compatOpts := plugins.NewAddOpts(hs.Cfg.BuildVersion, runtime.GOOS, runtime.GOARCH, "")
|
||||
ctx := repo.WithRequestOrigin(c.Req.Context(), "api")
|
||||
err := hs.pluginInstaller.Add(ctx, pluginID, dto.Version, compatOpts)
|
||||
if err != nil {
|
||||
|
||||
+11
-9
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
type Installer interface {
|
||||
// Add adds a new plugin.
|
||||
Add(ctx context.Context, pluginID, version string, opts CompatOpts) error
|
||||
Add(ctx context.Context, pluginID, version string, opts AddOpts) error
|
||||
// Remove removes an existing plugin.
|
||||
Remove(ctx context.Context, pluginID, version string) error
|
||||
}
|
||||
@@ -33,31 +33,33 @@ type File struct {
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
type CompatOpts struct {
|
||||
type AddOpts struct {
|
||||
grafanaVersion string
|
||||
|
||||
os string
|
||||
arch string
|
||||
|
||||
url string
|
||||
}
|
||||
|
||||
func (co CompatOpts) GrafanaVersion() string {
|
||||
func (co AddOpts) GrafanaVersion() string {
|
||||
return co.grafanaVersion
|
||||
}
|
||||
|
||||
func (co CompatOpts) OS() string {
|
||||
func (co AddOpts) OS() string {
|
||||
return co.os
|
||||
}
|
||||
|
||||
func (co CompatOpts) Arch() string {
|
||||
func (co AddOpts) Arch() string {
|
||||
return co.arch
|
||||
}
|
||||
|
||||
func NewCompatOpts(grafanaVersion, os, arch string) CompatOpts {
|
||||
return CompatOpts{grafanaVersion: grafanaVersion, arch: arch, os: os}
|
||||
func (co AddOpts) URL() string {
|
||||
return co.url
|
||||
}
|
||||
|
||||
func NewSystemCompatOpts(os, arch string) CompatOpts {
|
||||
return CompatOpts{arch: arch, os: os}
|
||||
func NewAddOpts(grafanaVersion, os, arch, url string) AddOpts {
|
||||
return AddOpts{grafanaVersion: grafanaVersion, arch: arch, os: os, url: url}
|
||||
}
|
||||
|
||||
type UpdateInfo struct {
|
||||
|
||||
@@ -21,12 +21,12 @@ import (
|
||||
)
|
||||
|
||||
type FakePluginInstaller struct {
|
||||
AddFunc func(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error
|
||||
AddFunc func(ctx context.Context, pluginID, version string, opts plugins.AddOpts) error
|
||||
// Remove removes a plugin from the store.
|
||||
RemoveFunc func(ctx context.Context, pluginID, version string) error
|
||||
}
|
||||
|
||||
func (i *FakePluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error {
|
||||
func (i *FakePluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.AddOpts) error {
|
||||
if i.AddFunc != nil {
|
||||
return i.AddFunc(ctx, pluginID, version, opts)
|
||||
}
|
||||
|
||||
@@ -51,12 +51,7 @@ func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginRep
|
||||
}
|
||||
}
|
||||
|
||||
func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error {
|
||||
compatOpts, err := RepoCompatOpts(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.AddOpts) error {
|
||||
if ok, _ := m.installing.Load(pluginID); ok != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -65,7 +60,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
|
||||
m.installing.Delete(pluginID)
|
||||
}()
|
||||
|
||||
archive, err := m.install(ctx, pluginID, version, compatOpts)
|
||||
archive, err := m.install(ctx, pluginID, version, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,8 +88,12 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *PluginInstaller) install(ctx context.Context, pluginID, version string, compatOpts repo.CompatOpts) (*storage.ExtractedPluginArchive, error) {
|
||||
func (m *PluginInstaller) install(ctx context.Context, pluginID, version string, opts plugins.AddOpts) (*storage.ExtractedPluginArchive, error) {
|
||||
var pluginArchive *repo.PluginArchive
|
||||
compatOpts, err := RepoCompatOpts(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if plugin, exists := m.plugin(ctx, pluginID, version); exists {
|
||||
if plugin.IsCorePlugin() || plugin.IsBundledPlugin() {
|
||||
return nil, plugins.ErrInstallCorePlugin
|
||||
@@ -105,46 +104,21 @@ func (m *PluginInstaller) install(ctx context.Context, pluginID, version string,
|
||||
PluginID: plugin.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// get plugin update information to confirm if target update is possible
|
||||
pluginArchiveInfo, err := m.pluginRepo.GetPluginArchiveInfo(ctx, pluginID, version, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.log.Info("Updating plugin", "pluginId", pluginID, "from", plugin.Info.Version, "to", pluginArchiveInfo.Version)
|
||||
|
||||
// if existing plugin version is the same as the target update version
|
||||
if pluginArchiveInfo.Version == plugin.Info.Version {
|
||||
return nil, plugins.DuplicateError{
|
||||
PluginID: plugin.ID,
|
||||
}
|
||||
}
|
||||
|
||||
if pluginArchiveInfo.URL == "" && pluginArchiveInfo.Version == "" {
|
||||
return nil, fmt.Errorf("could not determine update options for %s", pluginID)
|
||||
}
|
||||
|
||||
// remove existing installation of plugin
|
||||
err = m.Remove(ctx, plugin.ID, plugin.Info.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pluginArchiveInfo.URL != "" {
|
||||
pluginArchive, err = m.pluginRepo.GetPluginArchiveByURL(ctx, pluginArchiveInfo.URL, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.URL() != "" {
|
||||
pluginArchive, err = m.updateFromURL(ctx, plugin, opts.URL(), compatOpts)
|
||||
} else {
|
||||
pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, pluginArchiveInfo.Version, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pluginArchive, err = m.updateFromCatalog(ctx, plugin, version, compatOpts)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, version, compatOpts)
|
||||
if opts.URL() != "" {
|
||||
pluginArchive, err = m.pluginRepo.GetPluginArchiveByURL(ctx, opts.URL(), compatOpts)
|
||||
} else {
|
||||
pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, version, compatOpts)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,9 +130,64 @@ func (m *PluginInstaller) install(ctx context.Context, pluginID, version string,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check that the extracted plugin archive has the expected ID and version
|
||||
// but avoid a hard error for backwards compatibility with older plugins
|
||||
// and because in the case of an update, the previous version has been already uninstalled
|
||||
if extractedArchive.ID != pluginID {
|
||||
m.log.Error("Installed plugin ID mismatch", "expected", pluginID, "got", extractedArchive.ID)
|
||||
}
|
||||
if version != "" && extractedArchive.Version != version {
|
||||
m.log.Error("Installed plugin version mismatch", "expected", version, "got", extractedArchive.Version)
|
||||
}
|
||||
|
||||
return extractedArchive, nil
|
||||
}
|
||||
|
||||
func (m *PluginInstaller) updateFromURL(ctx context.Context, plugin *plugins.Plugin, url string, compatOpts repo.CompatOpts) (*repo.PluginArchive, error) {
|
||||
m.log.Info("Updating plugin", "pluginId", plugin.ID, "from", plugin.Info.Version, "url", url)
|
||||
|
||||
// remove existing installation of plugin
|
||||
err := m.Remove(ctx, plugin.ID, plugin.Info.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.pluginRepo.GetPluginArchiveByURL(ctx, url, compatOpts)
|
||||
}
|
||||
|
||||
func (m *PluginInstaller) updateFromCatalog(ctx context.Context, plugin *plugins.Plugin, version string, compatOpts repo.CompatOpts) (*repo.PluginArchive, error) {
|
||||
// get plugin update information to confirm if target update is possible
|
||||
pluginArchiveInfo, err := m.pluginRepo.GetPluginArchiveInfo(ctx, plugin.ID, version, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.log.Info("Updating plugin", "pluginId", plugin.ID, "from", plugin.Info.Version, "to", pluginArchiveInfo.Version)
|
||||
|
||||
// if existing plugin version is the same as the target update version
|
||||
if pluginArchiveInfo.Version == plugin.Info.Version {
|
||||
return nil, plugins.DuplicateError{
|
||||
PluginID: plugin.ID,
|
||||
}
|
||||
}
|
||||
|
||||
if pluginArchiveInfo.URL == "" && pluginArchiveInfo.Version == "" {
|
||||
return nil, fmt.Errorf("could not determine update options for %s", plugin.ID)
|
||||
}
|
||||
|
||||
// remove existing installation of plugin
|
||||
err = m.Remove(ctx, plugin.ID, plugin.Info.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pluginArchiveInfo.URL != "" {
|
||||
return m.pluginRepo.GetPluginArchiveByURL(ctx, pluginArchiveInfo.URL, compatOpts)
|
||||
} else {
|
||||
return m.pluginRepo.GetPluginArchive(ctx, plugin.ID, pluginArchiveInfo.Version, compatOpts)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *PluginInstaller) Remove(ctx context.Context, pluginID, version string) error {
|
||||
plugin, exists := m.plugin(ctx, pluginID, version)
|
||||
if !exists {
|
||||
@@ -197,7 +226,7 @@ func (m *PluginInstaller) plugin(ctx context.Context, pluginID, pluginVersion st
|
||||
return p, true
|
||||
}
|
||||
|
||||
func RepoCompatOpts(opts plugins.CompatOpts) (repo.CompatOpts, error) {
|
||||
func RepoCompatOpts(opts plugins.AddOpts) (repo.CompatOpts, error) {
|
||||
os := opts.OS()
|
||||
arch := opts.Arch()
|
||||
if len(os) == 0 || len(arch) == 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package manager
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"testing"
|
||||
@@ -62,7 +63,9 @@ func TestPluginManager_Add_Remove(t *testing.T) {
|
||||
require.Equal(t, pluginID, id)
|
||||
require.Equal(t, mockZipV1, z)
|
||||
return &storage.ExtractedPluginArchive{
|
||||
Path: zipNameV1,
|
||||
ID: pluginID,
|
||||
Version: v1,
|
||||
Path: zipNameV1,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
@@ -84,6 +87,22 @@ func TestPluginManager_Add_Remove(t *testing.T) {
|
||||
}, err)
|
||||
})
|
||||
|
||||
t.Run("Add from URL", func(t *testing.T) {
|
||||
url := "https://grafanaplugins.com"
|
||||
pluginRepo := &fakes.FakePluginRepo{
|
||||
GetPluginArchiveByURLFunc: func(_ context.Context, archiveURL string, _ repo.CompatOpts) (*repo.PluginArchive, error) {
|
||||
require.Equal(t, pluginID, pluginID)
|
||||
require.Equal(t, url, archiveURL)
|
||||
return &repo.PluginArchive{
|
||||
File: mockZipV1,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
inst := New(fakes.NewFakePluginRegistry(), loader, pluginRepo, fs, storage.SimpleDirNameGeneratorFunc, &fakes.FakeAuthService{})
|
||||
err := inst.Add(context.Background(), pluginID, v1, plugins.NewAddOpts(v1, runtime.GOOS, runtime.GOARCH, url))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Update plugin to different version", func(t *testing.T) {
|
||||
// mock a plugin to be returned automatically by the plugin loader
|
||||
pluginV2 := createPlugin(t, pluginID, plugins.ClassExternal, true, true, func(plugin *plugins.Plugin) {
|
||||
@@ -113,7 +132,9 @@ func TestPluginManager_Add_Remove(t *testing.T) {
|
||||
require.Equal(t, pluginV1.ID, pluginID)
|
||||
require.Equal(t, mockZipV2, z)
|
||||
return &storage.ExtractedPluginArchive{
|
||||
Path: zipNameV2,
|
||||
ID: pluginID,
|
||||
Version: v2,
|
||||
Path: zipNameV2,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -121,6 +142,47 @@ func TestPluginManager_Add_Remove(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Update plugin from url", func(t *testing.T) {
|
||||
url := "https://grafanaplugins.com"
|
||||
// mock a plugin to be returned automatically by the plugin loader
|
||||
pluginV2 := createPlugin(t, pluginID, plugins.ClassExternal, true, true, func(plugin *plugins.Plugin) {
|
||||
plugin.Info.Version = v2
|
||||
})
|
||||
|
||||
mockZipV2 := &zip.ReadCloser{Reader: zip.Reader{File: []*zip.File{{
|
||||
FileHeader: zip.FileHeader{Name: zipNameV2},
|
||||
}}}}
|
||||
loader.LoadFunc = func(ctx context.Context, src plugins.PluginSource) ([]*plugins.Plugin, error) {
|
||||
require.Equal(t, plugins.ClassExternal, src.PluginClass(ctx))
|
||||
require.Equal(t, []string{zipNameV2}, src.PluginURIs(ctx))
|
||||
return []*plugins.Plugin{pluginV2}, nil
|
||||
}
|
||||
pluginRepo.GetPluginArchiveInfoFunc = func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginArchiveInfo, error) {
|
||||
return nil, errors.New("shouldn't be called")
|
||||
}
|
||||
getPluginArchiveByURLCalled := false
|
||||
pluginRepo.GetPluginArchiveByURLFunc = func(_ context.Context, pluginZipURL string, _ repo.CompatOpts) (*repo.PluginArchive, error) {
|
||||
require.Equal(t, url, pluginZipURL)
|
||||
getPluginArchiveByURLCalled = true
|
||||
return &repo.PluginArchive{
|
||||
File: mockZipV2,
|
||||
}, nil
|
||||
}
|
||||
fs.ExtractFunc = func(_ context.Context, pluginID string, _ storage.DirNameGeneratorFunc, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
|
||||
require.Equal(t, pluginV1.ID, pluginID)
|
||||
require.Equal(t, mockZipV2, z)
|
||||
return &storage.ExtractedPluginArchive{
|
||||
ID: pluginID,
|
||||
Version: v2,
|
||||
Path: zipNameV2,
|
||||
}, nil
|
||||
}
|
||||
|
||||
err = inst.Add(context.Background(), pluginID, v2, plugins.NewAddOpts(v2, runtime.GOOS, runtime.GOARCH, url))
|
||||
require.NoError(t, err)
|
||||
require.True(t, getPluginArchiveByURLCalled)
|
||||
})
|
||||
|
||||
t.Run("Removing an existing plugin", func(t *testing.T) {
|
||||
inst.pluginRegistry = &fakes.FakePluginRegistry{
|
||||
Store: map[string]*plugins.Plugin{
|
||||
@@ -210,14 +272,19 @@ func TestPluginManager_Add_Remove(t *testing.T) {
|
||||
ExtractFunc: func(_ context.Context, id string, _ storage.DirNameGeneratorFunc, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
|
||||
switch id {
|
||||
case p1:
|
||||
return &storage.ExtractedPluginArchive{Path: p1Zip}, nil
|
||||
return &storage.ExtractedPluginArchive{
|
||||
ID: p1,
|
||||
Path: p1Zip,
|
||||
}, nil
|
||||
case p2:
|
||||
return &storage.ExtractedPluginArchive{
|
||||
ID: p2,
|
||||
Dependencies: []*storage.Dependency{{ID: p1}},
|
||||
Path: p2Zip,
|
||||
}, nil
|
||||
case p3:
|
||||
return &storage.ExtractedPluginArchive{
|
||||
ID: p3,
|
||||
Dependencies: []*storage.Dependency{{ID: p2}},
|
||||
Path: p3Zip,
|
||||
}, nil
|
||||
@@ -260,11 +327,13 @@ func TestPluginManager_Add_Remove(t *testing.T) {
|
||||
switch id {
|
||||
case p1:
|
||||
return &storage.ExtractedPluginArchive{
|
||||
ID: p1,
|
||||
Dependencies: []*storage.Dependency{{ID: p2}},
|
||||
Path: p1Zip,
|
||||
}, nil
|
||||
case p2:
|
||||
return &storage.ExtractedPluginArchive{
|
||||
ID: p2,
|
||||
Dependencies: []*storage.Dependency{{ID: p1}},
|
||||
Path: p2Zip,
|
||||
}, nil
|
||||
@@ -309,6 +378,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
|
||||
switch id {
|
||||
case testPluginID:
|
||||
return &storage.ExtractedPluginArchive{
|
||||
ID: testPluginID,
|
||||
Dependencies: []*storage.Dependency{{ID: pluginDependencyID}},
|
||||
Path: "test-plugin.zip",
|
||||
}, nil
|
||||
@@ -352,6 +422,6 @@ func createPlugin(t *testing.T, pluginID string, class plugins.Class, managed, b
|
||||
return p
|
||||
}
|
||||
|
||||
func testCompatOpts() plugins.CompatOpts {
|
||||
return plugins.NewCompatOpts("10.0.0", runtime.GOOS, runtime.GOARCH)
|
||||
func testCompatOpts() plugins.AddOpts {
|
||||
return plugins.NewAddOpts("10.0.0", runtime.GOOS, runtime.GOARCH, "")
|
||||
}
|
||||
|
||||
@@ -135,8 +135,6 @@ func (s *Service) shouldUpdate(ctx context.Context, pluginID, currentVersion str
|
||||
}
|
||||
|
||||
func (s *Service) installPlugins(ctx context.Context) error {
|
||||
compatOpts := plugins.NewCompatOpts(s.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
for _, installPlugin := range s.cfg.PreinstallPlugins {
|
||||
// Check if the plugin is already installed
|
||||
p, exists := s.pluginStore.Plugin(ctx, installPlugin.ID)
|
||||
@@ -162,6 +160,7 @@ func (s *Service) installPlugins(ctx context.Context) error {
|
||||
s.log.Info("Installing plugin", "pluginId", installPlugin.ID, "version", installPlugin.Version)
|
||||
start := time.Now()
|
||||
ctx = repo.WithRequestOrigin(ctx, "preinstall")
|
||||
compatOpts := plugins.NewAddOpts(s.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH, installPlugin.URL)
|
||||
err := s.pluginInstaller.Add(ctx, installPlugin.ID, installPlugin.Version, compatOpts)
|
||||
if err != nil {
|
||||
var dupeErr plugins.DuplicateError
|
||||
|
||||
@@ -115,6 +115,11 @@ func TestService_Run(t *testing.T) {
|
||||
existingPlugins: []*plugins.Plugin{{JSONData: plugins.JSONData{ID: "myplugin", Info: plugins.Info{Version: "1.0.0"}}}},
|
||||
latestPlugin: &repo.PluginArchiveInfo{Version: "2.0.0"},
|
||||
},
|
||||
{
|
||||
name: "Should install a plugin with a URL",
|
||||
shouldInstall: true,
|
||||
pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin", URL: "https://example.com/myplugin.tar.gz"}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -124,6 +129,7 @@ func TestService_Run(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
installed := 0
|
||||
installedFromURL := 0
|
||||
s, err := ProvideService(
|
||||
&setting.Cfg{
|
||||
PreinstallPlugins: tt.pluginsToInstall,
|
||||
@@ -131,7 +137,7 @@ func TestService_Run(t *testing.T) {
|
||||
},
|
||||
pluginstore.New(preg, &fakes.FakeLoader{}),
|
||||
&fakes.FakePluginInstaller{
|
||||
AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error {
|
||||
AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.AddOpts) error {
|
||||
for _, plugin := range tt.pluginsToFail {
|
||||
if plugin == pluginID {
|
||||
return errors.New("Failed to install plugin")
|
||||
@@ -143,7 +149,11 @@ func TestService_Run(t *testing.T) {
|
||||
}
|
||||
for _, plugin := range tt.pluginsToInstall {
|
||||
if plugin.ID == pluginID && plugin.Version == version {
|
||||
installed++
|
||||
if opts.URL() != "" {
|
||||
installedFromURL++
|
||||
} else {
|
||||
installed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -168,7 +178,27 @@ func TestService_Run(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if tt.shouldInstall {
|
||||
require.Equal(t, len(tt.pluginsToInstall)-len(tt.pluginsToFail), installed)
|
||||
expectedInstalled := 0
|
||||
expectedInstalledFromURL := 0
|
||||
for _, plugin := range tt.pluginsToInstall {
|
||||
expectedFailed := false
|
||||
for _, pluginFail := range tt.pluginsToFail {
|
||||
if plugin.ID == pluginFail {
|
||||
expectedFailed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if expectedFailed {
|
||||
continue
|
||||
}
|
||||
if plugin.URL != "" {
|
||||
expectedInstalledFromURL++
|
||||
} else {
|
||||
expectedInstalled++
|
||||
}
|
||||
}
|
||||
require.Equal(t, expectedInstalled, installed)
|
||||
require.Equal(t, expectedInstalledFromURL, installedFromURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -543,6 +543,7 @@ type UnifiedStorageConfig struct {
|
||||
type InstallPlugin struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// AddChangePasswordLink returns if login form is disabled or not since
|
||||
|
||||
@@ -29,7 +29,7 @@ func extractPluginSettings(sections []*ini.Section) PluginSettings {
|
||||
var (
|
||||
defaultPreinstallPlugins = map[string]InstallPlugin{
|
||||
// Default preinstalled plugins
|
||||
"grafana-lokiexplore-app": {"grafana-lokiexplore-app", ""},
|
||||
"grafana-lokiexplore-app": {"grafana-lokiexplore-app", "", ""},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -58,11 +58,16 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error {
|
||||
for _, plugin := range rawInstallPlugins {
|
||||
parts := strings.Split(plugin, "@")
|
||||
id := parts[0]
|
||||
v := ""
|
||||
if len(parts) == 2 {
|
||||
v = parts[1]
|
||||
version := ""
|
||||
url := ""
|
||||
if len(parts) > 1 {
|
||||
version = parts[1]
|
||||
if len(parts) > 2 {
|
||||
url = parts[2]
|
||||
}
|
||||
}
|
||||
preinstallPlugins[id] = InstallPlugin{id, v}
|
||||
|
||||
preinstallPlugins[id] = InstallPlugin{id, version, url}
|
||||
}
|
||||
// Remove from the list the plugins that have been disabled
|
||||
for _, disabledPlugin := range cfg.DisablePlugins {
|
||||
|
||||
@@ -120,12 +120,12 @@ func Test_readPluginSettings(t *testing.T) {
|
||||
{
|
||||
name: "should add the default preinstalled plugin and the one defined",
|
||||
rawInput: "plugin1",
|
||||
expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", ""}),
|
||||
expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "", ""}),
|
||||
},
|
||||
{
|
||||
name: "should add the default preinstalled plugin and the one defined with version",
|
||||
rawInput: "plugin1@1.0.0",
|
||||
expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "1.0.0"}),
|
||||
expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "1.0.0", ""}),
|
||||
},
|
||||
{
|
||||
name: "it should remove the disabled plugin",
|
||||
@@ -149,7 +149,17 @@ func Test_readPluginSettings(t *testing.T) {
|
||||
name: "should mark preinstall as sync",
|
||||
rawInput: "plugin1",
|
||||
disableAsync: true,
|
||||
expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", ""}),
|
||||
expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "", ""}),
|
||||
},
|
||||
{
|
||||
name: "should parse a plugin with version and URL",
|
||||
rawInput: "plugin1@1.0.1@https://example.com/plugin1.tar.gz",
|
||||
expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "1.0.1", "https://example.com/plugin1.tar.gz"}),
|
||||
},
|
||||
{
|
||||
name: "should parse a plugin with URL",
|
||||
rawInput: "plugin1@@https://example.com/plugin1.tar.gz",
|
||||
expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "", "https://example.com/plugin1.tar.gz"}),
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
|
||||
+1
-10
@@ -10,19 +10,10 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
// we adjust the dataframes to be the way frontend & alerting
|
||||
// wants them.
|
||||
func adjustFrame(frame *data.Frame, query *lokiQuery, setMetricFrameName bool, logsDataplane bool) error {
|
||||
fields := frame.Fields
|
||||
|
||||
if len(fields) < 2 {
|
||||
return fmt.Errorf("missing fields in frame")
|
||||
}
|
||||
|
||||
// metric-fields have "timefield, valuefield"
|
||||
// logs-fields have "labelsfield, timefield, ..."
|
||||
|
||||
secondField := fields[1]
|
||||
secondField := frame.Fields[1]
|
||||
|
||||
if secondField.Type() == data.FieldTypeFloat64 {
|
||||
return adjustMetricFrame(frame, query, setMetricFrameName)
|
||||
|
||||
@@ -54,6 +54,8 @@ func TestSuccessResponse(t *testing.T) {
|
||||
|
||||
{name: "parse structured metadata", filepath: "streams_structured_metadata", query: streamsQuery},
|
||||
{name: "parse structured metadata different labels each log line", filepath: "streams_structured_metadata_2", query: streamsQuery},
|
||||
|
||||
{name: "parse warnings", filepath: "warning", query: streamsQuery},
|
||||
}
|
||||
|
||||
runTest := func(folder string, path string, query lokiQuery, responseOpts ResponseOpts) {
|
||||
|
||||
@@ -289,8 +289,12 @@ func runQuery(ctx context.Context, api *LokiAPI, query *lokiQuery, responseOpts
|
||||
}
|
||||
|
||||
for _, frame := range res.Frames {
|
||||
err = adjustFrame(frame, query, false, responseOpts.logsDataplane)
|
||||
// Skip frames without fields
|
||||
if len(frame.Fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
err = adjustFrame(frame, query, false, responseOpts.logsDataplane)
|
||||
if err != nil {
|
||||
plog.Error("Error adjusting frame", "error", err)
|
||||
return res, err
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
//
|
||||
// Frame[0] {
|
||||
// "typeVersion": [
|
||||
// 0,
|
||||
// 0
|
||||
// ],
|
||||
// "notices": [
|
||||
// {
|
||||
// "severity": "warning",
|
||||
// "text": "Some logs may have been dropped by Adaptive Logs sampling"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// Name: Warnings
|
||||
// Dimensions: 0 Fields by 0 Rows
|
||||
// +
|
||||
// +
|
||||
//
|
||||
//
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
{
|
||||
"status": 200,
|
||||
"frames": [
|
||||
{
|
||||
"schema": {
|
||||
"name": "Warnings",
|
||||
"meta": {
|
||||
"typeVersion": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"notices": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"text": "Some logs may have been dropped by Adaptive Logs sampling"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fields": []
|
||||
},
|
||||
"data": {
|
||||
"values": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "success",
|
||||
"warnings": ["Some logs may have been dropped by Adaptive Logs sampling"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
//
|
||||
// Frame[0] {
|
||||
// "typeVersion": [
|
||||
// 0,
|
||||
// 0
|
||||
// ],
|
||||
// "notices": [
|
||||
// {
|
||||
// "severity": "warning",
|
||||
// "text": "Some logs may have been dropped by Adaptive Logs sampling"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// Name: Warnings
|
||||
// Dimensions: 0 Fields by 0 Rows
|
||||
// +
|
||||
// +
|
||||
//
|
||||
//
|
||||
// 🌟 This was machine generated. Do not edit. 🌟
|
||||
{
|
||||
"status": 200,
|
||||
"frames": [
|
||||
{
|
||||
"schema": {
|
||||
"name": "Warnings",
|
||||
"meta": {
|
||||
"typeVersion": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"notices": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"text": "Some logs may have been dropped by Adaptive Logs sampling"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fields": []
|
||||
},
|
||||
"data": {
|
||||
"values": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "success",
|
||||
"warnings": ["Some logs may have been dropped by Adaptive Logs sampling"]
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { RouteDescriptor } from './core/navigation/types';
|
||||
import { ThemeProvider } from './core/utils/ConfigProvider';
|
||||
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
|
||||
import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext';
|
||||
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
|
||||
import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper';
|
||||
|
||||
interface AppWrapperProps {
|
||||
@@ -104,7 +105,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
|
||||
<GlobalStyles />
|
||||
<MaybeTimeRangeProvider>
|
||||
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
|
||||
<ExtensionRegistriesProvider registries={app.pluginExtensionsRegistries}>
|
||||
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
|
||||
<div className="grafana-app">
|
||||
{config.featureToggles.appSidecar ? (
|
||||
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
|
||||
|
||||
+8
-15
@@ -85,12 +85,12 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi
|
||||
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
||||
import { DatasourceSrv } from './features/plugins/datasource_srv';
|
||||
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
|
||||
import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
|
||||
import { PluginExtensionRegistries } from './features/plugins/extensions/registry/types';
|
||||
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
|
||||
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
|
||||
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
|
||||
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
|
||||
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
|
||||
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
|
||||
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
|
||||
import { preloadPlugins } from './features/plugins/pluginPreloader';
|
||||
import { QueryRunner } from './features/query/state/QueryRunner';
|
||||
@@ -127,7 +127,6 @@ if (process.env.NODE_ENV === 'development') {
|
||||
|
||||
export class GrafanaApp {
|
||||
context!: GrafanaContextType;
|
||||
pluginExtensionsRegistries!: PluginExtensionRegistries;
|
||||
|
||||
async init() {
|
||||
try {
|
||||
@@ -217,22 +216,16 @@ export class GrafanaApp {
|
||||
setDataSourceSrv(dataSourceSrv);
|
||||
initWindowRuntime();
|
||||
|
||||
// Initialize plugin extensions
|
||||
this.pluginExtensionsRegistries = setupPluginExtensionRegistries();
|
||||
|
||||
if (contextSrv.user.orgRole !== '') {
|
||||
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
|
||||
// TODO: remove the following exception once the issue mentioned above is fixed.
|
||||
const awaitedAppPluginIds = ['cloud-home-app'];
|
||||
const awaitedAppPlugins = Object.values(config.apps).filter((app) => awaitedAppPluginIds.includes(app.id));
|
||||
const appPlugins = Object.values(config.apps).filter((app) => !awaitedAppPluginIds.includes(app.id));
|
||||
const appPluginsToAwait = getAppPluginsToAwait();
|
||||
const appPluginsToPreload = getAppPluginsToPreload();
|
||||
|
||||
preloadPlugins(appPlugins, this.pluginExtensionsRegistries);
|
||||
await preloadPlugins(awaitedAppPlugins, this.pluginExtensionsRegistries, 'frontend_awaited_plugins_preload');
|
||||
preloadPlugins(appPluginsToPreload);
|
||||
await preloadPlugins(appPluginsToAwait);
|
||||
}
|
||||
|
||||
setPluginExtensionGetter(createPluginExtensionsGetter(this.pluginExtensionsRegistries));
|
||||
setPluginExtensionsHook(createUsePluginExtensions(this.pluginExtensionsRegistries));
|
||||
setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionRegistries));
|
||||
setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionRegistries));
|
||||
setPluginLinksHook(usePluginLinks);
|
||||
setPluginComponentHook(usePluginComponent);
|
||||
setPluginComponentsHook(usePluginComponents);
|
||||
|
||||
@@ -240,7 +240,7 @@ describe.each([
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, {
|
||||
alertmanager_config: {
|
||||
route: {},
|
||||
receivers: [{ name: 'grafana-default-email' }],
|
||||
receivers: [{ name: 'lotsa-emails' }],
|
||||
},
|
||||
template_files: {},
|
||||
});
|
||||
@@ -294,6 +294,7 @@ describe.each([
|
||||
});
|
||||
|
||||
it('allows user to reload and update policies if its been changed by another user', async () => {
|
||||
jest.retryTimes(2);
|
||||
const { user } = renderNotificationPolicies();
|
||||
|
||||
await getRootRoute();
|
||||
@@ -317,7 +318,8 @@ describe.each([
|
||||
await openDefaultPolicyEditModal();
|
||||
await user.click(await screen.findByRole('button', { name: /update default policy/i }));
|
||||
expect(await screen.findByText(/updated notification policies/i)).toBeInTheDocument();
|
||||
});
|
||||
// TODO: Check if test flakiness/length can be improved
|
||||
}, 60000);
|
||||
|
||||
it('Should be able to delete an empty route', async () => {
|
||||
const defaultConfig: AlertManagerCortexConfig = {
|
||||
|
||||
+70
-77
@@ -1,11 +1,13 @@
|
||||
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
|
||||
import { render, screen, waitFor, within } from 'test/test-utils';
|
||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
|
||||
import { setAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers';
|
||||
import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
|
||||
import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||
import { mockApi, setupMswServer } from '../../../mockApi';
|
||||
import { getMockConfig, setupMswServer } from '../../../mockApi';
|
||||
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
|
||||
import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi';
|
||||
import { Folder } from '../../../types/rule-form';
|
||||
@@ -36,7 +38,7 @@ const ui = {
|
||||
route: byTestId('matching-policy-route'),
|
||||
routeButton: byRole('button', { name: /Expand policy route/ }),
|
||||
routeMatchingInstances: byTestId('route-matching-instance'),
|
||||
loadingIndicator: byText(/Loading/),
|
||||
loadingIndicator: byText(/Loading routing preview/i),
|
||||
previewButton: byRole('button', { name: /preview routing/i }),
|
||||
grafanaAlertManagerLabel: byText(/alertmanager:grafana/i),
|
||||
otherAlertManagerLabel: byText(/alertmanager:other_am/i),
|
||||
@@ -62,52 +64,33 @@ const grafanaAlertManagerDataSource: AlertManagerDataSource = {
|
||||
hasConfigurationAPI: true,
|
||||
};
|
||||
|
||||
const mockConfig = getMockConfig((amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
routeBuilder
|
||||
.withReceiver('email')
|
||||
.addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
|
||||
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
|
||||
)
|
||||
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
|
||||
.addReceivers((b) => b.withName('slack'))
|
||||
.addReceivers((b) => b.withName('opsgenie'))
|
||||
);
|
||||
|
||||
function mockOneAlertManager() {
|
||||
getAlertManagerDataSourcesByPermissionAndConfigMock.mockReturnValue([grafanaAlertManagerDataSource]);
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
routeBuilder
|
||||
.withReceiver('email')
|
||||
.addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
|
||||
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
|
||||
)
|
||||
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
|
||||
.addReceivers((b) => b.withName('slack'))
|
||||
.addReceivers((b) => b.withName('opsgenie'))
|
||||
);
|
||||
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
}
|
||||
|
||||
function mockTwoAlertManagers() {
|
||||
getAlertManagerDataSourcesByPermissionAndConfigMock.mockReturnValue([
|
||||
{ name: 'OTHER_AM', imgUrl: '', hasConfigurationAPI: true },
|
||||
grafanaAlertManagerDataSource,
|
||||
{ name: 'OTHER_AM', imgUrl: '', hasConfigurationAPI: true },
|
||||
]);
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
routeBuilder
|
||||
.withReceiver('email')
|
||||
.addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
|
||||
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
|
||||
)
|
||||
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
|
||||
.addReceivers((b) => b.withName('slack'))
|
||||
.addReceivers((b) => b.withName('opsgenie'))
|
||||
);
|
||||
mockApi(server).getAlertmanagerConfig('OTHER_AM', (amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
routeBuilder
|
||||
.withReceiver('email')
|
||||
.addRoute((rb) => rb.withReceiver('slack').addMatcher('tomato', MatcherOperator.equal, 'red'))
|
||||
.addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations'))
|
||||
)
|
||||
.addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com')))
|
||||
.addReceivers((b) => b.withName('slack'))
|
||||
.addReceivers((b) => b.withName('opsgenie'))
|
||||
);
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
setAlertmanagerConfig('OTHER_AM', mockConfig);
|
||||
}
|
||||
|
||||
function mockHasEditPermission(enabled: boolean) {
|
||||
@@ -131,14 +114,22 @@ const folder: Folder = {
|
||||
title: 'title',
|
||||
};
|
||||
|
||||
describe('NotificationPreview', () => {
|
||||
describe.each([
|
||||
// k8s API enabled
|
||||
true,
|
||||
// k8s API disabled
|
||||
false,
|
||||
])('NotificationPreview with alertingApiServer=%p', (apiServerEnabled) => {
|
||||
apiServerEnabled ? testWithFeatureToggles(['alertingApiServer']) : testWithFeatureToggles([]);
|
||||
it('should render notification preview without alert manager label, when having only one alert manager configured to receive alerts', async () => {
|
||||
mockOneAlertManager();
|
||||
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
|
||||
|
||||
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />);
|
||||
const { user } = render(
|
||||
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
|
||||
);
|
||||
|
||||
await userEvent.click(ui.previewButton.get());
|
||||
await user.click(ui.previewButton.get());
|
||||
await waitFor(() => {
|
||||
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -160,23 +151,18 @@ describe('NotificationPreview', () => {
|
||||
mockTwoAlertManagers();
|
||||
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
|
||||
|
||||
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />);
|
||||
await waitFor(() => {
|
||||
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
|
||||
});
|
||||
const { user } = render(
|
||||
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
|
||||
);
|
||||
|
||||
await userEvent.click(ui.previewButton.get());
|
||||
await waitFor(() => {
|
||||
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
|
||||
});
|
||||
await user.click(await ui.previewButton.find());
|
||||
|
||||
// we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts
|
||||
await waitFor(() => {
|
||||
expect(ui.grafanaAlertManagerLabel.query()).toBeInTheDocument();
|
||||
});
|
||||
expect(ui.otherAlertManagerLabel.query()).toBeInTheDocument();
|
||||
expect(await ui.grafanaAlertManagerLabel.find()).toBeInTheDocument();
|
||||
expect(await ui.otherAlertManagerLabel.find()).toBeInTheDocument();
|
||||
|
||||
const matchingPoliciesElements = await ui.route.findAll();
|
||||
|
||||
const matchingPoliciesElements = ui.route.queryAll();
|
||||
expect(matchingPoliciesElements).toHaveLength(2);
|
||||
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
|
||||
expect(matchingPoliciesElements[1]).toHaveTextContent(/tomato = red/);
|
||||
@@ -187,13 +173,15 @@ describe('NotificationPreview', () => {
|
||||
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
|
||||
mockHasEditPermission(true);
|
||||
|
||||
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />);
|
||||
const { user } = render(
|
||||
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(ui.previewButton.get());
|
||||
await userEvent.click(await ui.seeDetails.find());
|
||||
await user.click(ui.previewButton.get());
|
||||
await user.click(await ui.seeDetails.find());
|
||||
expect(ui.details.title.query()).toBeInTheDocument();
|
||||
//we expect seeing the default policy
|
||||
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
|
||||
@@ -209,13 +197,15 @@ describe('NotificationPreview', () => {
|
||||
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
|
||||
mockHasEditPermission(false);
|
||||
|
||||
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />);
|
||||
const { user } = render(
|
||||
<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(ui.previewButton.get());
|
||||
await userEvent.click(await ui.seeDetails.find());
|
||||
await user.click(ui.previewButton.get());
|
||||
await user.click(await ui.seeDetails.find());
|
||||
expect(ui.details.title.query()).toBeInTheDocument();
|
||||
//we expect seeing the default policy
|
||||
expect(screen.getByText(/default policy/i)).toBeInTheDocument();
|
||||
@@ -234,7 +224,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
{ job: 'prometheus', severity: 'warning' },
|
||||
];
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
const mockConfig = getMockConfig((amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
routeBuilder
|
||||
@@ -246,10 +236,9 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
.addReceivers((b) => b.withName('slack'))
|
||||
.addReceivers((b) => b.withName('opsgenie'))
|
||||
);
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
const { user } = render(
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={grafanaAlertManagerDataSource}
|
||||
potentialInstances={potentialInstances}
|
||||
@@ -285,7 +274,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
{ job: 'prometheus', severity: 'warning' },
|
||||
];
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
const mockConfig = getMockConfig((amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
routeBuilder
|
||||
@@ -300,10 +289,9 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
.addReceivers((b) => b.withName('slack'))
|
||||
.addReceivers((b) => b.withName('opsgenie'))
|
||||
);
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
const { user } = render(
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={grafanaAlertManagerDataSource}
|
||||
potentialInstances={potentialInstances}
|
||||
@@ -339,7 +327,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
{ job: 'prometheus', severity: 'warning' },
|
||||
];
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
const mockConfig = getMockConfig((amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.withRoute((routeBuilder) =>
|
||||
routeBuilder
|
||||
@@ -355,9 +343,9 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
.addReceivers((b) => b.withName('opsgenie'))
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
|
||||
render(
|
||||
const { user } = render(
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={grafanaAlertManagerDataSource}
|
||||
potentialInstances={potentialInstances}
|
||||
@@ -392,7 +380,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
it('does not match regex in middle of the word as alertmanager will anchor when queried via API', async () => {
|
||||
const potentialInstances: Labels[] = [{ regexfield: 'foobarfoo' }];
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
const mockConfig = getMockConfig((amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.addReceivers((b) => b.withName('email'))
|
||||
.withRoute((routeBuilder) =>
|
||||
@@ -402,6 +390,8 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
)
|
||||
);
|
||||
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
|
||||
render(
|
||||
<NotificationPreviewByAlertManager
|
||||
alertManagerSource={grafanaAlertManagerDataSource}
|
||||
@@ -417,7 +407,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
it('matches regex at the start of the word', async () => {
|
||||
const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }];
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
const mockConfig = getMockConfig((amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.addReceivers((b) => b.withName('email'))
|
||||
.withRoute((routeBuilder) =>
|
||||
@@ -426,6 +416,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, 'ba.*h'))
|
||||
)
|
||||
);
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
|
||||
render(
|
||||
<NotificationPreviewByAlertManager
|
||||
@@ -441,7 +432,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
it('handles negated regex correctly', async () => {
|
||||
const potentialInstances: Labels[] = [{ regexfield: 'thing' }];
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
const mockConfig = getMockConfig((amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.addReceivers((b) => b.withName('email'))
|
||||
.withRoute((routeBuilder) =>
|
||||
@@ -450,6 +441,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.notRegex, 'thing'))
|
||||
)
|
||||
);
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
|
||||
render(
|
||||
<NotificationPreviewByAlertManager
|
||||
@@ -466,7 +458,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
it('matches regex with flags', async () => {
|
||||
const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }];
|
||||
|
||||
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
|
||||
const mockConfig = getMockConfig((amConfigBuilder) =>
|
||||
amConfigBuilder
|
||||
.addReceivers((b) => b.withName('email'))
|
||||
.withRoute((routeBuilder) =>
|
||||
@@ -475,6 +467,7 @@ describe('NotificationPreviewByAlertmanager', () => {
|
||||
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, '(?i)BA.*h'))
|
||||
)
|
||||
);
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig);
|
||||
|
||||
render(
|
||||
<NotificationPreviewByAlertManager
|
||||
|
||||
+14
-7
@@ -2,6 +2,7 @@ import { compact } from 'lodash';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import { Button, LoadingPlaceholder, Stack, Text } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
@@ -64,26 +65,32 @@ export const NotificationPreview = ({
|
||||
<Stack direction="column">
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
|
||||
<Stack direction="column" gap={1}>
|
||||
<Text element="h5">Alert instance routing preview</Text>
|
||||
<Text element="h5">
|
||||
<Trans i18nKey="alerting.notification-preview.title">Alert instance routing preview</Trans>
|
||||
</Text>
|
||||
{isLoading && previewUninitialized && (
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
Loading...
|
||||
<Trans i18nKey="alerting.common.loading">Loading...</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{previewUninitialized ? (
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
When you have your folder selected and your query and labels are configured, click "Preview
|
||||
routing" to see the results here.
|
||||
<Trans i18nKey="alerting.notification-preview.uninitialized">
|
||||
When you have your folder selected and your query and labels are configured, click "Preview
|
||||
routing" to see the results here.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
Based on the labels added, alert instances are routed to the following notification policies. Expand each
|
||||
notification policy below to view more details.
|
||||
<Trans i18nKey="alerting.notification-preview.initialized">
|
||||
Based on the labels added, alert instances are routed to the following notification policies. Expand
|
||||
each notification policy below to view more details.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Button icon="sync" variant="secondary" type="button" onClick={onPreview} disabled={disabled}>
|
||||
Preview routing
|
||||
<Trans i18nKey="alerting.notification-preview.preview-routing">Preview routing</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
|
||||
|
||||
+8
-4
@@ -2,6 +2,8 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||
|
||||
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
|
||||
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||
@@ -27,9 +29,12 @@ function NotificationPreviewByAlertManager({
|
||||
);
|
||||
|
||||
if (error) {
|
||||
const title = t('alerting.notification-preview.error', 'Could not load routing preview for {{alertmanager}}', {
|
||||
alertmanager: alertManagerSource.name,
|
||||
});
|
||||
return (
|
||||
<Alert title="Cannot load Alertmanager configuration" severity="error">
|
||||
{error.message}
|
||||
<Alert title={title} severity="error">
|
||||
{stringifyErrorLike(error)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -46,8 +51,7 @@ function NotificationPreviewByAlertManager({
|
||||
<Stack direction="row" alignItems="center">
|
||||
<div className={styles.firstAlertManagerLine}></div>
|
||||
<div className={styles.alertManagerName}>
|
||||
{' '}
|
||||
Alertmanager:
|
||||
<Trans i18nKey="alerting.notification-preview.alertmanager">Alertmanager:</Trans>
|
||||
<img src={alertManagerSource.imgUrl} alt="" className={styles.img} />
|
||||
{alertManagerSource.name}
|
||||
</div>
|
||||
|
||||
+41
-29
@@ -1,9 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { useContactPointsWithStatus } from 'app/features/alerting/unified/components/contact-points/useContactPoints';
|
||||
import { useNotificationPolicyRoute } from 'app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute';
|
||||
|
||||
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||
import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig';
|
||||
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
|
||||
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
@@ -11,41 +13,50 @@ import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../
|
||||
|
||||
import { getRoutesByIdMap, RouteWithPath } from './route';
|
||||
|
||||
export const useAlertmanagerNotificationRoutingPreview = (
|
||||
alertManagerSourceName: string,
|
||||
potentialInstances: Labels[]
|
||||
) => {
|
||||
const { currentData, isLoading: configLoading, error: configError } = useAlertmanagerConfig(alertManagerSourceName);
|
||||
const config = currentData?.alertmanager_config;
|
||||
export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, potentialInstances: Labels[]) => {
|
||||
const {
|
||||
data: currentData,
|
||||
isLoading: isPoliciesLoading,
|
||||
error: policiesError,
|
||||
} = useNotificationPolicyRoute({ alertmanager });
|
||||
|
||||
const {
|
||||
contactPoints,
|
||||
isLoading: contactPointsLoading,
|
||||
error: contactPointsError,
|
||||
} = useContactPointsWithStatus({
|
||||
alertmanager,
|
||||
fetchPolicies: false,
|
||||
fetchStatuses: false,
|
||||
});
|
||||
|
||||
const { matchInstancesToRoute } = useRouteGroupsMatcher();
|
||||
|
||||
// to create the list of matching contact points we need to first get the rootRoute
|
||||
const { rootRoute, receivers } = useMemo(() => {
|
||||
if (!config) {
|
||||
return {
|
||||
receivers: [],
|
||||
rootRoute: undefined,
|
||||
};
|
||||
const [defaultPolicy] = currentData ?? [];
|
||||
const rootRoute = useMemo(() => {
|
||||
if (!defaultPolicy) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
rootRoute: config.route ? normalizeRoute(addUniqueIdentifierToRoute(config.route)) : undefined,
|
||||
receivers: config.receivers ?? [],
|
||||
};
|
||||
}, [config]);
|
||||
return normalizeRoute(addUniqueIdentifierToRoute(defaultPolicy));
|
||||
}, [defaultPolicy]);
|
||||
|
||||
// create maps for routes to be get by id, this map also contains the path to the route
|
||||
// ⚠️ don't forget to compute the inherited tree before using this map
|
||||
const routesByIdMap: Map<string, RouteWithPath> = rootRoute
|
||||
const routesByIdMap = rootRoute
|
||||
? getRoutesByIdMap(computeInheritedTree(rootRoute))
|
||||
: new Map();
|
||||
: new Map<string, RouteWithPath>();
|
||||
|
||||
// create map for receivers to be get by name
|
||||
const receiversByName =
|
||||
receivers.reduce((map, receiver) => {
|
||||
// to create the list of matching contact points we need to first get the rootRoute
|
||||
const receiversByName = useMemo(() => {
|
||||
if (!contactPoints) {
|
||||
return new Map<string, Receiver>();
|
||||
}
|
||||
|
||||
// create map for receivers to be get by name
|
||||
return contactPoints.reduce((map, receiver) => {
|
||||
return map.set(receiver.name, receiver);
|
||||
}, new Map<string, Receiver>()) ?? new Map<string, Receiver>();
|
||||
}, new Map<string, Receiver>());
|
||||
}, [contactPoints]);
|
||||
|
||||
// match labels in the tree => map of notification policies and the alert instances (list of labels) in each one
|
||||
const {
|
||||
@@ -56,8 +67,9 @@ export const useAlertmanagerNotificationRoutingPreview = (
|
||||
if (!rootRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await matchInstancesToRoute(rootRoute, potentialInstances, {
|
||||
unquoteMatchers: alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME,
|
||||
unquoteMatchers: alertmanager !== GRAFANA_RULES_SOURCE_NAME,
|
||||
});
|
||||
}, [rootRoute, potentialInstances]);
|
||||
|
||||
@@ -65,7 +77,7 @@ export const useAlertmanagerNotificationRoutingPreview = (
|
||||
routesByIdMap,
|
||||
receiversByName,
|
||||
matchingMap,
|
||||
loading: configLoading || matchingLoading,
|
||||
error: configError ?? matchingError,
|
||||
loading: isPoliciesLoading || contactPointsLoading || matchingLoading,
|
||||
error: policiesError ?? contactPointsError ?? matchingError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -159,23 +159,11 @@ export class AlertmanagerReceiverBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
export function mockApi(server: SetupServer) {
|
||||
return {
|
||||
getAlertmanagerConfig: (amName: string, configure: (builder: AlertmanagerConfigBuilder) => void) => {
|
||||
const builder = new AlertmanagerConfigBuilder();
|
||||
configure(builder);
|
||||
|
||||
server.use(
|
||||
http.get(`api/alertmanager/${amName}/config/api/v1/alerts`, () =>
|
||||
HttpResponse.json<AlertManagerCortexConfig>({
|
||||
alertmanager_config: builder.build(),
|
||||
template_files: {},
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
export const getMockConfig = (configure: (builder: AlertmanagerConfigBuilder) => void): AlertManagerCortexConfig => {
|
||||
const builder = new AlertmanagerConfigBuilder();
|
||||
configure(builder);
|
||||
return { alertmanager_config: builder.build(), template_files: {} };
|
||||
};
|
||||
|
||||
export function mockAlertRuleApi(server: SetupServer) {
|
||||
return {
|
||||
|
||||
@@ -14,9 +14,8 @@ const normalizeMatchers = (route: Route) => {
|
||||
const routeMatchers: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Matcher[] = [];
|
||||
|
||||
if (route.object_matchers) {
|
||||
// todo foreach
|
||||
route.object_matchers.map(([label, type, value]) => {
|
||||
return { label, type, value };
|
||||
route.object_matchers.forEach(([label, type, value]) => {
|
||||
routeMatchers.push({ label, type, value });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,41 +7,43 @@ import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } f
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { PROVENANCE_NONE, K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants';
|
||||
|
||||
const config = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME);
|
||||
const getReceiversList = () => {
|
||||
const config = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME);
|
||||
|
||||
// Turn our mock alertmanager config into the format that we expect to be returned by the k8s API
|
||||
const mappedReceivers =
|
||||
config.alertmanager_config?.receivers?.map((contactPoint) => {
|
||||
const provenance =
|
||||
contactPoint.grafana_managed_receiver_configs?.find((integration) => {
|
||||
return integration.provenance;
|
||||
})?.provenance || PROVENANCE_NONE;
|
||||
return {
|
||||
metadata: {
|
||||
// This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses
|
||||
uid: camelCase(contactPoint.name),
|
||||
annotations: {
|
||||
[K8sAnnotations.Provenance]: provenance,
|
||||
[K8sAnnotations.AccessAdmin]: 'true',
|
||||
[K8sAnnotations.AccessDelete]: 'true',
|
||||
[K8sAnnotations.AccessWrite]: 'true',
|
||||
// Turn our mock alertmanager config into the format that we expect to be returned by the k8s API
|
||||
const mappedReceivers =
|
||||
config.alertmanager_config?.receivers?.map((contactPoint) => {
|
||||
const provenance =
|
||||
contactPoint.grafana_managed_receiver_configs?.find((integration) => {
|
||||
return integration.provenance;
|
||||
})?.provenance || PROVENANCE_NONE;
|
||||
return {
|
||||
metadata: {
|
||||
// This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses
|
||||
uid: camelCase(contactPoint.name),
|
||||
annotations: {
|
||||
[K8sAnnotations.Provenance]: provenance,
|
||||
[K8sAnnotations.AccessAdmin]: 'true',
|
||||
[K8sAnnotations.AccessDelete]: 'true',
|
||||
[K8sAnnotations.AccessWrite]: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
title: contactPoint.name,
|
||||
integrations: contactPoint.grafana_managed_receiver_configs || [],
|
||||
},
|
||||
};
|
||||
}) || [];
|
||||
spec: {
|
||||
title: contactPoint.name,
|
||||
integrations: contactPoint.grafana_managed_receiver_configs || [],
|
||||
},
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const parsedReceivers = getK8sResponse<ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver>(
|
||||
'ReceiverList',
|
||||
mappedReceivers
|
||||
);
|
||||
return getK8sResponse<ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver>(
|
||||
'ReceiverList',
|
||||
mappedReceivers
|
||||
);
|
||||
};
|
||||
|
||||
const listNamespacedReceiverHandler = () =>
|
||||
http.get<{ namespace: string }>(`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/receivers`, () => {
|
||||
return HttpResponse.json(parsedReceivers);
|
||||
return HttpResponse.json(getReceiversList());
|
||||
});
|
||||
|
||||
const createNamespacedReceiverHandler = () =>
|
||||
@@ -58,6 +60,7 @@ const deleteNamespacedReceiverHandler = () =>
|
||||
`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/receivers/:name`,
|
||||
({ params }) => {
|
||||
const { name } = params;
|
||||
const parsedReceivers = getReceiversList();
|
||||
const matchedReceiver = parsedReceivers.items.find((receiver) => receiver.metadata.uid === name);
|
||||
if (matchedReceiver) {
|
||||
return HttpResponse.json(parsedReceivers);
|
||||
|
||||
@@ -3,15 +3,19 @@ import { HttpResponse, http } from 'msw';
|
||||
import { getAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
|
||||
const alertmanagerConfig = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME);
|
||||
const defaultReceiversResponse = alertmanagerConfig.alertmanager_config.receivers;
|
||||
const defaultTimeIntervalsResponse = alertmanagerConfig.alertmanager_config.time_intervals;
|
||||
const getNotificationReceiversHandler = () =>
|
||||
http.get('/api/v1/notifications/receivers', () => {
|
||||
const receivers = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME).alertmanager_config.receivers || [];
|
||||
|
||||
const getNotificationReceiversHandler = (response = defaultReceiversResponse) =>
|
||||
http.get('/api/v1/notifications/receivers', () => HttpResponse.json(response));
|
||||
return HttpResponse.json(receivers);
|
||||
});
|
||||
|
||||
const getTimeIntervalsHandler = (response = defaultTimeIntervalsResponse) =>
|
||||
http.get('/api/v1/notifications/time-intervals', () => HttpResponse.json(response));
|
||||
const getTimeIntervalsHandler = () =>
|
||||
http.get('/api/v1/notifications/time-intervals', () => {
|
||||
const intervals = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME).alertmanager_config.time_intervals;
|
||||
|
||||
return HttpResponse.json(intervals);
|
||||
});
|
||||
|
||||
const handlers = [getNotificationReceiversHandler(), getTimeIntervalsHandler()];
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { Echo } from 'app/core/services/echo/Echo';
|
||||
|
||||
import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext';
|
||||
import { setupPluginExtensionRegistries } from '../extensions/registry/setup';
|
||||
import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry';
|
||||
import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry';
|
||||
import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry';
|
||||
import { getPluginSettings } from '../pluginSettings';
|
||||
import { importAppPlugin } from '../plugin_loader';
|
||||
|
||||
@@ -86,7 +88,11 @@ function renderUnderRouter(page = '') {
|
||||
|
||||
appPluginNavItem.parentItem = appsSection;
|
||||
|
||||
const registries = setupPluginExtensionRegistries();
|
||||
const registries = {
|
||||
addedComponentsRegistry: new AddedComponentsRegistry(),
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
};
|
||||
const pagePath = page ? `/${page}` : '';
|
||||
const route = {
|
||||
path: `/a/:pluginId/*`,
|
||||
|
||||
@@ -5,17 +5,17 @@ import { AddedLinksRegistry } from './AddedLinksRegistry';
|
||||
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './types';
|
||||
|
||||
export function setupPluginExtensionRegistries(): PluginExtensionRegistries {
|
||||
const pluginExtensionsRegistries = {
|
||||
addedComponentsRegistry: new AddedComponentsRegistry(),
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
};
|
||||
export const addedComponentsRegistry = new AddedComponentsRegistry();
|
||||
export const exposedComponentsRegistry = new ExposedComponentsRegistry();
|
||||
export const addedLinksRegistry = new AddedLinksRegistry();
|
||||
export const pluginExtensionRegistries: PluginExtensionRegistries = {
|
||||
addedComponentsRegistry,
|
||||
exposedComponentsRegistry,
|
||||
addedLinksRegistry,
|
||||
};
|
||||
|
||||
pluginExtensionsRegistries.addedLinksRegistry.register({
|
||||
pluginId: 'grafana',
|
||||
configs: getCoreExtensionConfigurations(),
|
||||
});
|
||||
|
||||
return pluginExtensionsRegistries;
|
||||
}
|
||||
// Registering core extensions
|
||||
addedLinksRegistry.register({
|
||||
pluginId: 'grafana',
|
||||
configs: getCoreExtensionConfigurations(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { preloadPlugins } from '../pluginPreloader';
|
||||
|
||||
import { getAppPluginConfigs } from './utils';
|
||||
|
||||
export function useLoadAppPlugins(pluginIds: string[] = []): { isLoading: boolean } {
|
||||
const { loading: isLoading } = useAsync(async () => {
|
||||
const appConfigs = getAppPluginConfigs(pluginIds);
|
||||
|
||||
if (!appConfigs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await preloadPlugins(appConfigs);
|
||||
});
|
||||
|
||||
return { isLoading };
|
||||
}
|
||||
@@ -7,11 +7,15 @@ 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 { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
import { usePluginComponent } from './usePluginComponent';
|
||||
import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
|
||||
|
||||
jest.mock('./useLoadAppPlugins');
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
getPluginSettings: jest.fn().mockResolvedValue({
|
||||
id: 'my-app-plugin',
|
||||
@@ -83,7 +87,12 @@ describe('usePluginComponent()', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
registries = setupPluginExtensionRegistries();
|
||||
registries = {
|
||||
addedComponentsRegistry: new AddedComponentsRegistry(),
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
};
|
||||
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
resetLogMock(log);
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { UsePluginComponentResult } from '@grafana/runtime';
|
||||
import { useExposedComponentsRegistry } from './ExtensionRegistriesContext';
|
||||
import * as errors from './errors';
|
||||
import { log } from './logs/log';
|
||||
import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
import { getExposedComponentPluginDependencies, isGrafanaDevMode, wrapWithPluginContext } from './utils';
|
||||
import { isExposedComponentDependencyMissing } from './validators';
|
||||
|
||||
// Returns a component exposed by a plugin.
|
||||
@@ -16,11 +17,19 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
|
||||
const registry = useExposedComponentsRegistry();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const pluginContext = usePluginContext();
|
||||
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id));
|
||||
|
||||
return useMemo(() => {
|
||||
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||
const enableRestrictions = isGrafanaDevMode() && pluginContext;
|
||||
|
||||
if (isLoadingAppPlugins) {
|
||||
return {
|
||||
isLoading: true,
|
||||
component: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!registryState?.[id]) {
|
||||
return {
|
||||
isLoading: false,
|
||||
@@ -47,5 +56,5 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
|
||||
isLoading: false,
|
||||
component: wrapWithPluginContext(registryItem.pluginId, registryItem.component, componentLog),
|
||||
};
|
||||
}, [id, pluginContext, registryState]);
|
||||
}, [id, pluginContext, registryState, isLoadingAppPlugins]);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@ 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 { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
import { usePluginComponents } from './usePluginComponents';
|
||||
import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
|
||||
|
||||
jest.mock('./useLoadAppPlugins');
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
getPluginSettings: jest.fn().mockResolvedValue({
|
||||
id: 'my-app-plugin',
|
||||
@@ -50,8 +54,14 @@ describe('usePluginComponents()', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
|
||||
|
||||
resetLogMock(log);
|
||||
registries = setupPluginExtensionRegistries();
|
||||
registries = {
|
||||
addedComponentsRegistry: new AddedComponentsRegistry(),
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
};
|
||||
|
||||
jest.mocked(wrapWithPluginContext).mockClear();
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
|
||||
import * as errors from './errors';
|
||||
import { log } from './logs/log';
|
||||
import { isGrafanaDevMode } from './utils';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
|
||||
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
|
||||
|
||||
// Returns an array of component extensions for the given extension point
|
||||
@@ -21,6 +22,7 @@ export function usePluginComponents<Props extends object = {}>({
|
||||
const registry = useAddedComponentsRegistry();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const pluginContext = usePluginContext();
|
||||
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
|
||||
|
||||
return useMemo(() => {
|
||||
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||
@@ -45,6 +47,13 @@ export function usePluginComponents<Props extends object = {}>({
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoadingAppPlugins) {
|
||||
return {
|
||||
isLoading: true,
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
|
||||
for (const registryItem of registryState?.[extensionPointId] ?? []) {
|
||||
const { pluginId } = registryItem;
|
||||
|
||||
@@ -65,5 +74,5 @@ export function usePluginComponents<Props extends object = {}>({
|
||||
isLoading: false,
|
||||
components,
|
||||
};
|
||||
}, [extensionPointId, limitPerPlugin, pluginContext, registryState]);
|
||||
}, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
import { createUsePluginExtensions } from './usePluginExtensions';
|
||||
|
||||
jest.mock('./useLoadAppPlugins');
|
||||
|
||||
describe('usePluginExtensions()', () => {
|
||||
let registries: PluginExtensionRegistries;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
@@ -18,6 +21,7 @@ describe('usePluginExtensions()', () => {
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
};
|
||||
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no extensions registered for the extension point', () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import * as errors from './errors';
|
||||
import { getPluginExtensions } from './getPluginExtensions';
|
||||
import { log } from './logs/log';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { isGrafanaDevMode } from './utils';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
|
||||
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
|
||||
|
||||
export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
|
||||
@@ -20,8 +21,9 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
|
||||
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
|
||||
const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
|
||||
const { extensionPointId, context, limitPerPlugin } = options;
|
||||
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
|
||||
|
||||
const { extensions } = useMemo(() => {
|
||||
return useMemo(() => {
|
||||
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||
const enableRestrictions = isGrafanaDevMode() && pluginContext !== null;
|
||||
const pluginId = pluginContext?.meta.id ?? '';
|
||||
@@ -50,19 +52,35 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
|
||||
};
|
||||
}
|
||||
|
||||
return getPluginExtensions({
|
||||
if (isLoadingAppPlugins) {
|
||||
return {
|
||||
isLoading: true,
|
||||
extensions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { extensions } = getPluginExtensions({
|
||||
extensionPointId,
|
||||
context,
|
||||
limitPerPlugin,
|
||||
addedComponentsRegistry,
|
||||
addedLinksRegistry,
|
||||
});
|
||||
|
||||
return { extensions, isLoading: false };
|
||||
|
||||
// Doing the deps like this instead of just `option` because users probably aren't going to memoize the
|
||||
// options object so we are checking it's simple value attributes.
|
||||
// The context though still has to be memoized though and not mutated.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: refactor `getPluginExtensions` to accept service dependencies as arguments instead of relying on the sidecar singleton under the hood
|
||||
}, [addedLinksRegistry, addedComponentsRegistry, extensionPointId, context, limitPerPlugin, pluginContext]);
|
||||
|
||||
return { extensions, isLoading: false };
|
||||
}, [
|
||||
addedLinksRegistry,
|
||||
addedComponentsRegistry,
|
||||
extensionPointId,
|
||||
context,
|
||||
limitPerPlugin,
|
||||
pluginContext,
|
||||
isLoadingAppPlugins,
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@ 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 { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
import { usePluginLinks } from './usePluginLinks';
|
||||
import { isGrafanaDevMode } from './utils';
|
||||
|
||||
jest.mock('./useLoadAppPlugins');
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
getPluginSettings: jest.fn().mockResolvedValue({
|
||||
id: 'my-app-plugin',
|
||||
@@ -48,8 +52,13 @@ describe('usePluginLinks()', () => {
|
||||
const extensionPointId = `${pluginId}/extension-point/v1`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
registries = setupPluginExtensionRegistries();
|
||||
registries = {
|
||||
addedComponentsRegistry: new AddedComponentsRegistry(),
|
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
||||
addedLinksRegistry: new AddedLinksRegistry(),
|
||||
};
|
||||
resetLogMock(log);
|
||||
|
||||
pluginMeta = {
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
|
||||
import * as errors from './errors';
|
||||
import { log } from './logs/log';
|
||||
import { useLoadAppPlugins } from './useLoadAppPlugins';
|
||||
import {
|
||||
generateExtensionId,
|
||||
getExtensionPointPluginDependencies,
|
||||
getLinkExtensionOnClick,
|
||||
getLinkExtensionOverrides,
|
||||
getLinkExtensionPathWithTracking,
|
||||
@@ -30,6 +32,7 @@ export function usePluginLinks({
|
||||
const registry = useAddedLinksRegistry();
|
||||
const pluginContext = usePluginContext();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
|
||||
|
||||
return useMemo(() => {
|
||||
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||
@@ -56,6 +59,13 @@ export function usePluginLinks({
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoadingAppPlugins) {
|
||||
return {
|
||||
isLoading: true,
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!registryState || !registryState[extensionPointId]) {
|
||||
return {
|
||||
isLoading: false,
|
||||
@@ -117,5 +127,5 @@ export function usePluginLinks({
|
||||
isLoading: false,
|
||||
links: extensions,
|
||||
};
|
||||
}, [context, extensionPointId, limitPerPlugin, registryState, pluginContext]);
|
||||
}, [context, extensionPointId, limitPerPlugin, registryState, pluginContext, isLoadingAppPlugins]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { type Unsubscribable } from 'rxjs';
|
||||
|
||||
import { dateTime, usePluginContext } from '@grafana/data';
|
||||
import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { ShowModalReactEvent } from 'app/types/events';
|
||||
|
||||
@@ -12,6 +13,10 @@ import {
|
||||
getReadOnlyProxy,
|
||||
createOpenModalFunction,
|
||||
wrapWithPluginContext,
|
||||
getExtensionPointPluginDependencies,
|
||||
getExposedComponentPluginDependencies,
|
||||
getAppPluginConfigs,
|
||||
getAppPluginIdFromExposedComponentId,
|
||||
} from './utils';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
@@ -447,4 +452,431 @@ describe('Plugin Extensions / Utils', () => {
|
||||
expect(screen.getByText('Version: 1.0.0')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppPluginConfigs()', () => {
|
||||
const originalApps = config.apps;
|
||||
const genereicAppPluginConfig = {
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
test('should return the app plugin configs based on the provided plugin ids', () => {
|
||||
config.apps = {
|
||||
'myorg-first-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-first-app',
|
||||
},
|
||||
'myorg-second-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-second-app',
|
||||
},
|
||||
'myorg-third-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-third-app',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getAppPluginConfigs(['myorg-first-app', 'myorg-third-app'])).toEqual([
|
||||
config.apps['myorg-first-app'],
|
||||
config.apps['myorg-third-app'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should simply ignore the app plugin ids that do not belong to a config', () => {
|
||||
config.apps = {
|
||||
'myorg-first-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-first-app',
|
||||
},
|
||||
'myorg-second-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-second-app',
|
||||
},
|
||||
'myorg-third-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-third-app',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getAppPluginConfigs(['myorg-first-app', 'unknown-app-id'])).toEqual([config.apps['myorg-first-app']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppPluginIdFromExposedComponentId()', () => {
|
||||
test('should return the app plugin id from an extension point id', () => {
|
||||
expect(getAppPluginIdFromExposedComponentId('myorg-extensions-app/component/v1')).toBe('myorg-extensions-app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtensionPointPluginDependencies()', () => {
|
||||
const originalApps = config.apps;
|
||||
const genereicAppPluginConfig = {
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
test('should return the app plugin ids that register extensions to a link extension point', () => {
|
||||
const extensionPointId = 'myorg-first-app/link/v1';
|
||||
|
||||
config.apps = {
|
||||
'myorg-first-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-first-app',
|
||||
},
|
||||
// This plugin is registering a link extension to the extension point
|
||||
'myorg-second-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-second-app',
|
||||
extensions: {
|
||||
addedLinks: [
|
||||
{
|
||||
targets: [extensionPointId],
|
||||
title: 'Link title',
|
||||
},
|
||||
],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
},
|
||||
'myorg-third-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-third-app',
|
||||
},
|
||||
};
|
||||
|
||||
const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
|
||||
|
||||
expect(appPluginIds).toEqual(['myorg-second-app']);
|
||||
});
|
||||
|
||||
test('should return the app plugin ids that register extensions to a component extension point', () => {
|
||||
const extensionPointId = 'myorg-first-app/component/v1';
|
||||
|
||||
config.apps = {
|
||||
'myorg-first-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-first-app',
|
||||
},
|
||||
'myorg-second-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-second-app',
|
||||
},
|
||||
// This plugin is registering a component extension to the extension point
|
||||
'myorg-third-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-third-app',
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [
|
||||
{
|
||||
targets: [extensionPointId],
|
||||
title: 'Component title',
|
||||
},
|
||||
],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
|
||||
|
||||
expect(appPluginIds).toEqual(['myorg-third-app']);
|
||||
});
|
||||
|
||||
test('should return an empty array if there are no apps that that extend the extension point', () => {
|
||||
const extensionPointId = 'myorg-first-app/component/v1';
|
||||
|
||||
// None of the apps are extending the extension point
|
||||
config.apps = {
|
||||
'myorg-first-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-first-app',
|
||||
},
|
||||
'myorg-second-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-second-app',
|
||||
},
|
||||
'myorg-third-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-third-app',
|
||||
},
|
||||
};
|
||||
|
||||
const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
|
||||
|
||||
expect(appPluginIds).toEqual([]);
|
||||
});
|
||||
|
||||
test('should also return (recursively) the app plugin ids that the apps which extend the extension-point depend on', () => {
|
||||
const extensionPointId = 'myorg-first-app/component/v1';
|
||||
|
||||
config.apps = {
|
||||
'myorg-first-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-first-app',
|
||||
},
|
||||
// This plugin is registering a component extension to the extension point.
|
||||
// It is also depending on the 'myorg-fourth-app' plugin.
|
||||
'myorg-second-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-second-app',
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [
|
||||
{
|
||||
targets: [extensionPointId],
|
||||
title: 'Component title',
|
||||
},
|
||||
],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
dependencies: {
|
||||
...genereicAppPluginConfig.dependencies,
|
||||
extensions: {
|
||||
exposedComponents: ['myorg-fourth-app/component/v1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'myorg-third-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-third-app',
|
||||
},
|
||||
// This plugin exposes a component, but is also depending on the 'myorg-fifth-app'.
|
||||
'myorg-fourth-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-fourth-app',
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: 'myorg-fourth-app/component/v1',
|
||||
title: 'Exposed component',
|
||||
},
|
||||
],
|
||||
extensionPoints: [],
|
||||
},
|
||||
dependencies: {
|
||||
...genereicAppPluginConfig.dependencies,
|
||||
extensions: {
|
||||
exposedComponents: ['myorg-fifth-app/component/v1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'myorg-fifth-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-fifth-app',
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: 'myorg-fifth-app/component/v1',
|
||||
title: 'Exposed component',
|
||||
},
|
||||
],
|
||||
extensionPoints: [],
|
||||
},
|
||||
},
|
||||
'myorg-sixth-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-sixth-app',
|
||||
},
|
||||
};
|
||||
|
||||
const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
|
||||
|
||||
expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExposedComponentPluginDependencies()', () => {
|
||||
const originalApps = config.apps;
|
||||
const genereicAppPluginConfig = {
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
test('should only return the app plugin id that exposes the component, if that component does not depend on anything', () => {
|
||||
const exposedComponentId = 'myorg-second-app/component/v1';
|
||||
|
||||
config.apps = {
|
||||
'myorg-first-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-first-app',
|
||||
},
|
||||
'myorg-second-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-second-app',
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: exposedComponentId,
|
||||
title: 'Component title',
|
||||
},
|
||||
],
|
||||
extensionPoints: [],
|
||||
},
|
||||
},
|
||||
'myorg-third-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-third-app',
|
||||
},
|
||||
};
|
||||
|
||||
const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId);
|
||||
|
||||
expect(appPluginIds).toEqual(['myorg-second-app']);
|
||||
});
|
||||
|
||||
test('should also return the list of app plugin ids that the plugin - which exposes the component - is depending on', () => {
|
||||
const exposedComponentId = 'myorg-second-app/component/v1';
|
||||
|
||||
config.apps = {
|
||||
'myorg-first-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-first-app',
|
||||
},
|
||||
'myorg-second-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-second-app',
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: exposedComponentId,
|
||||
title: 'Component title',
|
||||
},
|
||||
],
|
||||
extensionPoints: [],
|
||||
},
|
||||
dependencies: {
|
||||
...genereicAppPluginConfig.dependencies,
|
||||
extensions: {
|
||||
exposedComponents: ['myorg-fourth-app/component/v1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'myorg-third-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-third-app',
|
||||
},
|
||||
'myorg-fourth-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-fourth-app',
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: 'myorg-fourth-app/component/v1',
|
||||
title: 'Component title',
|
||||
},
|
||||
],
|
||||
extensionPoints: [],
|
||||
},
|
||||
dependencies: {
|
||||
...genereicAppPluginConfig.dependencies,
|
||||
extensions: {
|
||||
exposedComponents: ['myorg-fifth-app/component/v1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'myorg-fifth-app': {
|
||||
...genereicAppPluginConfig,
|
||||
id: 'myorg-fifth-app',
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: 'myorg-fifth-app/component/v1',
|
||||
title: 'Component title',
|
||||
},
|
||||
],
|
||||
extensionPoints: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId);
|
||||
|
||||
expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
PanelMenuItem,
|
||||
PluginExtensionAddedLinkConfig,
|
||||
urlUtil,
|
||||
PluginExtensionPoints,
|
||||
} from '@grafana/data';
|
||||
import { reportInteraction, config } from '@grafana/runtime';
|
||||
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
|
||||
import { Modal } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
@@ -421,3 +422,75 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string,
|
||||
// Comes from the `app_mode` setting in the Grafana config (defaults to "development")
|
||||
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable
|
||||
export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
|
||||
|
||||
export const getAppPluginConfigs = (pluginIds: string[] = []) =>
|
||||
Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
|
||||
|
||||
export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string) => {
|
||||
return exposedComponentId.split('/')[0];
|
||||
};
|
||||
|
||||
// Returns a list of app plugin ids that are registering extensions to this extension point.
|
||||
// (These plugins are necessary to be loaded to use the extension point.)
|
||||
// (The function also returns the plugin ids that the plugins - that extend the extension point - depend on.)
|
||||
export const getExtensionPointPluginDependencies = (extensionPointId: string): string[] => {
|
||||
return Object.values(config.apps)
|
||||
.filter(
|
||||
(app) =>
|
||||
app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)) ||
|
||||
app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId))
|
||||
)
|
||||
.map((app) => app.id)
|
||||
.reduce((acc: string[], id: string) => {
|
||||
return [...acc, id, ...getAppPluginDependencies(id)];
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component.
|
||||
// (It is first the plugin that exposes the component, and then the ones that it depends on.)
|
||||
export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {
|
||||
const pluginId = getAppPluginIdFromExposedComponentId(exposedComponentId);
|
||||
|
||||
return [pluginId].reduce((acc: string[], pluginId: string) => {
|
||||
return [...acc, pluginId, ...getAppPluginDependencies(pluginId)];
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Returns a list of app plugin ids that are necessary to be loaded, based on the `dependencies.extensions`
|
||||
// metadata field. (For example the plugins that expose components that the app depends on.)
|
||||
// Heads up! This is a recursive function.
|
||||
export const getAppPluginDependencies = (pluginId: string): string[] => {
|
||||
if (!config.apps[pluginId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pluginIdDependencies = config.apps[pluginId].dependencies.extensions.exposedComponents.map(
|
||||
getAppPluginIdFromExposedComponentId
|
||||
);
|
||||
|
||||
return pluginIdDependencies.reduce((acc, pluginId) => {
|
||||
return [...acc, ...getAppPluginDependencies(pluginId)];
|
||||
}, pluginIdDependencies);
|
||||
};
|
||||
|
||||
// Returns a list of app plugins that has to be loaded before core Grafana could finish the initialization.
|
||||
export const getAppPluginsToAwait = () => {
|
||||
const pluginIds = [
|
||||
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
|
||||
'cloud-home-app',
|
||||
];
|
||||
|
||||
return Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
|
||||
};
|
||||
|
||||
// Returns a list of app plugins that has to be preloaded in parallel with the core Grafana initialization.
|
||||
export const getAppPluginsToPreload = () => {
|
||||
// The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading.
|
||||
const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(PluginExtensionPoints.DashboardPanelMenu);
|
||||
const awaitedPluginIds = getAppPluginsToAwait().map((app) => app.id);
|
||||
const isNotAwaited = (app: AppPluginConfig) => !awaitedPluginIds.includes(app.id);
|
||||
|
||||
return Object.values(config.apps).filter((app) => {
|
||||
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data';
|
||||
import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
|
||||
import type { AppPluginConfig } from '@grafana/runtime';
|
||||
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { PluginExtensionRegistries } from './extensions/registry/types';
|
||||
import { importPluginModule } from './plugin_loader';
|
||||
import { importAppPlugin } from './plugin_loader';
|
||||
|
||||
export type PluginPreloadResult = {
|
||||
pluginId: string;
|
||||
@@ -15,67 +13,28 @@ export type PluginPreloadResult = {
|
||||
addedLinkConfigs?: PluginExtensionAddedLinkConfig[];
|
||||
};
|
||||
|
||||
export async function preloadPlugins(
|
||||
apps: AppPluginConfig[] = [],
|
||||
registries: PluginExtensionRegistries,
|
||||
eventName = 'frontend_plugins_preload'
|
||||
) {
|
||||
startMeasure(eventName);
|
||||
const promises = apps.filter((config) => config.preload).map((config) => preload(config));
|
||||
const preloadedPlugins = await Promise.all(promises);
|
||||
const preloadedAppPlugins = new Set<string>();
|
||||
const isNotYetPreloaded = ({ id }: AppPluginConfig) => !preloadedAppPlugins.has(id);
|
||||
const markAsPreloaded = (apps: AppPluginConfig[]) => apps.forEach(({ id }) => preloadedAppPlugins.add(id));
|
||||
|
||||
for (const preloadedPlugin of preloadedPlugins) {
|
||||
if (preloadedPlugin.error) {
|
||||
console.error(`[Plugins] Skip loading extensions for "${preloadedPlugin.pluginId}" due to an error.`);
|
||||
continue;
|
||||
}
|
||||
export async function preloadPlugins(apps: AppPluginConfig[] = []) {
|
||||
const appPluginsToPreload = apps.filter(isNotYetPreloaded);
|
||||
|
||||
registries.exposedComponentsRegistry.register({
|
||||
pluginId: preloadedPlugin.pluginId,
|
||||
configs: preloadedPlugin.exposedComponentConfigs,
|
||||
});
|
||||
registries.addedComponentsRegistry.register({
|
||||
pluginId: preloadedPlugin.pluginId,
|
||||
configs: preloadedPlugin.addedComponentConfigs || [],
|
||||
});
|
||||
registries.addedLinksRegistry.register({
|
||||
pluginId: preloadedPlugin.pluginId,
|
||||
configs: preloadedPlugin.addedLinkConfigs || [],
|
||||
});
|
||||
if (appPluginsToPreload.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopMeasure(eventName);
|
||||
markAsPreloaded(apps);
|
||||
|
||||
await Promise.all(appPluginsToPreload.map(preload));
|
||||
}
|
||||
|
||||
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
|
||||
const { path, version, id: pluginId, loadingStrategy } = config;
|
||||
async function preload(config: AppPluginConfig) {
|
||||
try {
|
||||
startMeasure(`frontend_plugin_preload_${pluginId}`);
|
||||
const { plugin } = await importPluginModule({
|
||||
path,
|
||||
version,
|
||||
isAngular: config.angular.detected,
|
||||
pluginId,
|
||||
loadingStrategy,
|
||||
moduleHash: config.moduleHash,
|
||||
});
|
||||
const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin;
|
||||
const meta = await getPluginSettings(config.id);
|
||||
|
||||
// Fetching meta-information for the preloaded app plugin and caching it for later.
|
||||
// (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.)
|
||||
getPluginSettings(pluginId);
|
||||
|
||||
return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs };
|
||||
await importAppPlugin(meta);
|
||||
} catch (error) {
|
||||
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error);
|
||||
return {
|
||||
pluginId,
|
||||
error,
|
||||
exposedComponentConfigs: [],
|
||||
addedComponentConfigs: [],
|
||||
addedLinkConfigs: [],
|
||||
};
|
||||
} finally {
|
||||
stopMeasure(`frontend_plugin_preload_${pluginId}`);
|
||||
console.error(`[Plugins] Failed to preload plugin: ${config.path} (version: ${config.version})`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DataQuery } from '@grafana/schema';
|
||||
import { GenericDataSourcePlugin } from '../datasources/types';
|
||||
|
||||
import builtInPlugins from './built_in_plugins';
|
||||
import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup';
|
||||
import { getPluginFromCache, registerPluginInCache } from './loader/cache';
|
||||
// SystemJS has to be imported before the sharedDependenciesMap
|
||||
import { SystemJS } from './loader/systemjs';
|
||||
@@ -69,6 +70,15 @@ systemJSPrototype.resolve = decorateSystemJSResolve.bind(systemJSPrototype, syst
|
||||
// Any css files loaded via SystemJS have their styles applied onload.
|
||||
systemJSPrototype.onload = decorateSystemJsOnload;
|
||||
|
||||
type PluginImportInfo = {
|
||||
path: string;
|
||||
pluginId: string;
|
||||
loadingStrategy: PluginLoadingStrategy;
|
||||
version?: string;
|
||||
isAngular?: boolean;
|
||||
moduleHash?: string;
|
||||
};
|
||||
|
||||
export async function importPluginModule({
|
||||
path,
|
||||
pluginId,
|
||||
@@ -76,14 +86,7 @@ export async function importPluginModule({
|
||||
version,
|
||||
isAngular,
|
||||
moduleHash,
|
||||
}: {
|
||||
path: string;
|
||||
pluginId: string;
|
||||
loadingStrategy: PluginLoadingStrategy;
|
||||
version?: string;
|
||||
isAngular?: boolean;
|
||||
moduleHash?: string;
|
||||
}): Promise<System.Module> {
|
||||
}: PluginImportInfo): Promise<System.Module> {
|
||||
if (version) {
|
||||
registerPluginInCache({ path, version, loadingStrategy });
|
||||
}
|
||||
@@ -166,21 +169,44 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<Gene
|
||||
});
|
||||
}
|
||||
|
||||
export function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
|
||||
const isAngular = meta.angular?.detected ?? meta.angularDetected;
|
||||
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
|
||||
return importPluginModule({
|
||||
// Only successfully loaded plugins are cached
|
||||
const importedAppPlugins: Record<string, AppPlugin> = {};
|
||||
|
||||
export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
|
||||
const pluginId = meta.id;
|
||||
|
||||
if (importedAppPlugins[pluginId]) {
|
||||
return importedAppPlugins[pluginId];
|
||||
}
|
||||
|
||||
const pluginExports = await importPluginModule({
|
||||
path: meta.module,
|
||||
version: meta.info?.version,
|
||||
isAngular,
|
||||
loadingStrategy: fallbackLoadingStrategy,
|
||||
pluginId: meta.id,
|
||||
isAngular: meta.angular?.detected ?? meta.angularDetected,
|
||||
loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch,
|
||||
moduleHash: meta.moduleHash,
|
||||
}).then((pluginExports) => {
|
||||
const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin();
|
||||
plugin.init(meta);
|
||||
plugin.meta = meta;
|
||||
plugin.setComponentsFromLegacyExports(pluginExports);
|
||||
return plugin;
|
||||
});
|
||||
|
||||
const { plugin = new AppPlugin() } = pluginExports;
|
||||
plugin.init(meta);
|
||||
plugin.meta = meta;
|
||||
plugin.setComponentsFromLegacyExports(pluginExports);
|
||||
|
||||
exposedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: plugin.exposedComponentConfigs || [],
|
||||
});
|
||||
addedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: plugin.addedComponentConfigs || [],
|
||||
});
|
||||
addedLinksRegistry.register({
|
||||
pluginId,
|
||||
configs: plugin.addedLinkConfigs || [],
|
||||
});
|
||||
|
||||
importedAppPlugins[pluginId] = plugin;
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
@@ -12,27 +12,25 @@ jest.mock('app/core/core', () => {
|
||||
import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data';
|
||||
|
||||
// Loaded after the `unmock` above
|
||||
import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from '../extensions/registry/setup';
|
||||
import { SystemJS } from '../loader/systemjs';
|
||||
import { importAppPlugin } from '../plugin_loader';
|
||||
|
||||
class MyCustomApp extends AppPlugin {
|
||||
initWasCalled = false;
|
||||
calledTwice = false;
|
||||
|
||||
init(meta: AppPluginMeta) {
|
||||
this.initWasCalled = true;
|
||||
this.calledTwice = this.meta === meta;
|
||||
}
|
||||
}
|
||||
jest.mock('../extensions/registry/setup');
|
||||
|
||||
describe('Load App', () => {
|
||||
const app = new MyCustomApp();
|
||||
const app = new AppPlugin();
|
||||
const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js';
|
||||
// Hook resolver for tests
|
||||
const originalResolve = SystemJS.constructor.prototype.resolve;
|
||||
SystemJS.constructor.prototype.resolve = (x: unknown) => x;
|
||||
|
||||
beforeAll(() => {
|
||||
app.init = jest.fn();
|
||||
addedComponentsRegistry.register = jest.fn();
|
||||
addedLinksRegistry.register = jest.fn();
|
||||
exposedComponentsRegistry.register = jest.fn();
|
||||
|
||||
SystemJS.set(modulePath, { plugin: app });
|
||||
});
|
||||
|
||||
@@ -55,14 +53,17 @@ describe('Load App', () => {
|
||||
const m = await SystemJS.import(modulePath);
|
||||
expect(m.plugin).toBe(app);
|
||||
|
||||
const loaded = await importAppPlugin(meta);
|
||||
expect(loaded).toBe(app);
|
||||
// Importing the app should initialise the meta
|
||||
const importedApp = await importAppPlugin(meta);
|
||||
expect(importedApp).toBe(app);
|
||||
expect(app.meta).toBe(meta);
|
||||
expect(app.initWasCalled).toBeTruthy();
|
||||
expect(app.calledTwice).toBeFalsy();
|
||||
|
||||
const again = await importAppPlugin(meta);
|
||||
expect(again).toBe(app);
|
||||
expect(app.calledTwice).toBeTruthy();
|
||||
// Importing the same app again doesn't initialise it twice
|
||||
const importedAppAgain = await importAppPlugin(meta);
|
||||
expect(importedAppAgain).toBe(app);
|
||||
expect(app.init).toHaveBeenCalledTimes(1);
|
||||
expect(addedComponentsRegistry.register).toHaveBeenCalledTimes(1);
|
||||
expect(addedLinksRegistry.register).toHaveBeenCalledTimes(1);
|
||||
expect(exposedComponentsRegistry.register).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
"edit": "",
|
||||
"export": "",
|
||||
"export-all": "",
|
||||
"loading": "",
|
||||
"view": ""
|
||||
},
|
||||
"contact-points": {
|
||||
@@ -329,6 +330,14 @@
|
||||
"save": "",
|
||||
"saving": ""
|
||||
},
|
||||
"notification-preview": {
|
||||
"alertmanager": "",
|
||||
"error": "",
|
||||
"initialized": "",
|
||||
"preview-routing": "",
|
||||
"title": "",
|
||||
"uninitialized": ""
|
||||
},
|
||||
"policies": {
|
||||
"default-policy": {
|
||||
"description": "",
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
"edit": "Edit",
|
||||
"export": "Export",
|
||||
"export-all": "Export all",
|
||||
"loading": "Loading...",
|
||||
"view": "View"
|
||||
},
|
||||
"contact-points": {
|
||||
@@ -329,6 +330,14 @@
|
||||
"save": "Save mute timing",
|
||||
"saving": "Saving mute timing"
|
||||
},
|
||||
"notification-preview": {
|
||||
"alertmanager": "Alertmanager:",
|
||||
"error": "Could not load routing preview for {{alertmanager}}",
|
||||
"initialized": "Based on the labels added, alert instances are routed to the following notification policies. Expand each notification policy below to view more details.",
|
||||
"preview-routing": "Preview routing",
|
||||
"title": "Alert instance routing preview",
|
||||
"uninitialized": "When you have your folder selected and your query and labels are configured, click \"Preview routing\" to see the results here."
|
||||
},
|
||||
"policies": {
|
||||
"default-policy": {
|
||||
"description": "All alert instances will be handled by the default policy if no other matching policies are found.",
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
"edit": "",
|
||||
"export": "",
|
||||
"export-all": "",
|
||||
"loading": "",
|
||||
"view": ""
|
||||
},
|
||||
"contact-points": {
|
||||
@@ -329,6 +330,14 @@
|
||||
"save": "",
|
||||
"saving": ""
|
||||
},
|
||||
"notification-preview": {
|
||||
"alertmanager": "",
|
||||
"error": "",
|
||||
"initialized": "",
|
||||
"preview-routing": "",
|
||||
"title": "",
|
||||
"uninitialized": ""
|
||||
},
|
||||
"policies": {
|
||||
"default-policy": {
|
||||
"description": "",
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
"edit": "",
|
||||
"export": "",
|
||||
"export-all": "",
|
||||
"loading": "",
|
||||
"view": ""
|
||||
},
|
||||
"contact-points": {
|
||||
@@ -329,6 +330,14 @@
|
||||
"save": "",
|
||||
"saving": ""
|
||||
},
|
||||
"notification-preview": {
|
||||
"alertmanager": "",
|
||||
"error": "",
|
||||
"initialized": "",
|
||||
"preview-routing": "",
|
||||
"title": "",
|
||||
"uninitialized": ""
|
||||
},
|
||||
"policies": {
|
||||
"default-policy": {
|
||||
"description": "",
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
"edit": "Ēđįŧ",
|
||||
"export": "Ēχpőřŧ",
|
||||
"export-all": "Ēχpőřŧ äľľ",
|
||||
"loading": "Ŀőäđįʼnģ...",
|
||||
"view": "Vįęŵ"
|
||||
},
|
||||
"contact-points": {
|
||||
@@ -329,6 +330,14 @@
|
||||
"save": "Ŝävę mūŧę ŧįmįʼnģ",
|
||||
"saving": "Ŝävįʼnģ mūŧę ŧįmįʼnģ"
|
||||
},
|
||||
"notification-preview": {
|
||||
"alertmanager": "Åľęřŧmäʼnäģęř:",
|
||||
"error": "Cőūľđ ʼnőŧ ľőäđ řőūŧįʼnģ přęvįęŵ ƒőř {{alertmanager}}",
|
||||
"initialized": "ßäşęđ őʼn ŧĥę ľäþęľş äđđęđ, äľęřŧ įʼnşŧäʼnčęş äřę řőūŧęđ ŧő ŧĥę ƒőľľőŵįʼnģ ʼnőŧįƒįčäŧįőʼn pőľįčįęş. Ēχpäʼnđ ęäčĥ ʼnőŧįƒįčäŧįőʼn pőľįčy þęľőŵ ŧő vįęŵ mőřę đęŧäįľş.",
|
||||
"preview-routing": "Přęvįęŵ řőūŧįʼnģ",
|
||||
"title": "Åľęřŧ įʼnşŧäʼnčę řőūŧįʼnģ přęvįęŵ",
|
||||
"uninitialized": "Ŵĥęʼn yőū ĥävę yőūř ƒőľđęř şęľęčŧęđ äʼnđ yőūř qūęřy äʼnđ ľäþęľş äřę čőʼnƒįģūřęđ, čľįčĸ \"Přęvįęŵ řőūŧįʼnģ\" ŧő şęę ŧĥę řęşūľŧş ĥęřę."
|
||||
},
|
||||
"policies": {
|
||||
"default-policy": {
|
||||
"description": "Åľľ äľęřŧ įʼnşŧäʼnčęş ŵįľľ þę ĥäʼnđľęđ þy ŧĥę đęƒäūľŧ pőľįčy įƒ ʼnő őŧĥęř mäŧčĥįʼnģ pőľįčįęş äřę ƒőūʼnđ.",
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
"edit": "",
|
||||
"export": "",
|
||||
"export-all": "",
|
||||
"loading": "",
|
||||
"view": ""
|
||||
},
|
||||
"contact-points": {
|
||||
@@ -329,6 +330,14 @@
|
||||
"save": "",
|
||||
"saving": ""
|
||||
},
|
||||
"notification-preview": {
|
||||
"alertmanager": "",
|
||||
"error": "",
|
||||
"initialized": "",
|
||||
"preview-routing": "",
|
||||
"title": "",
|
||||
"uninitialized": ""
|
||||
},
|
||||
"policies": {
|
||||
"default-policy": {
|
||||
"description": "",
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
"edit": "",
|
||||
"export": "",
|
||||
"export-all": "",
|
||||
"loading": "",
|
||||
"view": ""
|
||||
},
|
||||
"contact-points": {
|
||||
@@ -327,6 +328,14 @@
|
||||
"save": "",
|
||||
"saving": ""
|
||||
},
|
||||
"notification-preview": {
|
||||
"alertmanager": "",
|
||||
"error": "",
|
||||
"initialized": "",
|
||||
"preview-routing": "",
|
||||
"title": "",
|
||||
"uninitialized": ""
|
||||
},
|
||||
"policies": {
|
||||
"default-policy": {
|
||||
"description": "",
|
||||
|
||||
@@ -3776,11 +3776,11 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana/scenes-react@npm:5.26.0":
|
||||
version: 5.26.0
|
||||
resolution: "@grafana/scenes-react@npm:5.26.0"
|
||||
"@grafana/scenes-react@npm:5.28.1":
|
||||
version: 5.28.1
|
||||
resolution: "@grafana/scenes-react@npm:5.28.1"
|
||||
dependencies:
|
||||
"@grafana/scenes": "npm:5.26.0"
|
||||
"@grafana/scenes": "npm:5.28.1"
|
||||
lru-cache: "npm:^10.2.2"
|
||||
react-use: "npm:^17.4.0"
|
||||
peerDependencies:
|
||||
@@ -3791,13 +3791,13 @@ __metadata:
|
||||
"@grafana/ui": ^11.0.0
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
checksum: 10/57bc608902b4b07f090bd03e7148d21264796d8412072e0d887672d8d6271e2fdd0c1417b1622c8339f2411da34da8a208ce6f85914ddcc792e61c157d2ef90f
|
||||
checksum: 10/1979eebf0eea30550e9ffc1c9a766cd239914f5b79e0487ddf77d49006288c78f2e3f72bbe97782b6144902ef05e649f6f46197891811e3bbf809e6d016f0920
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/scenes@npm:5.26.0":
|
||||
version: 5.26.0
|
||||
resolution: "@grafana/scenes@npm:5.26.0"
|
||||
"@grafana/scenes@npm:5.28.1":
|
||||
version: 5.28.1
|
||||
resolution: "@grafana/scenes@npm:5.28.1"
|
||||
dependencies:
|
||||
"@floating-ui/react": "npm:^0.26.16"
|
||||
"@leeoniya/ufuzzy": "npm:^1.0.16"
|
||||
@@ -3814,7 +3814,7 @@ __metadata:
|
||||
"@grafana/ui": ">=10.4"
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
checksum: 10/b1aebcb7aafdbbd6090af75f2b2e38d3fda5789efc1c6cd586e715cc9d32210b4f2340b7ddd96fe3eadb19355dba7a1f5e067e152b4107855a98fca932c60e4a
|
||||
checksum: 10/a12ab38c048e886a880bff64c5b17ae959f34efea44bee06afa4acdc4dcde14ccafc7aa263df568f54d7d5dd165d40952f11ce0ee0c1daa3ad46ea435dbe00ad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -17670,8 +17670,8 @@ __metadata:
|
||||
"@grafana/prometheus": "workspace:*"
|
||||
"@grafana/runtime": "workspace:*"
|
||||
"@grafana/saga-icons": "workspace:*"
|
||||
"@grafana/scenes": "npm:5.26.0"
|
||||
"@grafana/scenes-react": "npm:5.26.0"
|
||||
"@grafana/scenes": "npm:5.28.1"
|
||||
"@grafana/scenes-react": "npm:5.28.1"
|
||||
"@grafana/schema": "workspace:*"
|
||||
"@grafana/sql": "workspace:*"
|
||||
"@grafana/tsconfig": "npm:^2.0.0"
|
||||
|
||||
Reference in New Issue
Block a user