diff --git a/.betterer.results b/.betterer.results index f6934188d82..14864140482 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1443,16 +1443,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], - "public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] - ], - "public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] - ], "public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 579a68b787c..475380bc006 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml new file mode 100644 index 00000000000..4c51a57b229 --- /dev/null +++ b/.github/workflows/deploy-pr-preview.yml @@ -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/ \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 2831e537237..4bea7d788e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/sources/menu.yaml b/docs/sources/menu.yaml deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/e2e/test-plugins/grafana-extensionstest-app/README.md b/e2e/test-plugins/grafana-extensionstest-app/README.md index e92e2b3f064..b952f16df66 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/README.md +++ b/e2e/test-plugins/grafana-extensionstest-app/README.md @@ -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` diff --git a/package.json b/package.json index 9db46142dfa..ee87465775a 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx index d2a5c36eefe..0e3a94b354a 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -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 = { 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; 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 ( + { + action('onChange')(val); + setArgs({ value: val }); + }} + /> + ); }, }; diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx new file mode 100644 index 00000000000..f0545b13b94 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -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(); + 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(); + 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(); + 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) => { + const [value, setValue] = React.useState([]); + return ( + { + //@ts-expect-error Don't do this for real life use cases + setValue(val ?? []); + onChange(val); + }} + /> + ); + }; + render(); + 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(); + await user.click(screen.getByRole('combobox')); + expect(await screen.findByText('d')).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index f7d8d7de3bc..a8b769ad647 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -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 extends Omit, 'value' | 'onChange'> { - value?: string | Array>; - onChange: (items?: Array>) => void; + value?: T[] | Array>; + onChange: (items?: T[]) => void; } -type MultiComboboxProps = MultiComboboxBaseProps & AutoSizeConditionals; +export type MultiComboboxProps = MultiComboboxBaseProps & AutoSizeConditionals; export const MultiCombobox = (props: MultiComboboxProps) => { - 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(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>>([]); + + const isOptionSelected = useCallback( + (item: ComboboxOption) => selectedItems.some((opt) => opt.value === item.value), + [selectedItems] + ); const [inputValue, setInputValue] = useState(''); @@ -39,9 +51,10 @@ export const MultiCombobox = (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 = (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 = (props: MultiComboboxPro
{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 (
  • (props: MultiComboboxPro > {' '} {/* Add styling with virtualization */} - - + +
  • ); })} @@ -136,3 +161,44 @@ export const MultiCombobox = (props: MultiComboboxPro
    ); }; + +function getSelectedItemsFromValue( + value: T[] | Array>, + options: Array> +) { + if (!isComboboxOptions(value)) { + const resultingItems: Array | 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( + value: T[] | Array> +): value is Array> { + return typeof value[0] === 'object'; +} + +function getComboboxOptionsValues(optionArray: Array>) { + return optionArray.map((option) => option.value); +} diff --git a/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx b/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx index 1138a60c282..d80c70e01d2 100644 --- a/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx +++ b/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx @@ -5,13 +5,16 @@ import { getComboboxStyles } from './getComboboxStyles'; interface Props { option: ComboboxOption; + id: string; } -export const OptionListItem = ({ option }: Props) => { +export const OptionListItem = ({ option, id }: Props) => { const styles = useStyles2(getComboboxStyles); return (
    - {option.label ?? option.value} + + {option.label ?? option.value} + {option.description && {option.description}}
    ); diff --git a/pkg/api/fakes.go b/pkg/api/fakes.go index 28b1a747443..1f2508812d4 100644 --- a/pkg/api/fakes.go +++ b/pkg/api/fakes.go @@ -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, diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 905feddef0f..fffadca6a2d 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -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 { diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 14684b3bc55..0160fc1112b 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -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 { diff --git a/pkg/plugins/manager/fakes/fakes.go b/pkg/plugins/manager/fakes/fakes.go index 8120b460b79..d06e81ceaa3 100644 --- a/pkg/plugins/manager/fakes/fakes.go +++ b/pkg/plugins/manager/fakes/fakes.go @@ -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) } diff --git a/pkg/plugins/manager/installer.go b/pkg/plugins/manager/installer.go index 227d8a35450..cd445383d2e 100644 --- a/pkg/plugins/manager/installer.go +++ b/pkg/plugins/manager/installer.go @@ -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 { diff --git a/pkg/plugins/manager/installer_test.go b/pkg/plugins/manager/installer_test.go index bb4564c7d25..d89a63e3c5b 100644 --- a/pkg/plugins/manager/installer_test.go +++ b/pkg/plugins/manager/installer_test.go @@ -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, "") } diff --git a/pkg/services/pluginsintegration/plugininstaller/service.go b/pkg/services/pluginsintegration/plugininstaller/service.go index b8d0b4e37f9..d49fe12b0da 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service.go +++ b/pkg/services/pluginsintegration/plugininstaller/service.go @@ -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 diff --git a/pkg/services/pluginsintegration/plugininstaller/service_test.go b/pkg/services/pluginsintegration/plugininstaller/service_test.go index 6ec4ec516ec..d2b8668560f 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service_test.go +++ b/pkg/services/pluginsintegration/plugininstaller/service_test.go @@ -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) } }) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9dcd3bcde78..f0039db5ae2 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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 diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index d3ba8384a40..8ba9fd134b0 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -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 { diff --git a/pkg/setting/setting_plugins_test.go b/pkg/setting/setting_plugins_test.go index de1e45d9f42..9ddac396a76 100644 --- a/pkg/setting/setting_plugins_test.go +++ b/pkg/setting/setting_plugins_test.go @@ -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 { diff --git a/pkg/tsdb/loki/frame.go b/pkg/tsdb/loki/frame.go index 8655b746aca..97fa32840e0 100644 --- a/pkg/tsdb/loki/frame.go +++ b/pkg/tsdb/loki/frame.go @@ -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) diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index db36a7624b1..8715f98e4d5 100644 --- a/pkg/tsdb/loki/framing_test.go +++ b/pkg/tsdb/loki/framing_test.go @@ -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) { diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index 099e9b344f3..4b8563e5405 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -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 diff --git a/pkg/tsdb/loki/test_non_dataplane/warning.golden.jsonc b/pkg/tsdb/loki/test_non_dataplane/warning.golden.jsonc new file mode 100644 index 00000000000..68060bb1d73 --- /dev/null +++ b/pkg/tsdb/loki/test_non_dataplane/warning.golden.jsonc @@ -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": [] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/loki/test_non_dataplane/warning.json b/pkg/tsdb/loki/test_non_dataplane/warning.json new file mode 100644 index 00000000000..f8d67e07231 --- /dev/null +++ b/pkg/tsdb/loki/test_non_dataplane/warning.json @@ -0,0 +1,4 @@ +{ + "status": "success", + "warnings": ["Some logs may have been dropped by Adaptive Logs sampling"] +} diff --git a/pkg/tsdb/loki/testdata_dataplane/warning.golden.jsonc b/pkg/tsdb/loki/testdata_dataplane/warning.golden.jsonc new file mode 100644 index 00000000000..68060bb1d73 --- /dev/null +++ b/pkg/tsdb/loki/testdata_dataplane/warning.golden.jsonc @@ -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": [] + } + } + ] +} \ No newline at end of file diff --git a/pkg/tsdb/loki/testdata_dataplane/warning.json b/pkg/tsdb/loki/testdata_dataplane/warning.json new file mode 100644 index 00000000000..f8d67e07231 --- /dev/null +++ b/pkg/tsdb/loki/testdata_dataplane/warning.json @@ -0,0 +1,4 @@ +{ + "status": "success", + "warnings": ["Some logs may have been dropped by Adaptive Logs sampling"] +} diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index a9de5a23ea7..570d2d97756 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -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 { - +
    {config.featureToggles.appSidecar ? ( diff --git a/public/app/app.ts b/public/app/app.ts index dad577603d8..556952c0775 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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); diff --git a/public/app/features/alerting/unified/NotificationPolicies.test.tsx b/public/app/features/alerting/unified/NotificationPolicies.test.tsx index 72001d0e596..82da6db5e59 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.test.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.test.tsx @@ -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 = { diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx index 89f4e72354c..8444c3169f7 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx @@ -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(); + const { user } = render( + + ); - 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(); - await waitFor(() => { - expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); - }); + const { user } = render( + + ); - 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(); + const { user } = render( + + ); 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(); + const { user } = render( + + ); 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( { { 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( { { 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( { 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( { 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( { 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( { 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( - Alert instance routing preview + + Alert instance routing preview + {isLoading && previewUninitialized && ( - Loading... + Loading... )} {previewUninitialized ? ( - When you have your folder selected and your query and labels are configured, click "Preview - routing" to see the results here. + + When you have your folder selected and your query and labels are configured, click "Preview + routing" to see the results here. + ) : ( - Based on the labels added, alert instances are routed to the following notification policies. Expand each - notification policy below to view more details. + + Based on the labels added, alert instances are routed to the following notification policies. Expand + each notification policy below to view more details. + )} {!isLoading && !previewUninitialized && potentialInstances.length > 0 && ( diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx index f437f80df74..28d35e9e056 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx @@ -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 ( - - {error.message} + + {stringifyErrorLike(error)} ); } @@ -46,8 +51,7 @@ function NotificationPreviewByAlertManager({
    - {' '} - Alertmanager: + Alertmanager: {alertManagerSource.name}
    diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts index a8df723da12..b6dcfb3e746 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts @@ -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 = rootRoute + const routesByIdMap = rootRoute ? getRoutesByIdMap(computeInheritedTree(rootRoute)) - : new Map(); + : new Map(); - // 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(); + } + + // create map for receivers to be get by name + return contactPoints.reduce((map, receiver) => { return map.set(receiver.name, receiver); - }, new Map()) ?? new Map(); + }, new Map()); + }, [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, }; }; diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 81e78c10f52..5d57be50076 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -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({ - 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 { diff --git a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts index 613ad3e114c..9babb1332bf 100644 --- a/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts +++ b/public/app/features/alerting/unified/mocks/server/entities/k8s/routingtrees.ts @@ -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 }); }); } diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts index 84588563a0c..461ef3810ce 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts @@ -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( - 'ReceiverList', - mappedReceivers -); + return getK8sResponse( + '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); diff --git a/public/app/features/alerting/unified/mocks/server/handlers/notifications.ts b/public/app/features/alerting/unified/mocks/server/handlers/notifications.ts index 2e08a15c640..cf2ca614f15 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/notifications.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/notifications.ts @@ -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()]; diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index 805a780be3c..d9b3bd9d77d 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -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/*`, diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts index 05fe0f21b7a..6c2fd1e6a5f 100644 --- a/public/app/features/plugins/extensions/registry/setup.ts +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -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(), +}); diff --git a/public/app/features/plugins/extensions/useLoadAppPlugins.tsx b/public/app/features/plugins/extensions/useLoadAppPlugins.tsx new file mode 100644 index 00000000000..ce013713906 --- /dev/null +++ b/public/app/features/plugins/extensions/useLoadAppPlugins.tsx @@ -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 }; +} diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 007414ab4dc..9491700e217 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -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); diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index ba057721f26..5e079beea93 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -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(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(id: string): UsePl isLoading: false, component: wrapWithPluginContext(registryItem.pluginId, registryItem.component, componentLog), }; - }, [id, pluginContext, registryState]); + }, [id, pluginContext, registryState, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index e873e6e81a7..5528525bf47 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -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(); diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index 3147a8441d9..e583a69141d 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -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({ 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({ }; } + if (isLoadingAppPlugins) { + return { + isLoading: true, + components: [], + }; + } + for (const registryItem of registryState?.[extensionPointId] ?? []) { const { pluginId } = registryItem; @@ -65,5 +74,5 @@ export function usePluginComponents({ isLoading: false, components, }; - }, [extensionPointId, limitPerPlugin, pluginContext, registryState]); + }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 71254272588..203b55391a0 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -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', () => { diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index 5a7050062e2..2dfc57e289e 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -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, + ]); }; } diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index c9d6ca2af39..dc9729560d9 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -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 = { diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 1a7205e347a..58efcc4e3e6 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -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]); } diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index b78afe339ba..606661af2a3 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -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']); + }); + }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index b4819a3f1b8..bb339ffcf38 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -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)); + }); +}; diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index b4d7489f80f..8b7a85759be 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -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(); +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 { - 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); } } diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index cf65adc4811..aa20ae5881f 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -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 { +}: PluginImportInfo): Promise { if (version) { registerPluginInCache({ path, version, loadingStrategy }); } @@ -166,21 +169,44 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { - const isAngular = meta.angular?.detected ?? meta.angularDetected; - const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch; - return importPluginModule({ +// Only successfully loaded plugins are cached +const importedAppPlugins: Record = {}; + +export async function importAppPlugin(meta: PluginMeta): Promise { + 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; } diff --git a/public/app/features/plugins/tests/plugin_loader.test.ts b/public/app/features/plugins/tests/plugin_loader.test.ts index e81e833966a..d41caa1dbe1 100644 --- a/public/app/features/plugins/tests/plugin_loader.test.ts +++ b/public/app/features/plugins/tests/plugin_loader.test.ts @@ -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); }); }); diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index f4d7eb31706..ae56080d8ca 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -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": "", diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index ff385eca064..3aca6723ded 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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.", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 85d1c76449c..fedba1f1abf 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -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": "", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index d0345b0674f..86256c0ee9e 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -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": "", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index e642ff56ecb..71c193eb5b1 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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đ.", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index b43369d5648..712456d7217 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -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": "", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index de69d235d0a..0518af6ce7c 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -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": "", diff --git a/yarn.lock b/yarn.lock index 25c97c76c0c..a09b6ed506e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"