Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley
2024-12-02 07:34:54 +03:00
64 changed files with 1569 additions and 456 deletions
-10
View File
@@ -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"],
+1
View File
@@ -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
+22
View File
@@ -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/
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
+74 -45
View File
@@ -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 {
+75 -5
View File
@@ -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)
}
})
}
+1
View File
@@ -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
+10 -5
View File
@@ -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 {
+13 -3
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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) {
+5 -1
View File
@@ -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"]
}
+2 -1
View File
@@ -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
View File
@@ -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 = {
@@ -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
@@ -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 &quot;Preview
routing&quot; 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 &quot;Preview
routing&quot; 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 && (
@@ -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>
@@ -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));
});
};
+15 -56
View File
@@ -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);
}
}
+46 -20
View File
@@ -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);
});
});
+9
View File
@@ -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": "",
+9
View File
@@ -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.",
+9
View File
@@ -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": "",
+9
View File
@@ -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đ.",
+9
View File
@@ -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": "",
+9
View File
@@ -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": "",
+11 -11
View File
@@ -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"