Merge remote-tracking branch 'origin/main' into drclau/unistor/replace-authenticators-3

This commit is contained in:
gamab 2024-10-04 13:07:48 +02:00
commit c591631135
No known key found for this signature in database
GPG Key ID: 88D8810B587562C1
124 changed files with 4518 additions and 541 deletions

View File

@ -3049,19 +3049,12 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx:5381": [
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/dashboard/components/Inspector/PanelInspector.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]

View File

@ -4,7 +4,7 @@ go 1.23.1
require (
github.com/grafana/grafana-app-sdk v0.19.0
k8s.io/apimachinery v0.31.0
k8s.io/apimachinery v0.31.1
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340
)

View File

@ -101,8 +101,8 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=

View File

@ -1740,7 +1740,7 @@ hide_angular_deprecation =
# Comma separated list of plugin ids for which environment variables should be forwarded. Used only when feature flag pluginsSkipHostEnvVars is enabled.
forward_host_env_vars =
# Comma separated list of plugin ids to install as part of the startup process.
preinstall =
preinstall = grafana-lokiexplore-app
# Controls whether preinstall plugins asynchronously (in the background) or synchronously (blocking). Useful when preinstalled plugins are used with provisioning.
preinstall_async = true
# Disables preinstall feature. It has the same effect as setting preinstall to an empty list.

View File

@ -17,3 +17,7 @@ apps:
org_id: 1
org_name: Main Org.
disabled: false
- type: grafana-extensionexample3-app
org_id: 1
org_name: Main Org.
disabled: false

View File

@ -48,6 +48,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes |
| `dashgpt` | Enable AI powered features in dashboards | Yes |
| `alertingInsights` | Show the new alerting insights landing page | Yes |
| `externalServiceAccounts` | Automatic service account and token setup for plugins | Yes |
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
| `formatString` | Enable format string transformer | Yes |
| `transformationsVariableSupport` | Allows using variables in transformations | Yes |
@ -100,7 +101,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel |
| `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior |
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `teamHttpHeaders` | Enables LBAC for datasources to apply LogQL filtering of logs to the client requests for users in teams |
| `pdfTables` | Enables generating table data as PDF in reporting |

View File

@ -0,0 +1,121 @@
---
aliases:
- ../../../auth/enhanced-ldap/
description: Learn about configuring LDAP authentication in Grafana using the Grafana UI.
labels:
products:
- cloud
- enterprise
- oss
menuTitle: LDAP user interface
title: Configure LDAP authentication using the Grafana user interface
weight: 300
---
# Configure LDAP authentication using the Grafana user interface
This page explains how to configure LDAP authentication in Grafana using the Grafana user interface. For more detailed information about configuring LDAP authentication using the configuration file, refer to [LDAP authentication]({{< relref "../ldap" >}}).
Benefits of using the Grafana user interface to configure LDAP authentication include:
- There is no need to edit the configuration file manually.
- Quickly test the connection to the LDAP server.
- There is no need to restart Grafana after making changes.
{{% admonition type="note" %}}
Any configuration changes made through the Grafana user interface (UI) will take precedence over settings specified in the Grafana configuration file or through environment variables. If you modify any configuration settings in the UI, they will override any corresponding settings set via environment variables or defined in the configuration file.
{{% /admonition %}}
## Before you begin
Prerequisites:
- Knowledge of LDAP authentication and how it works.
- Grafana instance v11.3.0 or later.
- Permissions `settings:read` and `settings:write` with `settings:auth.ldap:*` scope.
- This feature requires the `ssoSettingsLDAP` feature toggle to be enabled.
## Steps to configure LDAP authentication
Sign in to Grafana and navigate to **Administration > Authentication > LDAP**.
### 1. Complete mandatory fields
The mandatory fields have an asterisk (**\***) next to them. Complete the following fields:
1. **Server host**: Host name or IP address of the LDAP server.
1. **Search filter**: The LDAP search filter finds entries within the directory.
1. **Search base DNS**: List of base DNs to search through.
### 2. Complete optional fields
Complete the optional fields as needed:
1. **Bind DN**: Distinguished name (DN) of the user to bind to.
1. **Bind password**: Password for the server.
### 3. Advanced settings
Click the **Edit** button in the **Advanced settings** section to configure the following settings:
#### 1. Miscellaneous settings
Complementary settings for LDAP authentication.
1. **Allow sign-up**: Allows new users to register upon logging in.
1. **Port**: Port number of the LDAP server. The default is 389.
1. **Timeout**: Time in seconds to wait for a response from the LDAP server.
#### 2. Attributes
Attributes used to map LDAP user assertion to Grafana user attributes.
1. **Name**: Name of the assertion attribute to map to the Grafana user name.
1. **Surname**: Name of the assertion attribute to map to the Grafana user surname.
1. **Username**: Name of the assertion attribute to map to the Grafana user username.
1. **Member Of**: Name of the assertion attribute to map to the Grafana user membership.
1. **Email**: Name of the assertion attribute to map to the Grafana user email.
#### 3. Group mapping
Map LDAP groups to Grafana roles.
1. **Skip organization role sync**: This option avoids syncing organization roles. It is useful when you want to manage roles manually.
1. **Group search filter**: The LDAP search filter finds groups within the directory.
1. **Group search base DNS**: List of base DNS to specify the matching groups' locations.
1. **Group name attribute**: Identifies users within group entries.
1. **Manage group mappings**:
When managing group mappings, the following fields will become available. To add a new group mapping, click the **Add group mapping** button.
1. **Add a group DN mapping**: The name of the key used to extract the ID token.
1. **Add an organization role mapping**: Select the Basic Role mapped to this group.
1. **Add the organization ID membership mapping**: Map the group to an organization ID.
1. **Define Grafana Admin membership**: Enable Grafana Admin privileges to the group.
#### 4. Extra security settings
Additional security settings options for LDAP authentication.
1. **Enable SSL**: This option will enable SSL to connect to the LDAP server.
1. **Start TLS**: Use StartTLS to secure the connection to the LDAP server.
1. **Min TLS version**: Choose the minimum TLS version to use. TLS1.2 or TLS1.3
1. **TLS ciphers**: List the ciphers to use for the connection. For a complete list of ciphers, refer to the [Cipher Go library](https://go.dev/src/crypto/tls/cipher_suites.go).
1. **Encryption key and certificate provision specification**:
This section allows you to specify the key and certificate for the LDAP server. You can provide the key and certificate in two ways: **base-64** encoded or **path to files**.
1. **Base-64 encoded certificate**:
All values used in this section must be base-64 encoded.
1. **Root CA certificate content**: List of root CA certificates.
1. **Client certificate content**: Client certificate content.
1. **Client key content**: Client key content.
1. **Path to files**:
Path in the file system to the key and certificate files
1. **Root CA certificate path**: Path to the root CA certificate.
1. **Client certificate path**: Path to the client certificate.
1. **Client key path**: Path to the client key.
### 4. Persisting the configuration
Once you have configured the LDAP settings, click **Save** to persist the configuration.
If you want to delete all the changes made through the UI and revert to the configuration file settings, click the three dots menu icon and click **Reset to default values**.

View File

@ -9,7 +9,7 @@ type ReusableComponentProps = {
export function AddedComponents() {
const { components } = usePluginComponents<ReusableComponentProps>({
extensionPointId: 'plugins/grafana-extensionexample2-app/addComponent/v1',
extensionPointId: 'plugins/grafana-extensionstest-app/addComponent/v1',
});
return (

View File

@ -16,7 +16,7 @@ type ReusableComponentProps = {
export function LegacyGetters() {
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1';
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
const context: AppExtensionContext = {};
const { extensions } = getPluginExtensions({

View File

@ -16,7 +16,7 @@ type ReusableComponentProps = {
export function LegacyHooks() {
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1';
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
const context: AppExtensionContext = {};
const { extensions } = usePluginExtensions({

View File

@ -60,8 +60,43 @@
"defaultNav": false
}
],
"extensions": {
"addedLinks": [
{
"targets": ["grafana/dashboard/panel/menu"],
"title": "Open from time series or pie charts (path)",
"description": "This link will only be visible on time series and pie charts"
},
{
"targets": ["grafana/dashboard/panel/menu"],
"title": "Open from time series or pie charts (onClick)",
"description": "This link will only be visible on time series and pie charts"
}
],
"extensionPoints": [
{
"id": "plugins/grafana-extensionstest-app/use-plugin-links/v1",
"title": "Extension point - links"
},
{
"id": "plugins/grafana-extensionstest-app/addComponent/v1",
"title": "Extension point - components"
},
{
"id": "plugins/grafana-extensionstest-app/actions",
"title": "Legacy extension point - usePluginExtensions() and usePluginLinkExtensions()"
},
{
"id": "plugins/grafana-extensionstest-app/configure-extension-component/v1",
"title": "Legacy extension point - usePluginComponentExtensions()"
}
]
},
"dependencies": {
"grafanaDependency": ">=10.4.0",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": ["grafana-extensionexample1-app/reusable-component/v1"]
}
}
}

View File

@ -28,6 +28,30 @@
"defaultNav": false
}
],
"extensions": {
"exposedComponents": [
{
"id": "grafana-extensionexample1-app/reusable-component/v1",
"title": "Exposed component",
"description": "A component that can be reused by other app plugins."
}
],
"addedLinks": [
{
"targets": [
"plugins/grafana-extensionstest-app/actions",
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
],
"title": "Go to A",
"description": "Navigating to pluging A"
},
{
"targets": ["plugins/grafana-extensionstest-app/use-plugin-links/v1"],
"title": "Basic link",
"description": "..."
}
]
},
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []

View File

@ -19,13 +19,13 @@ export const plugin = new AppPlugin<{}>()
},
})
.configureExtensionComponent({
extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1',
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
title: 'Configure extension component from B',
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
})
.addComponent<{ name: string }>({
targets: 'plugins/grafana-extensionexample2-app/addComponent/v1',
targets: 'plugins/grafana-extensionstest-app/addComponent/v1',
title: 'Added component from B',
description: 'A component that can be reused by other app plugins. Shared using addComponent api',
component: ({ name }: { name: string }) => (

View File

@ -18,6 +18,30 @@
"version": "%VERSION%",
"updated": "%TODAY%"
},
"extensions": {
"addedLinks": [
{
"targets": [
"plugins/grafana-extensionstest-app/actions",
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
],
"title": "Open from B",
"description": "Open a modal from plugin B"
}
],
"addedComponents": [
{
"targets": ["plugins/grafana-extensionstest-app/configure-extension-component/v1"],
"title": "Configure extension component from B",
"description": "A component that can be reused by other app plugins. Shared using configureExtensionComponent api"
},
{
"targets": ["plugins/grafana-extensionstest-app/addComponent/v1"],
"title": "Added component from B",
"description": "A component that can be reused by other app plugins. Shared using addComponent api"
}
]
},
"includes": [
{
"type": "page",

View File

@ -0,0 +1,24 @@
import { usePluginExtensions, usePluginLinks } from '@grafana/runtime';
import { Stack } from '@grafana/ui';
import { testIds } from '../../../../testIds';
import { ActionButton } from '../../../../components/ActionButton';
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
export function AddedLinks() {
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
const { extensions } = usePluginExtensions({ extensionPointId: LINKS_EXTENSION_POINT_ID });
return (
<Stack direction={'column'} gap={4}>
<section data-testid={testIds.appC.section1}>
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
<ActionButton extensions={links} />
</section>
<section data-testid={testIds.appC.section2}>
<h3>Link extensions defined with addLink and retrieved using usePluginExtensions</h3>
<ActionButton extensions={extensions} />
</section>
</Stack>
);
}

View File

@ -0,0 +1,15 @@
import * as React from 'react';
import { AppRootProps } from '@grafana/data';
import { AddedLinks } from './AddedLinks';
import { testIds } from '../../../../testIds';
export class App extends React.PureComponent<AppRootProps> {
render() {
return (
<div data-testid={testIds.appC.container} className="page-container">
Hello Grafana!
<AddedLinks></AddedLinks>
</div>
);
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,44 @@
import { AppPlugin } from '@grafana/data';
import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
import { testIds } from '../../testIds';
import { App } from '../../components/App';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink({
title: 'configureExtensionLink (where meta data is missing)',
description: 'Open a modal from plugin B',
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
onClick: (_, { openModal }) => {
openModal({
title: 'Modal from app B',
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
});
},
})
.configureExtensionComponent({
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
title: 'configureExtensionComponent (where meta data is missing)',
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
})
.addComponent<{ name: string }>({
targets: ['plugins/grafana-extensionstest-app/addComponent/v1'],
title: 'Added component (where meta data is missing)',
description: '.',
component: ({ name }: { name: string }) => (
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
),
})
.addLink({
title: 'Added link (where meta data is missing)',
description: '.',
targets: [LINKS_EXTENSION_POINT_ID],
onClick: (_, { openModal }) => {
openModal({
title: 'Modal from app C',
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
});
},
});

View File

@ -0,0 +1,35 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "D App",
"id": "grafana-extensionexample3-app",
"preload": true,
"info": {
"keywords": ["app"],
"description": "Will extend root app with ui extensions",
"author": {
"name": "grafana"
},
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"screenshots": [],
"version": "%VERSION%",
"updated": "%TODAY%"
},
"includes": [
{
"type": "page",
"name": "Default",
"path": "/a/grafana-extensionexample3-app",
"role": "Admin",
"addToNav": false,
"defaultNav": false
}
],
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []
}
}

View File

@ -16,6 +16,11 @@ export const testIds = {
reusableAddedComponent: 'b-app-add-component',
exposedComponent: 'b-app-exposed-component',
},
appC: {
container: 'c-app-body',
section1: 'use-plugin-links',
section2: 'use-plugin-extensions',
},
legacyGettersPage: {
container: 'data-testid pg-legacy-getters-container',
section1: 'get-plugin-extensions',

View File

@ -2,6 +2,7 @@ import { test, expect } from '@grafana/plugin-e2e';
import { testIds } from '../../testIds';
import pluginJson from '../../plugin.json';
import testApp3pluginJson from '../../plugins/grafana-extensionexample3-app/plugin.json';
test.describe('usePluginExtensions + configureExtensionLink', () => {
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
@ -12,6 +13,17 @@ test.describe('usePluginExtensions + configureExtensionLink', () => {
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
test('should not display extensions that have not been declared in plugin.json when in development mode', async ({
page,
}) => {
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.legacyHooksPage.section1);
await section.getByTestId(testIds.actions.button).click();
await expect(
page.getByTestId(testIds.container).getByText('configureExtensionLink (where meta data is missing)')
).not.toBeVisible();
});
});
test.describe('usePluginExtensions + configureExtensionComponent', () => {
@ -43,3 +55,13 @@ test.describe('usePluginComponentExtensions + configureExtensionComponent', () =
).toHaveText('Hello World!');
});
});
test.describe('usePluginExtensions + addLink', () => {
test('should not display extensions in case extension point has not been declared in plugin json (dev mode only)', async ({
page,
}) => {
await page.goto(`/a/${testApp3pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.appC.section2);
await expect(section.getByTestId(testIds.actions.button)).not.toBeVisible();
});
});

View File

@ -1,6 +1,7 @@
import { test, expect } from '@grafana/plugin-e2e';
import pluginJson from '../plugin.json';
import testApp3pluginJson from '../plugins/grafana-extensionexample3-app/plugin.json';
import { testIds } from '../testIds';
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
@ -28,3 +29,11 @@ test('should extend main app with basic link extension from app A', async ({ pag
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
test('should not display any extensions when extension point is not declared in plugin json when in development mode', async ({
page,
}) => {
await page.goto(`/a/${testApp3pluginJson.id}`);
const container = await page.getByTestId(testIds.appC.section1);
await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible();
});

18
go.mod
View File

@ -111,7 +111,7 @@ require (
github.com/huandu/xstrings v1.3.3 // @grafana/partner-datasources
github.com/influxdata/influxdb-client-go/v2 v2.13.0 // @grafana/observability-metrics
github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad
github.com/jmespath/go-jmespath v0.4.0 // @grafana/grafana-backend-group
github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group
github.com/jmoiron/sqlx v1.3.5 // @grafana/grafana-backend-group
github.com/json-iterator/go v1.1.12 // @grafana/grafana-backend-group
github.com/lib/pq v1.10.9 // @grafana/grafana-backend-group
@ -187,13 +187,13 @@ require (
gopkg.in/ini.v1 v1.67.0 // @grafana/alerting-backend
gopkg.in/mail.v2 v2.3.1 // @grafana/grafana-backend-group
gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-backend
k8s.io/api v0.31.0 // @grafana/grafana-app-platform-squad
k8s.io/apimachinery v0.31.0 // @grafana/grafana-app-platform-squad
k8s.io/apiserver v0.31.0 // @grafana/grafana-app-platform-squad
k8s.io/client-go v0.31.0 // @grafana/grafana-app-platform-squad
k8s.io/component-base v0.31.0 // @grafana/grafana-app-platform-squad
k8s.io/api v0.31.1 // @grafana/grafana-app-platform-squad
k8s.io/apimachinery v0.31.1 // @grafana/grafana-app-platform-squad
k8s.io/apiserver v0.31.1 // @grafana/grafana-app-platform-squad
k8s.io/client-go v0.31.1 // @grafana/grafana-app-platform-squad
k8s.io/component-base v0.31.1 // @grafana/grafana-app-platform-squad
k8s.io/klog/v2 v2.130.1 // @grafana/grafana-app-platform-squad
k8s.io/kube-aggregator v0.31.0 // @grafana/grafana-app-platform-squad
k8s.io/kube-aggregator v0.31.1 // @grafana/grafana-app-platform-squad
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // @grafana/grafana-app-platform-squad
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // @grafana/partner-datasources
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // @grafana-app-platform-squad
@ -447,7 +447,7 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/kms v0.31.0 // indirect
k8s.io/kms v0.31.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.41.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
@ -478,6 +478,8 @@ require (
github.com/grafana/grafana/apps/playlist v0.0.0-20240917082838-e2bce38a7990 // @grafana/grafana-app-platform-squad
)
require github.com/jmespath-community/go-jmespath v1.1.1 // @grafana/identity-access-team
require (
cloud.google.com/go/longrunning v0.5.12 // indirect
github.com/at-wat/mqtt-go v0.19.4 // indirect

30
go.sum
View File

@ -2524,6 +2524,8 @@ github.com/jhump/protoreflect v1.11.0/go.mod h1:U7aMIjN0NWq9swDP7xDdoMfRHb35uiuT
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
github.com/jmattheis/goverter v1.4.0/go.mod h1:iVIl/4qItWjWj2g3vjouGoYensJbRqDHpzlEVMHHFeY=
github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4=
github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
@ -4547,18 +4549,18 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80=
k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo=
k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU=
k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY=
k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c=
k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM=
k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0=
k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8=
k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU=
k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs=
k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo=
k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8=
k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w=
k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E=
k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70/go.mod h1:VH3AT8AaQOqiGjMF9p0/IM1Dj+82ZwjfxUP1IxaHE+8=
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
@ -4569,10 +4571,10 @@ k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kms v0.31.0 h1:KchILPfB1ZE+ka7223mpU5zeFNkmb45jl7RHnlImUaI=
k8s.io/kms v0.31.0/go.mod h1:OZKwl1fan3n3N5FFxnW5C4V3ygrah/3YXeJWS3O6+94=
k8s.io/kube-aggregator v0.31.0 h1:3DqSpmqHF8rey7fY+qYXLJms0tYPhxrgWvjpnKVnS0Y=
k8s.io/kube-aggregator v0.31.0/go.mod h1:Fa+OVSpMQC7zbTTz7/QG7FXe9jZ8usuJQej5sMdCrkM=
k8s.io/kms v0.31.1 h1:cGLyV3cIwb0ovpP/jtyIe2mEuQ/MkbhmeBF2IYCA9Io=
k8s.io/kms v0.31.1/go.mod h1:OZKwl1fan3n3N5FFxnW5C4V3ygrah/3YXeJWS3O6+94=
k8s.io/kube-aggregator v0.31.1 h1:vrYBTTs3xMrpiEsmBjsLETZE9uuX67oQ8B3i1BFfMPw=
k8s.io/kube-aggregator v0.31.1/go.mod h1:+aW4NX50uneozN+BtoCxI4g7ND922p8Wy3tWKFDiWVk=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=

View File

@ -495,8 +495,6 @@ github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8
github.com/ettle/strcase v0.1.1 h1:htFueZyVeE1XNnMEfbqp5r67qAN/4r6ya1ysq8Q+Zcw=
github.com/expr-lang/expr v1.16.2 h1:JvMnzUs3LeVHBvGFcXYmXo+Q6DPDmzrlcSBO6Wy3w4s=
github.com/expr-lang/expr v1.16.2/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
@ -534,6 +532,7 @@ github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU=
github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg=
github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 h1:NxXI5pTAtpEaU49bpLpQoDsu1zrteW/vxzTz8Cd2UAs=
github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI=
github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
@ -550,8 +549,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
github.com/goccmack/gocc v0.0.0-20230228185258-2292f9e40198 h1:FSii2UQeSLngl3jFoR4tUKZLprO7qUlh/TKKticc0BM=
github.com/goccmack/gocc v0.0.0-20230228185258-2292f9e40198/go.mod h1:DTh/Y2+NbnOVVoypCCQrovMPDKUGp4yZpSbWg5D0XIM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54=
github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4 h1:vF83LI8tAakwEwvWZtrIEx7pOySacl2TOxx6eXk4ePo=
github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA=
@ -595,8 +592,6 @@ github.com/grafana/alerting v0.0.0-20240926233713-446ddd356f8d/go.mod h1:GMLi6d0
github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45 h1:AJKOtDKAOg8XNFnIZSmqqqutoTSxVlRs6vekL2p2KEY=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45/go.mod h1:01sXtHoRwI8W324IPAzuxDFOmALqYLCOhvSC2fUHWXc=
github.com/grafana/grafana-aws-sdk v0.31.3 h1:QlgIwyyozYYQf/dL279Baicyax+SuE99Set5chMnq1s=
github.com/grafana/grafana-aws-sdk v0.31.3/go.mod h1:5nt5Gmp6+GyM+Jr7xsXKJtbizxbYXXLmEac6kw5paQI=
github.com/grafana/pyroscope-go/godeltaprof v0.1.6/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE=
github.com/grafana/thema v0.0.0-20230511182720-3146087fcc26 h1:HX927q4X1n451pnGb8U0wq74i8PCzuxVjzv7TyD10kc=
github.com/grafana/thema v0.0.0-20230511182720-3146087fcc26/go.mod h1:Pn9nfzCk7nV0mvNgwusgCjCROZP6nm4GpwTnmEhLT24=
@ -675,8 +670,6 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46 h1:veS9QfglfvqAw
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s=
github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs=
github.com/knadh/koanf/v2 v2.1.0 h1:eh4QmHHBuU8BybfIJ8mB8K8gsGCD/AUQTdwGq/GzId8=
@ -710,8 +703,6 @@ github.com/matryer/moq v0.3.3 h1:pScMH9VyrdT4S93yiLpVyU8rCDqGQr24uOyBxmktG5Q=
github.com/matryer/moq v0.3.3/go.mod h1:RJ75ZZZD71hejp39j4crZLsEDszGk6iH4v4YsWFKH4s=
github.com/matryer/moq v0.5.0 h1:h2PJUYjZSiyEahzVogDRmrgL9Bsx9xYAl8l+LPfmwL8=
github.com/matryer/moq v0.5.0/go.mod h1:39GTnrD0mVWHPvWdYj5ki/lxfhLQEtHcLh+tWoYF/iE=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
@ -742,6 +733,7 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1 h1:dOYG7LS/WK00RWZc8XGgcUTlTxpp3mKhdR2Q9z9HbXM=
github.com/oklog/oklog v0.3.2 h1:wVfs8F+in6nTBMkA7CbRw+zZMIB7nNM825cM1wuzoTk=
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/open-telemetry/opentelemetry-collector-contrib/exporter/kafkaexporter v0.97.0 h1:8GH8y3Cq54Ey6He9tyhcVYLfG4TEs/7pp3s6934zNKA=
github.com/open-telemetry/opentelemetry-collector-contrib/exporter/kafkaexporter v0.97.0/go.mod h1:QBHXt+tHds39B4xGyBkbOx2TST+p8JLWBiXbKKAhNss=
@ -830,7 +822,6 @@ github.com/relvacode/iso8601 v1.4.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH
github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s=
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4 h1:BN/Nyn2nWMoqGRA7G7paDNDqTXE30mXGqzzybrfo05w=
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY=
@ -855,6 +846,7 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJGQTUpVfEMJJd4nRFXogbc=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/stoewer/parquet-cli v0.0.7 h1:rhdZODIbyMS3twr4OM3am8BPPT5pbfMcHLH93whDM5o=
github.com/stoewer/parquet-cli v0.0.7/go.mod h1:bskxHdj8q3H1EmfuCqjViFoeO3NEvs5lzZAQvI8Nfjk=
github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo=
@ -1007,6 +999,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.23.1 h1:ZqR
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.23.1/go.mod h1:D7ynngPWlGJrqyGSDOdscuv7uqttfCE3jcBvffDv9y4=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.23.1 h1:q/Nj5/2TZRIt6PderQ9oU0M00fzoe8UZuINGw6ETGTw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.23.1/go.mod h1:DTE9yAu6r08jU3xa68GiSeI7oRcSEQ2RpKbbQGO+dWM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw=
go.opentelemetry.io/otel/exporters/prometheus v0.46.0 h1:I8WIFXR351FoLJYuloU4EgXbtNX2URfU/85pUPheIEQ=
go.opentelemetry.io/otel/exporters/prometheus v0.46.0/go.mod h1:ztwVUHe5DTR/1v7PeuGRnU5Bbd4QKYwApWmuutKsJSs=
@ -1016,6 +1010,7 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDO
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/sdk/metric v1.26.0 h1:cWSks5tfriHPdWFnl+qpX3P681aAYqlZHcAyHw5aU9Y=
go.opentelemetry.io/otel/sdk/metric v1.26.0/go.mod h1:ClMFFknnThJCksebJwz7KIyEDHO+nTB6gK8obLy8RyE=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
@ -1024,11 +1019,13 @@ go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4=
golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0=
@ -1036,7 +1033,9 @@ golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhp
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
@ -1052,6 +1051,7 @@ golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
@ -1063,6 +1063,7 @@ gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPj
gonum.org/v1/plot v0.10.1 h1:dnifSs43YJuNMDzB7v8wV64O4ABBHReuAVAoBxqBqS4=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/genproto v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:mCr1K1c8kX+1iSBREvU3Juo11CB+QOEWxbRS01wWl5M=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo=
google.golang.org/genproto/googleapis/api v0.0.0-20240725223205-93522f1f2a9f/go.mod h1:AHT0dDg3SoMOgZGnZk29b5xTbPHMoEC8qthmBLJCpys=
google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:OFMYQFHJ4TM3JRlWDZhJbZfra2uqc3WLBZiaaqP4DtU=
@ -1093,10 +1094,15 @@ gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.1.3 h1:qTakTkI6ni6LFD5sBwwsdSO+AQqbSIxOauHTTQKZ/7o=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk=
k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk=
k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU=
k8s.io/code-generator v0.31.0 h1:w607nrMi1KeDKB3/F/J4lIoOgAwc+gV9ZKew4XRfMp8=
k8s.io/code-generator v0.31.0/go.mod h1:84y4w3es8rOJOUUP1rLsIiGlO1JuEaPFXQPA9e/K6U0=
k8s.io/code-generator v0.31.1 h1:GvkRZEP2g2UnB2QKT2Dgc/kYxIkDxCHENv2Q1itioVs=
k8s.io/code-generator v0.31.1/go.mod h1:oL2ky46L48osNqqZAeOcWWy0S5BXj50vVdwOtTefqIs=
k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo=
k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01 h1:pWEwq4Asjm4vjW7vcsmijwBhOr1/shsbSYiWXmNGlks=
k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70 h1:NGrVE502P0s0/1hudf8zjgwki1X/TByhmAoILTarmzo=
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=

View File

@ -2,7 +2,7 @@ module github.com/grafana/grafana/hack
go 1.23.1
require k8s.io/code-generator v0.31.0
require k8s.io/code-generator v0.31.1
require (
github.com/go-logr/logr v1.4.2 // indirect

View File

@ -10,8 +10,8 @@ golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
k8s.io/code-generator v0.31.0 h1:w607nrMi1KeDKB3/F/J4lIoOgAwc+gV9ZKew4XRfMp8=
k8s.io/code-generator v0.31.0/go.mod h1:84y4w3es8rOJOUUP1rLsIiGlO1JuEaPFXQPA9e/K6U0=
k8s.io/code-generator v0.31.1 h1:GvkRZEP2g2UnB2QKT2Dgc/kYxIkDxCHENv2Q1itioVs=
k8s.io/code-generator v0.31.1/go.mod h1:oL2ky46L48osNqqZAeOcWWy0S5BXj50vVdwOtTefqIs=
k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70 h1:NGrVE502P0s0/1hudf8zjgwki1X/TByhmAoILTarmzo=
k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70/go.mod h1:VH3AT8AaQOqiGjMF9p0/IM1Dj+82ZwjfxUP1IxaHE+8=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=

View File

@ -11,7 +11,7 @@ set -o pipefail
SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/..
pushd "${SCRIPT_ROOT}/hack" && GO111MODULE=on go mod tidy && popd
CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo $(go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.31.0)}
CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo $(go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.31.1)}
OUTDIR="${HOME}/go/src"
OPENAPI_VIOLATION_EXCEPTIONS_FILENAME="zz_generated.openapi_violation_exceptions.list"

View File

@ -2,10 +2,14 @@ import { useContext } from 'react';
import { Context, PluginContextType } from './PluginContext';
export function usePluginContext(): PluginContextType {
export function usePluginContext(): PluginContextType | null {
const context = useContext(Context);
// The extensions hooks (e.g. `usePluginLinks()`) are using this hook to check
// if they are inside a plugin or not (core Grafana), so we should be able to return an empty state as well (`null`).
if (!context) {
throw new Error('usePluginContext must be used within a PluginContextProvider');
return null;
}
return context;
}

View File

@ -586,6 +586,7 @@ export {
type AngularMeta,
type PluginMeta,
type PluginDependencies,
type PluginExtensions,
type PluginInclude,
type PluginBuildInfo,
type ScreenshotInfo,

View File

@ -98,6 +98,7 @@ export interface PluginMeta<T extends KeyValue = {}> {
angular?: AngularMeta;
angularDetected?: boolean;
loadingStrategy?: PluginLoadingStrategy;
extensions?: PluginExtensions;
}
interface PluginDependencyInfo {
@ -111,6 +112,38 @@ export interface PluginDependencies {
grafanaDependency?: string;
grafanaVersion: string;
plugins: PluginDependencyInfo[];
extensions: {
// A list of exposed component IDs
exposedComponents: string[];
};
}
export type ExtensionInfo = {
targets: string | string[];
title: string;
description?: string;
};
export interface PluginExtensions {
// The component extensions that the plugin registers
addedComponents: ExtensionInfo[];
// The link extensions that the plugin registers
addedLinks: ExtensionInfo[];
// The React components that the plugin exposes
exposedComponents: Array<{
id: string;
title: string;
description?: string;
}>;
// The extension points that the plugin provides
extensionPoints: Array<{
id: string;
title: string;
description?: string;
}>;
}
export enum PluginIncludeType {

View File

@ -12,6 +12,13 @@ export function usePluginInteractionReporter(): typeof reportInteraction {
const context = usePluginContext();
return useMemo(() => {
// Happens when the hook is not used inside a plugin (e.g. in core Grafana)
if (!context) {
throw new Error(
`No PluginContext found. The usePluginInteractionReporter() hook can only be used from a plugin.`
);
}
const info = isDataSourcePluginContext(context)
? createDataSourcePluginEventProperties(context.instanceSettings)
: createPluginEventProperties(context.meta);

View File

@ -18,6 +18,8 @@ import {
getThemeById,
AngularMeta,
PluginLoadingStrategy,
PluginDependencies,
PluginExtensions,
} from '@grafana/data';
export interface AzureSettings {
@ -42,6 +44,8 @@ export type AppPluginConfig = {
preload: boolean;
angular: AngularMeta;
loadingStrategy: PluginLoadingStrategy;
dependencies: PluginDependencies;
extensions: PluginExtensions;
};
export type PreinstalledPlugin = {

View File

@ -10,11 +10,11 @@ require (
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38
github.com/stretchr/testify v1.9.0
go.opentelemetry.io/otel v1.30.0
k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0
k8s.io/apiserver v0.31.0
k8s.io/client-go v0.31.0
k8s.io/component-base v0.31.0
k8s.io/api v0.31.1
k8s.io/apimachinery v0.31.1
k8s.io/apiserver v0.31.1
k8s.io/client-go v0.31.1
k8s.io/component-base v0.31.1
k8s.io/klog/v2 v2.130.1
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340
sigs.k8s.io/structured-merge-diff/v4 v4.4.1

View File

@ -508,16 +508,16 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo=
k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY=
k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk=
k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8=
k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU=
k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs=
k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo=
k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c=
k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM=
k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8=
k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=

View File

@ -17,6 +17,7 @@ type PluginSetting struct {
Info plugins.Info `json:"info"`
Includes []*plugins.Includes `json:"includes"`
Dependencies plugins.Dependencies `json:"dependencies"`
Extensions plugins.Extensions `json:"extensions"`
JsonData map[string]any `json:"jsonData"`
SecureJsonFields map[string]bool `json:"secureJsonFields"`
DefaultNavUrl string `json:"defaultNavUrl"`

View File

@ -4,7 +4,6 @@ import (
"errors"
"net/http"
"strconv"
"strings"
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -39,35 +38,29 @@ const REDACTED = "redacted"
func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authorize func(accesscontrol.Evaluator) web.Handler) {
// #TODO add back auth part
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id"))
uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid"))
folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders))
folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID))
folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder))
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder))
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) {
folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList))
folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions))
})
})
if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) {
// Use k8s client to implement legacy API
handler := newFolderK8sHandler(hs)
folderRoute.Get("/", handler.searchFolders)
folderRoute.Post("/", handler.createFolder)
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", handler.getFolder)
folderUidRoute.Delete("/", handler.deleteFolder)
folderUidRoute.Put("/:uid", handler.updateFolder)
})
} else {
idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id"))
uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid"))
folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders))
folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID))
folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID))
folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder))
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder))
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) {
folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList))
folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions))
})
})
}
})
}
@ -634,6 +627,8 @@ type folderK8sHandler struct {
namespacer request.NamespaceMapper
gvr schema.GroupVersionResource
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
// #TODO check if it makes more sense to move this to FolderAPIBuilder
accesscontrolService accesscontrol.Service
}
//-----------------------------------------------------------------------------------------
@ -645,34 +640,36 @@ func newFolderK8sHandler(hs *HTTPServer) *folderK8sHandler {
gvr: folderalpha1.FolderResourceInfo.GroupVersionResource(),
namespacer: request.GetNamespaceMapper(hs.Cfg),
clientConfigProvider: hs.clientConfigProvider,
accesscontrolService: hs.accesscontrolService,
}
}
func (fk8s *folderK8sHandler) searchFolders(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
out, err := client.List(c.Req.Context(), v1.ListOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
// #TODO uncomment when we reinstate their corresponding routes
// func (fk8s *folderK8sHandler) searchFolders(c *contextmodel.ReqContext) {
// client, ok := fk8s.getClient(c)
// if !ok {
// return // error is already sent
// }
// out, err := client.List(c.Req.Context(), v1.ListOptions{})
// if err != nil {
// fk8s.writeError(c, err)
// return
// }
query := strings.ToUpper(c.Query("query"))
folders := []folder.Folder{}
for _, item := range out.Items {
p := internalfolders.UnstructuredToLegacyFolder(item)
if p == nil {
continue
}
if query != "" && !strings.Contains(strings.ToUpper(p.Title), query) {
continue // query filter
}
folders = append(folders, *p)
}
c.JSON(http.StatusOK, folders)
}
// query := strings.ToUpper(c.Query("query"))
// folders := []folder.Folder{}
// for _, item := range out.Items {
// p := internalfolders.UnstructuredToLegacyFolder(item)
// if p == nil {
// continue
// }
// if query != "" && !strings.Contains(strings.ToUpper(p.Title), query) {
// continue // query filter
// }
// folders = append(folders, *p)
// }
// c.JSON(http.StatusOK, folders)
// }
func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
@ -695,6 +692,7 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) {
return
}
fk8s.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out)
if err != nil {
fk8s.writeError(c, err)
@ -703,68 +701,68 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) {
c.JSON(http.StatusOK, f)
}
func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
uid := web.Params(c.Req)[":uid"]
out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
// func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) {
// client, ok := fk8s.getClient(c)
// if !ok {
// return // error is already sent
// }
// uid := web.Params(c.Req)[":uid"]
// out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{})
// if err != nil {
// fk8s.writeError(c, err)
// return
// }
f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out)
if err != nil {
fk8s.writeError(c, err)
return
}
// f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out)
// if err != nil {
// fk8s.writeError(c, err)
// return
// }
c.JSON(http.StatusOK, f)
}
// c.JSON(http.StatusOK, f)
// }
func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
uid := web.Params(c.Req)[":uid"]
err := client.Delete(c.Req.Context(), uid, v1.DeleteOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
c.JSON(http.StatusOK, "")
}
// func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) {
// client, ok := fk8s.getClient(c)
// if !ok {
// return // error is already sent
// }
// uid := web.Params(c.Req)[":uid"]
// err := client.Delete(c.Req.Context(), uid, v1.DeleteOptions{})
// if err != nil {
// fk8s.writeError(c, err)
// return
// }
// c.JSON(http.StatusOK, "")
// }
func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
uid := web.Params(c.Req)[":uid"]
cmd := folder.UpdateFolderCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
return
}
obj := internalfolders.LegacyUpdateCommandToUnstructured(cmd)
obj.SetName(uid)
out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
// func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) {
// client, ok := fk8s.getClient(c)
// if !ok {
// return // error is already sent
// }
// uid := web.Params(c.Req)[":uid"]
// cmd := folder.UpdateFolderCommand{}
// if err := web.Bind(c.Req, &cmd); err != nil {
// c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
// return
// }
// obj := internalfolders.LegacyUpdateCommandToUnstructured(cmd)
// obj.SetName(uid)
// out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{})
// if err != nil {
// fk8s.writeError(c, err)
// return
// }
f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out)
if err != nil {
fk8s.writeError(c, err)
return
}
// f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out)
// if err != nil {
// fk8s.writeError(c, err)
// return
// }
c.JSON(http.StatusOK, f)
}
// c.JSON(http.StatusOK, f)
// }
//-----------------------------------------------------------------------------------------
// Utility functions

View File

@ -561,6 +561,8 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
Preload: false,
Angular: plugin.Angular,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
Extensions: plugin.Extensions,
Dependencies: plugin.Dependencies,
}
if settings.Enabled {

View File

@ -209,6 +209,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
SecureJsonFields: map[string]bool{},
AngularDetected: plugin.Angular.Detected,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
Extensions: plugin.Extensions,
}
if plugin.IsApp() {

View File

@ -6,8 +6,8 @@ require (
github.com/grafana/authlib v0.0.0-20240919120951-58259833c564 // @grafana/identity-access-team
github.com/grafana/authlib/claims v0.0.0-20240903121118-16441568af1e // @grafana/identity-access-team
github.com/stretchr/testify v1.9.0
k8s.io/apimachinery v0.31.0
k8s.io/apiserver v0.31.0
k8s.io/apimachinery v0.31.1
k8s.io/apiserver v0.31.1
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340
)

View File

@ -152,10 +152,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY=
k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c=
k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=

View File

@ -11,9 +11,9 @@ require (
go.opentelemetry.io/contrib/propagators/jaeger v1.30.0
go.opentelemetry.io/otel v1.30.0
go.opentelemetry.io/otel/trace v1.30.0
k8s.io/apimachinery v0.31.0
k8s.io/apiserver v0.31.0
k8s.io/component-base v0.31.0
k8s.io/apimachinery v0.31.1
k8s.io/apiserver v0.31.1
k8s.io/component-base v0.31.1
k8s.io/klog/v2 v2.130.1
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8
sigs.k8s.io/structured-merge-diff/v4 v4.4.1
@ -89,8 +89,8 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.31.0 // indirect
k8s.io/client-go v0.31.0 // indirect
k8s.io/api v0.31.1 // indirect
k8s.io/client-go v0.31.1 // indirect
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect

View File

@ -325,16 +325,16 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo=
k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY=
k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk=
k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8=
k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU=
k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs=
k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo=
k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c=
k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM=
k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8=
k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=

View File

@ -50,6 +50,15 @@ func TestFinder_Find(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
State: plugins.ReleaseStateAlpha,
Backend: true,
@ -82,6 +91,15 @@ func TestFinder_Find(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")),
@ -104,6 +122,15 @@ func TestFinder_Find(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")),
@ -150,6 +177,9 @@ func TestFinder_Find(t *testing.T) {
{ID: "graphite", Type: "datasource", Name: "Graphite", Version: "1.0.0"},
{ID: "graph", Type: "panel", Name: "Graph", Version: "1.0.0"},
},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Includes: []*plugins.Includes{
{
@ -169,6 +199,12 @@ func TestFinder_Find(t *testing.T) {
{Name: "Nginx Panel", Type: "panel", Role: "Viewer", Action: "plugins.app:access"},
{Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Action: "plugins.app:access"},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "includes-symlinks")),
},
@ -197,6 +233,15 @@ func TestFinder_Find(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")),
@ -219,6 +264,15 @@ func TestFinder_Find(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")),
@ -241,6 +295,15 @@ func TestFinder_Find(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
State: plugins.ReleaseStateAlpha,
Backend: true,
@ -272,6 +335,15 @@ func TestFinder_Find(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
State: plugins.ReleaseStateAlpha,
Backend: true,

View File

@ -98,6 +98,15 @@ func TestLoader_Load(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Category: "cloud",
Annotations: true,
@ -141,6 +150,15 @@ func TestLoader_Load(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Executable: "test",
Backend: true,
@ -195,6 +213,9 @@ func TestLoader_Load(t *testing.T) {
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Includes: []*plugins.Includes{
{
@ -228,6 +249,12 @@ func TestLoader_Load(t *testing.T) {
Slug: "nginx-datasource",
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
@ -266,6 +293,15 @@ func TestLoader_Load(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: true,
State: plugins.ReleaseStateAlpha,
@ -312,6 +348,15 @@ func TestLoader_Load(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: true,
State: plugins.ReleaseStateAlpha,
@ -393,11 +438,20 @@ func TestLoader_Load(t *testing.T) {
GrafanaDependency: ">=8.0.0",
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Includes: []*plugins.Includes{
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"},
{Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: false,
},
DefaultNavURL: "/plugins/test-app/page/root-page-react",

View File

@ -1,6 +1,7 @@
package plugins
import (
"encoding/json"
"errors"
"fmt"
@ -42,9 +43,100 @@ func (e DuplicateError) Is(err error) bool {
}
type Dependencies struct {
GrafanaDependency string `json:"grafanaDependency"`
GrafanaVersion string `json:"grafanaVersion"`
Plugins []Dependency `json:"plugins"`
GrafanaDependency string `json:"grafanaDependency"`
GrafanaVersion string `json:"grafanaVersion"`
Plugins []Dependency `json:"plugins"`
Extensions ExtensionsDependencies `json:"extensions"`
}
// We need different versions for the Extensions struct because there is a now deprecated plugin.json schema out there, where the "extensions" prop
// is in a different format (Extensions V1). In order to support those as well while reading the plugin.json, we need to add a custom unmarshaling logic for extensions.
type ExtensionV1 struct {
ExtensionPointID string `json:"extensionPointId"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
}
type ExtensionsV2 struct {
AddedLinks []AddedLink `json:"addedLinks"`
AddedComponents []AddedComponent `json:"addedComponents"`
ExposedComponents []ExposedComponent `json:"exposedComponents"`
ExtensionPoints []ExtensionPoint `json:"extensionPoints"`
}
type Extensions ExtensionsV2
func (e *Extensions) UnmarshalJSON(data []byte) error {
var err error
var extensionsV2 ExtensionsV2
if err = json.Unmarshal(data, &extensionsV2); err == nil {
e.AddedComponents = extensionsV2.AddedComponents
e.AddedLinks = extensionsV2.AddedLinks
e.ExposedComponents = extensionsV2.ExposedComponents
e.ExtensionPoints = extensionsV2.ExtensionPoints
return nil
}
// Fallback (V1)
var extensionsV1 []ExtensionV1
if err = json.Unmarshal(data, &extensionsV1); err == nil {
// Trying to process old format and add them to `AddedLinks` and `AddedComponents`
for _, extensionV1 := range extensionsV1 {
if extensionV1.Type == "link" {
extensionV2 := AddedLink{
Targets: []string{extensionV1.ExtensionPointID},
Title: extensionV1.Title,
Description: extensionV1.Description,
}
e.AddedLinks = append(e.AddedLinks, extensionV2)
}
if extensionV1.Type == "component" {
extensionV2 := AddedComponent{
Targets: []string{extensionV1.ExtensionPointID},
Title: extensionV1.Title,
Description: extensionV1.Description,
}
e.AddedComponents = append(e.AddedComponents, extensionV2)
}
}
return nil
}
return err
}
type AddedLink struct {
Targets []string `json:"targets"`
Title string `json:"title"`
Description string `json:"description"`
}
type AddedComponent struct {
Targets []string `json:"targets"`
Title string `json:"title"`
Description string `json:"description"`
}
type ExposedComponent struct {
Id string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
}
type ExtensionPoint struct {
Id string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
}
type ExtensionsDependencies struct {
ExposedComponents []string `json:"exposedComponents"`
}
type Includes struct {
@ -231,6 +323,8 @@ type AppDTO struct {
Preload bool `json:"preload"`
Angular AngularMeta `json:"angular"`
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
Extensions Extensions `json:"extensions"`
Dependencies Dependencies `json:"dependencies"`
}
const (

View File

@ -109,7 +109,8 @@ type JSONData struct {
SkipDataQuery bool `json:"skipDataQuery"`
// App settings
AutoEnabled bool `json:"autoEnabled"`
AutoEnabled bool `json:"autoEnabled"`
Extensions Extensions `json:"extensions"`
// Datasource settings
Annotations bool `json:"annotations"`
@ -173,6 +174,26 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) {
plugin.Dependencies.GrafanaVersion = "*"
}
if len(plugin.Dependencies.Extensions.ExposedComponents) == 0 {
plugin.Dependencies.Extensions.ExposedComponents = make([]string, 0)
}
if plugin.Extensions.AddedLinks == nil {
plugin.Extensions.AddedLinks = []AddedLink{}
}
if plugin.Extensions.AddedComponents == nil {
plugin.Extensions.AddedComponents = []AddedComponent{}
}
if plugin.Extensions.ExposedComponents == nil {
plugin.Extensions.ExposedComponents = []ExposedComponent{}
}
if plugin.Extensions.ExtensionPoints == nil {
plugin.Extensions.ExtensionPoints = []ExtensionPoint{}
}
for _, include := range plugin.Includes {
if include.Role == "" {
include.Role = org.RoleViewer

View File

@ -52,13 +52,25 @@ func Test_ReadPluginJSON(t *testing.T) {
Updated: "2015-02-10",
Keywords: []string{"test"},
},
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
Dependencies: Dependencies{
GrafanaVersion: "3.x.x",
Plugins: []Dependency{
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
},
Extensions: ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Includes: []*Includes{
{Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess},
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess},
@ -94,10 +106,22 @@ func Test_ReadPluginJSON(t *testing.T) {
ID: "grafana-piechart-panel",
Type: TypePanel,
Name: "Pie Chart (old)",
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
Dependencies: Dependencies{
GrafanaVersion: "*",
Plugins: []Dependency{},
Extensions: ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Includes: []*Includes{
{Name: "Pie Charts", Path: "dashboards/demo.json", Type: "dashboard", Role: org.RoleViewer},
},
@ -117,10 +141,21 @@ func Test_ReadPluginJSON(t *testing.T) {
ID: "grafana-pyroscope-datasource",
AliasIDs: []string{"phlare"}, // Hardcoded from the parser
Type: TypeDataSource,
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
Dependencies: Dependencies{
GrafanaDependency: "",
GrafanaVersion: "*",
Plugins: []Dependency{},
Extensions: ExtensionsDependencies{
ExposedComponents: []string{},
},
},
},
},
@ -142,18 +177,257 @@ func Test_ReadPluginJSON(t *testing.T) {
Dependencies: Dependencies{},
},
},
{
name: "can read the latest versions of extensions information (v2)",
pluginJSON: func(t *testing.T) io.ReadCloser {
pJSON := `{
"id": "myorg-extensions-app",
"name": "Extensions App",
"type": "app",
"extensions": {
"addedLinks": [
{
"title": "Added link 1",
"description": "Added link 1 description",
"targets": ["grafana/dashboard/panel/menu"]
}
],
"addedComponents": [
{
"title": "Added component 1",
"description": "Added component 1 description",
"targets": ["grafana/user/profile/tab"]
}
],
"exposedComponents": [
{
"title": "Exposed component 1",
"description": "Exposed component 1 description",
"id": "myorg-extensions-app/component-1/v1"
}
],
"extensionPoints": [
{
"title": "Extension point 1",
"description": "Extension points 1 description",
"id": "myorg-extensions-app/extensions-point-1/v1"
}
]
}
}`
return io.NopCloser(strings.NewReader(pJSON))
},
expected: JSONData{
ID: "myorg-extensions-app",
Name: "Extensions App",
Type: TypeApp,
Extensions: Extensions{
AddedLinks: []AddedLink{
{Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}},
},
AddedComponents: []AddedComponent{
{Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}},
},
ExposedComponents: []ExposedComponent{
{Id: "myorg-extensions-app/component-1/v1", Title: "Exposed component 1", Description: "Exposed component 1 description"},
},
ExtensionPoints: []ExtensionPoint{
{Id: "myorg-extensions-app/extensions-point-1/v1", Title: "Extension point 1", Description: "Extension points 1 description"},
},
},
Dependencies: Dependencies{
GrafanaVersion: "*",
Plugins: []Dependency{},
Extensions: ExtensionsDependencies{
ExposedComponents: []string{},
},
},
},
},
{
name: "can read deprecated extensions info (v1) and parse it as v2",
pluginJSON: func(t *testing.T) io.ReadCloser {
pJSON := `{
"id": "myorg-extensions-app",
"name": "Extensions App",
"type": "app",
"extensions": [
{
"extensionPointId": "grafana/dashboard/panel/menu",
"title": "Added link 1",
"description": "Added link 1 description",
"type": "link"
},
{
"extensionPointId": "grafana/dashboard/panel/menu",
"title": "Added link 2",
"description": "Added link 2 description",
"type": "link"
},
{
"extensionPointId": "grafana/user/profile/tab",
"title": "Added component 1",
"description": "Added component 1 description",
"type": "component"
}
]
}`
return io.NopCloser(strings.NewReader(pJSON))
},
expected: JSONData{
ID: "myorg-extensions-app",
Name: "Extensions App",
Type: TypeApp,
Extensions: Extensions{
AddedLinks: []AddedLink{
{Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}},
{Title: "Added link 2", Description: "Added link 2 description", Targets: []string{"grafana/dashboard/panel/menu"}},
},
AddedComponents: []AddedComponent{
{Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}},
},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
Dependencies: Dependencies{
GrafanaVersion: "*",
Plugins: []Dependency{},
Extensions: ExtensionsDependencies{
ExposedComponents: []string{},
},
},
},
},
{
name: "works if extensions info is empty",
pluginJSON: func(t *testing.T) io.ReadCloser {
pJSON := `{
"id": "myorg-extensions-app",
"name": "Extensions App",
"type": "app",
"extensions": []
}`
return io.NopCloser(strings.NewReader(pJSON))
},
expected: JSONData{
ID: "myorg-extensions-app",
Name: "Extensions App",
Type: TypeApp,
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
Dependencies: Dependencies{
GrafanaVersion: "*",
Plugins: []Dependency{},
Extensions: ExtensionsDependencies{
ExposedComponents: []string{},
},
},
},
},
{
name: "works if extensions info is completely missing",
pluginJSON: func(t *testing.T) io.ReadCloser {
pJSON := `{
"id": "myorg-extensions-app",
"name": "Extensions App",
"type": "app"
}`
return io.NopCloser(strings.NewReader(pJSON))
},
expected: JSONData{
ID: "myorg-extensions-app",
Name: "Extensions App",
Type: TypeApp,
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
Dependencies: Dependencies{
GrafanaVersion: "*",
Plugins: []Dependency{},
Extensions: ExtensionsDependencies{
ExposedComponents: []string{},
},
},
},
},
{
name: "can read extensions related dependencies",
pluginJSON: func(t *testing.T) io.ReadCloser {
pJSON := `{
"id": "myorg-extensions-app",
"name": "Extensions App",
"type": "app",
"dependencies": {
"grafanaDependency": "10.0.0",
"extensions": {
"exposedComponents": ["myorg-extensions-app/component-1/v1"]
}
}
}`
return io.NopCloser(strings.NewReader(pJSON))
},
expected: JSONData{
ID: "myorg-extensions-app",
Name: "Extensions App",
Type: TypeApp,
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
Dependencies: Dependencies{
GrafanaVersion: "*",
GrafanaDependency: "10.0.0",
Plugins: []Dependency{},
Extensions: ExtensionsDependencies{
ExposedComponents: []string{"myorg-extensions-app/component-1/v1"},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := tt.pluginJSON(t)
got, err := ReadPluginJSON(p)
// Check if the test returns the same error as expected
// (unneccary to check further if there is an error at this point)
if tt.err == nil && err != nil {
t.Errorf("Error while reading pluginJSON: %+v", err)
return
}
// Check if the test returns the same error as expected
if tt.err != nil {
require.ErrorIs(t, err, tt.err)
}
// Check if the test returns the expected pluginJSONData
if !cmp.Equal(got, tt.expected) {
t.Errorf("Unexpected pluginJSONData: %v", cmp.Diff(got, tt.expected))
}
// Should be able to close the reader
require.NoError(t, p.Close())
})
}

View File

@ -14,7 +14,7 @@ require (
go.opentelemetry.io/otel v1.30.0
go.opentelemetry.io/otel/trace v1.30.0
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
k8s.io/apimachinery v0.31.0
k8s.io/apimachinery v0.31.1
)
require (

View File

@ -373,8 +373,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=

View File

@ -6,12 +6,11 @@ import (
"strconv"
"strings"
"github.com/grafana/authlib/claims"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/authn"
@ -32,6 +31,8 @@ type AccessControl interface {
// This is useful when we don't want to reuse any pre-configured resolvers
// for a authorization call.
WithoutResolvers() AccessControl
Check(ctx context.Context, req CheckRequest) (bool, error)
ListObjects(ctx context.Context, req ListObjectsRequest) ([]string, error)
}
type Service interface {

View File

@ -119,26 +119,24 @@ func (a *AccessControl) evaluateZanzana(ctx context.Context, user identity.Reque
return eval.EvaluateCustom(func(action, scope string) (bool, error) {
kind, _, identifier := accesscontrol.SplitScope(scope)
key, ok := zanzana.TranslateToTuple(user.GetUID(), action, kind, identifier, user.GetOrgID())
tupleKey, ok := zanzana.TranslateToTuple(user.GetUID(), action, kind, identifier, user.GetOrgID())
if !ok {
// unsupported translation
return false, errAccessNotImplemented
}
a.log.Debug("evaluating zanzana", "user", key.User, "relation", key.Relation, "object", key.Object)
res, err := a.zclient.Check(ctx, &openfgav1.CheckRequest{
TupleKey: &openfgav1.CheckRequestTupleKey{
User: key.User,
Relation: key.Relation,
Object: key.Object,
},
a.log.Debug("evaluating zanzana", "user", tupleKey.User, "relation", tupleKey.Relation, "object", tupleKey.Object)
allowed, err := a.Check(ctx, accesscontrol.CheckRequest{
User: tupleKey.User,
Relation: tupleKey.Relation,
Object: tupleKey.Object,
})
if err != nil {
return false, err
}
return res.Allowed, nil
return allowed, nil
})
}
@ -221,3 +219,30 @@ func (a *AccessControl) debug(ctx context.Context, ident identity.Requester, msg
a.log.FromContext(ctx).Debug(msg, "id", ident.GetID(), "orgID", ident.GetOrgID(), "permissions", eval.GoString())
}
func (a *AccessControl) Check(ctx context.Context, req accesscontrol.CheckRequest) (bool, error) {
key := &openfgav1.CheckRequestTupleKey{
User: req.User,
Relation: req.Relation,
Object: req.Object,
}
in := &openfgav1.CheckRequest{TupleKey: key}
res, err := a.zclient.Check(ctx, in)
if err != nil {
return false, err
}
return res.Allowed, err
}
func (a *AccessControl) ListObjects(ctx context.Context, req accesscontrol.ListObjectsRequest) ([]string, error) {
in := &openfgav1.ListObjectsRequest{
Type: req.Type,
User: req.User,
Relation: req.Relation,
}
res, err := a.zclient.ListObjects(ctx, in)
if err != nil {
return nil, err
}
return res.Objects, err
}

View File

@ -8,11 +8,10 @@ import (
"strings"
"time"
"github.com/grafana/authlib/claims"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/db"

View File

@ -75,6 +75,14 @@ func (f FakeAccessControl) Evaluate(ctx context.Context, user identity.Requester
func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) {
}
func (f FakeAccessControl) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) {
return false, nil
}
func (f FakeAccessControl) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) {
return nil, nil
}
func (f FakeAccessControl) WithoutResolvers() accesscontrol.AccessControl {
return f
}

View File

@ -94,12 +94,12 @@ func (z *ZanzanaSynchroniser) Sync(ctx context.Context) error {
func managedPermissionsCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "managed"
const query = `
query := `
SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id
FROM permission p
INNER JOIN role r ON p.role_id = r.id
LEFT JOIN user_role ur ON r.id = ur.role_id
LEFT JOIN user u ON u.id = ur.user_id
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id
LEFT JOIN team_role tr ON r.id = tr.role_id
LEFT JOIN team t ON tr.team_id = t.id
LEFT JOIN builtin_role br ON r.id = br.role_id
@ -156,11 +156,11 @@ func teamMembershipCollector(store db.DB) TupleCollector {
defer span.End()
const collectorID = "team_membership"
const query = `
query := `
SELECT t.uid as team_uid, u.uid as user_uid, tm.permission
FROM team_member tm
INNER JOIN team t ON tm.team_id = t.id
INNER JOIN user u ON tm.user_id = u.id
INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON tm.user_id = u.id
`
type membership struct {
@ -253,8 +253,10 @@ func dashboardFolderCollector(store db.DB) TupleCollector {
defer span.End()
const collectorID = "folder"
const query = `
SELECT org_id, uid, folder_uid, is_folder FROM dashboard WHERE is_folder = 0 AND folder_uid IS NOT NULL
query := `
SELECT org_id, uid, folder_uid, is_folder FROM dashboard
WHERE is_folder = ` + store.GetDialect().BooleanStr(false) + `
AND folder_uid IS NOT NULL
`
type dashboard struct {
OrgID int64 `xorm:"org_id"`
@ -426,10 +428,10 @@ func customRolesCollector(store db.DB) TupleCollector {
func basicRoleAssignemtCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "basic_role_assignment"
const query = `
query := `
SELECT ou.org_id, u.uid as user_uid, ou.role as org_role, u.is_admin
FROM org_user ou
LEFT JOIN user u ON u.id = ou.user_id
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ou.user_id
`
type Assignment struct {
OrgID int64 `xorm:"org_id"`
@ -474,11 +476,11 @@ func basicRoleAssignemtCollector(store db.DB) TupleCollector {
func userRoleAssignemtCollector(store db.DB) TupleCollector {
return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error {
const collectorID = "user_role_assignment"
const query = `
query := `
SELECT ur.org_id, u.uid AS user_uid, r.uid AS role_uid, r.name AS role_name
FROM user_role ur
LEFT JOIN role r ON r.id = ur.role_id
LEFT JOIN user u ON u.id = ur.user_id
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id
WHERE r.name NOT LIKE 'managed:%'
`

View File

@ -266,6 +266,14 @@ func (m *Mock) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol
return nil
}
func (m *Mock) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) {
return false, nil
}
func (m *Mock) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) {
return nil, nil
}
// WithoutResolvers implements fullAccessControl.
func (m *Mock) WithoutResolvers() accesscontrol.AccessControl {
return m

View File

@ -585,3 +585,15 @@ type QueryWithOrg struct {
OrgId *int64 `json:"orgId"`
Global bool `json:"global"`
}
type CheckRequest struct {
User string
Relation string
Object string
}
type ListObjectsRequest struct {
Type string
Relation string
User string
}

View File

@ -7,6 +7,8 @@ import (
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/openfga/language/pkg/go/transformer"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/wrapperspb"
@ -14,6 +16,8 @@ import (
"github.com/grafana/grafana/pkg/services/authz/zanzana/schema"
)
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/authz/zanzana/client")
type ClientOption func(c *Client)
func WithTenantID(tenantID string) ClientOption {
@ -82,12 +86,19 @@ func New(ctx context.Context, cc grpc.ClientConnInterface, opts ...ClientOption)
}
func (c *Client) Check(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error) {
ctx, span := tracer.Start(ctx, "authz.zanzana.client.Check")
defer span.End()
in.StoreId = c.storeID
in.AuthorizationModelId = c.modelID
return c.client.Check(ctx, in)
}
func (c *Client) ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error) {
ctx, span := tracer.Start(ctx, "authz.zanzana.client.ListObjects")
span.SetAttributes(attribute.String("resource.type", in.Type))
defer span.End()
in.StoreId = c.storeID
in.AuthorizationModelId = c.modelID
return c.client.ListObjects(ctx, in)

View File

@ -8,6 +8,8 @@ import (
"time"
"github.com/grafana/authlib/claims"
"go.opentelemetry.io/otel"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
@ -25,7 +27,6 @@ import (
"github.com/grafana/grafana/pkg/services/tag"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"go.opentelemetry.io/otel"
)
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboard/database")
@ -942,7 +943,9 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F
filters = append(filters, searchstore.K6FolderFilter{})
}
filters = append(filters, permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported))
if !query.SkipAccessControlFilter {
filters = append(filters, permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported))
}
filters = append(filters, searchstore.DeletedFilter{Deleted: query.IsDeleted})

View File

@ -130,6 +130,7 @@ var (
ErrFolderInvalidUID = errors.New("invalid uid for folder provided")
ErrFolderSameNameExists = errors.New("a folder with the same name already exists in the current location")
ErrFolderAccessDenied = errors.New("access denied to folder")
ErrUserIsNotSignedInToOrg = errors.New("user is not signed in to organization")
ErrMoveAccessDenied = errutil.Forbidden("folders.forbiddenMove", errutil.WithPublicMessage("Access denied to the destination folder"))
ErrFolderAccessEscalation = errutil.Forbidden("folders.accessEscalation", errutil.WithPublicMessage("Cannot move a folder to a folder where you have higher permissions"))
ErrFolderCreationAccessDenied = errutil.Forbidden("folders.forbiddenCreation", errutil.WithPublicMessage("not enough permissions to create a folder in the selected location"))

View File

@ -423,4 +423,8 @@ type FindPersistedDashboardsQuery struct {
IsDeleted bool
Filters []any
// Skip access control checks. This field is used by OpenFGA search implementation.
// Should not be used anywhere else.
SkipAccessControlFilter bool
}

View File

@ -8,12 +8,13 @@ import (
"time"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"golang.org/x/exp/slices"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
@ -716,7 +717,13 @@ func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *das
ctx, span := tracer.Start(ctx, "dashboards.service.SearchDashboards")
defer span.End()
res, err := dr.FindDashboards(ctx, query)
var res []dashboards.DashboardSearchProjection
var err error
if dr.features.IsEnabled(ctx, featuremgmt.FlagZanzana) {
res, err = dr.FindDashboardsZanzana(ctx, query)
} else {
res, err = dr.FindDashboards(ctx, query)
}
if err != nil {
return nil, err
}

View File

@ -10,8 +10,12 @@ const (
metricsSubSystem = "dashboards"
)
var defaultBuckets = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25}
type dashboardsMetrics struct {
sharedWithMeFetchDashboardsRequestsDuration *prometheus.HistogramVec
searchRequestsDuration *prometheus.HistogramVec
searchRequestStatusTotal *prometheus.CounterVec
}
func newDashboardsMetrics(r prometheus.Registerer) *dashboardsMetrics {
@ -20,7 +24,27 @@ func newDashboardsMetrics(r prometheus.Registerer) *dashboardsMetrics {
prometheus.HistogramOpts{
Name: "sharedwithme_fetch_dashboards_duration_seconds",
Help: "Duration of fetching dashboards with permissions directly assigned to user",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100},
Buckets: defaultBuckets,
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
},
[]string{"status"},
),
searchRequestsDuration: promauto.With(r).NewHistogramVec(
prometheus.HistogramOpts{
Name: "search_dashboards_duration_seconds",
Help: "Duration of dashboards search (by authorization engine)",
Buckets: defaultBuckets,
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
},
[]string{"engine"},
),
searchRequestStatusTotal: promauto.With(r).NewCounterVec(
prometheus.CounterOpts{
Name: "search_dashboards_status_total",
Help: "Search status (success or error) for zanzana",
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
},

View File

@ -0,0 +1,342 @@
package service
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
)
const (
// If search query string shorter than this value, then "List, then check" strategy will be used
listQueryLengthThreshold = 8
// If query limit set to value higher than this value, then "List, then check" strategy will be used
listQueryLimitThreshold = 50
defaultQueryLimit = 1000
)
type searchResult struct {
runner string
result []dashboards.DashboardSearchProjection
err error
duration time.Duration
}
func (dr *DashboardServiceImpl) FindDashboardsZanzana(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) {
if dr.cfg.Zanzana.ZanzanaOnlyEvaluation {
return dr.findDashboardsZanzanaOnly(ctx, *query)
}
return dr.findDashboardsZanzanaCompare(ctx, *query)
}
func (dr *DashboardServiceImpl) findDashboardsZanzanaOnly(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) {
timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("zanzana"))
defer timer.ObserveDuration()
return dr.findDashboardsZanzana(ctx, query)
}
func (dr *DashboardServiceImpl) findDashboardsZanzanaCompare(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) {
result := make(chan searchResult, 2)
go func() {
timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("zanzana"))
defer timer.ObserveDuration()
start := time.Now()
queryZanzana := query
res, err := dr.findDashboardsZanzana(ctx, queryZanzana)
result <- searchResult{"zanzana", res, err, time.Since(start)}
}()
go func() {
timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("grafana"))
defer timer.ObserveDuration()
start := time.Now()
res, err := dr.FindDashboards(ctx, &query)
result <- searchResult{"grafana", res, err, time.Since(start)}
}()
first, second := <-result, <-result
close(result)
if second.runner == "grafana" {
first, second = second, first
}
if second.err != nil {
dr.log.Error("zanzana search failed", "error", second.err)
dr.metrics.searchRequestStatusTotal.WithLabelValues("error").Inc()
} else if len(first.result) != len(second.result) {
dr.metrics.searchRequestStatusTotal.WithLabelValues("error").Inc()
dr.log.Warn(
"zanzana search result does not match grafana",
"grafana_result_len", len(first.result),
"zanana_result_len", len(second.result),
"grafana_duration", first.duration,
"zanzana_duration", second.duration,
)
} else {
dr.metrics.searchRequestStatusTotal.WithLabelValues("success").Inc()
dr.log.Debug("zanzana search is correct", "result_len", len(first.result), "grafana_duration", first.duration, "zanzana_duration", second.duration)
}
return first.result, first.err
}
func (dr *DashboardServiceImpl) findDashboardsZanzana(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) {
findDashboards := dr.getFindDashboardsFn(query)
return findDashboards(ctx, query)
}
type findDashboardsFn func(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error)
// getFindDashboardsFn makes a decision which search method should be used
func (dr *DashboardServiceImpl) getFindDashboardsFn(query dashboards.FindPersistedDashboardsQuery) findDashboardsFn {
if query.Limit > 0 && query.Limit < listQueryLimitThreshold && len(query.Title) > 0 {
return dr.findDashboardsZanzanaCheck
}
if len(query.DashboardUIDs) > 0 || len(query.DashboardIds) > 0 {
return dr.findDashboardsZanzanaCheck
}
if len(query.FolderUIDs) > 0 {
return dr.findDashboardsZanzanaCheck
}
if len(query.Title) <= listQueryLengthThreshold {
return dr.findDashboardsZanzanaList
}
return dr.findDashboardsZanzanaCheck
}
// findDashboardsZanzanaCheck implements "Search, then check" strategy. It first performs search query, then filters out results
// by checking access to each item.
func (dr *DashboardServiceImpl) findDashboardsZanzanaCheck(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) {
ctx, span := tracer.Start(ctx, "dashboards.service.findDashboardsZanzanaCheck")
defer span.End()
result := make([]dashboards.DashboardSearchProjection, 0, query.Limit)
var page int64 = 1
query.SkipAccessControlFilter = true
// Remember initial query limit
limit := query.Limit
// Set limit to default to prevent pagination issues
query.Limit = defaultQueryLimit
for len(result) < int(limit) {
query.Page = page
findRes, err := dr.dashboardStore.FindDashboards(ctx, &query)
if err != nil {
return nil, err
}
remains := limit - int64(len(result))
res, err := dr.checkDashboards(ctx, query, findRes, remains)
if err != nil {
return nil, err
}
result = append(result, res...)
page++
// Stop when last page reached
if len(findRes) < defaultQueryLimit {
break
}
}
return result, nil
}
func (dr *DashboardServiceImpl) checkDashboards(ctx context.Context, query dashboards.FindPersistedDashboardsQuery, searchRes []dashboards.DashboardSearchProjection, remains int64) ([]dashboards.DashboardSearchProjection, error) {
ctx, span := tracer.Start(ctx, "dashboards.service.checkDashboards")
defer span.End()
if len(searchRes) == 0 {
return nil, nil
}
orgId := query.OrgId
if orgId == 0 && query.SignedInUser.GetOrgID() != 0 {
orgId = query.SignedInUser.GetOrgID()
} else {
return nil, dashboards.ErrUserIsNotSignedInToOrg
}
concurrentRequests := dr.cfg.Zanzana.ConcurrentChecks
var wg sync.WaitGroup
res := make([]dashboards.DashboardSearchProjection, 0)
resToCheck := make(chan dashboards.DashboardSearchProjection, concurrentRequests)
allowedResults := make(chan dashboards.DashboardSearchProjection, len(searchRes))
for i := 0; i < int(concurrentRequests); i++ {
wg.Add(1)
go func() {
defer wg.Done()
for d := range resToCheck {
if int64(len(allowedResults)) >= remains {
return
}
objectType := zanzana.TypeDashboard
if d.IsFolder {
objectType = zanzana.TypeFolder
}
req := accesscontrol.CheckRequest{
User: query.SignedInUser.GetUID(),
Relation: "read",
Object: zanzana.NewScopedTupleEntry(objectType, d.UID, "", strconv.FormatInt(orgId, 10)),
}
allowed, err := dr.ac.Check(ctx, req)
if err != nil {
dr.log.Error("error checking access", "error", err)
} else if allowed {
allowedResults <- d
}
}
}()
}
for _, r := range searchRes {
resToCheck <- r
}
close(resToCheck)
wg.Wait()
close(allowedResults)
for r := range allowedResults {
if int64(len(res)) >= remains {
break
}
res = append(res, r)
}
return res, nil
}
// findDashboardsZanzanaList implements "List, then search" strategy. It first retrieve a list of resources
// with given type available to the user and then passes that list as a filter to the search query.
func (dr *DashboardServiceImpl) findDashboardsZanzanaList(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) {
// Always use "search, then check" if dashboard or folder UIDs provided. Otherwise we should make intersection
// of user's resources and provided UIDs which might not be correct if ListObjects() request is limited by OpenFGA.
if len(query.DashboardUIDs) > 0 || len(query.DashboardIds) > 0 || len(query.FolderUIDs) > 0 {
return dr.findDashboardsZanzanaCheck(ctx, query)
}
ctx, span := tracer.Start(ctx, "dashboards.service.findDashboardsZanzanaList")
defer span.End()
resourceUIDs, err := dr.listUserResources(ctx, query)
if err != nil {
return nil, err
}
if len(resourceUIDs) == 0 {
return []dashboards.DashboardSearchProjection{}, nil
}
query.DashboardUIDs = resourceUIDs
query.SkipAccessControlFilter = true
return dr.dashboardStore.FindDashboards(ctx, &query)
}
func (dr *DashboardServiceImpl) listUserResources(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]string, error) {
tasks := make([]func() ([]string, error), 0)
var resourceTypes []string
// For some search types we need dashboards or folders only
switch query.Type {
case searchstore.TypeDashboard:
resourceTypes = []string{zanzana.TypeDashboard}
case searchstore.TypeFolder, searchstore.TypeAlertFolder:
resourceTypes = []string{zanzana.TypeFolder}
default:
resourceTypes = []string{zanzana.TypeDashboard, zanzana.TypeFolder}
}
for _, resourceType := range resourceTypes {
tasks = append(tasks, func() ([]string, error) {
return dr.listAllowedResources(ctx, query, resourceType)
})
}
uids, err := runBatch(tasks)
if err != nil {
return nil, err
}
return uids, nil
}
func (dr *DashboardServiceImpl) listAllowedResources(ctx context.Context, query dashboards.FindPersistedDashboardsQuery, resourceType string) ([]string, error) {
res, err := dr.ac.ListObjects(ctx, accesscontrol.ListObjectsRequest{
User: query.SignedInUser.GetUID(),
Type: resourceType,
Relation: "read",
})
if err != nil {
return nil, err
}
orgId := query.OrgId
if orgId == 0 && query.SignedInUser.GetOrgID() != 0 {
orgId = query.SignedInUser.GetOrgID()
} else {
return nil, dashboards.ErrUserIsNotSignedInToOrg
}
// dashboard:<orgId>-
prefix := fmt.Sprintf("%s:%d-", resourceType, orgId)
resourceUIDs := make([]string, 0)
for _, d := range res {
if uid, found := strings.CutPrefix(d, prefix); found {
resourceUIDs = append(resourceUIDs, uid)
}
}
return resourceUIDs, nil
}
func runBatch(tasks []func() ([]string, error)) ([]string, error) {
var wg sync.WaitGroup
tasksNum := len(tasks)
resChan := make(chan []string, tasksNum)
errChan := make(chan error, tasksNum)
for _, task := range tasks {
wg.Add(1)
go func() {
defer wg.Done()
res, err := task()
resChan <- res
errChan <- err
}()
}
wg.Wait()
close(resChan)
close(errChan)
for err := range errChan {
if err != nil {
return nil, err
}
}
result := make([]string, 0)
for res := range resChan {
result = append(result, res...)
}
return result, nil
}

View File

@ -0,0 +1,121 @@
package service
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/accesscontrol/migrator"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/authz"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/database"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
func TestIntegrationDashboardServiceZanzana(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("Zanzana enabled", func(t *testing.T) {
// t.Helper()
features := featuremgmt.WithFeatures(featuremgmt.FlagZanzana)
db, cfg := db.InitTestDBWithCfg(t)
// Enable zanzana and run in embedded mode (part of grafana server)
cfg.Zanzana.ZanzanaOnlyEvaluation = true
cfg.Zanzana.Mode = setting.ZanzanaModeEmbedded
cfg.Zanzana.ConcurrentChecks = 10
_, err := cfg.Raw.Section("rbac").NewKey("resources_with_managed_permissions_on_creation", "dashboard, folder")
require.NoError(t, err)
quotaService := quotatest.New(false, nil)
tagService := tagimpl.ProvideService(db)
folderStore := folderimpl.ProvideDashboardFolderStore(db)
fStore := folderimpl.ProvideStore(db)
dashboardStore, err := database.ProvideDashboardStore(db, cfg, features, tagService, quotaService)
require.NoError(t, err)
zclient, err := authz.ProvideZanzana(cfg, db, features)
require.NoError(t, err)
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zclient)
service, err := ProvideDashboardServiceImpl(
cfg, dashboardStore, folderStore,
featuremgmt.WithFeatures(),
accesscontrolmock.NewMockedPermissionsService(),
accesscontrolmock.NewMockedPermissionsService(),
ac,
foldertest.NewFakeService(),
fStore,
nil,
)
require.NoError(t, err)
guardianMock := &guardian.FakeDashboardGuardian{
CanSaveValue: true,
}
guardian.MockDashboardGuardian(guardianMock)
createDashboards(t, service, 100, "test-a")
createDashboards(t, service, 100, "test-b")
// Sync Grafana DB with zanzana (migrate data)
zanzanaSyncronizer := migrator.NewZanzanaSynchroniser(zclient, db)
err = zanzanaSyncronizer.Sync(context.Background())
require.NoError(t, err)
query := &dashboards.FindPersistedDashboardsQuery{
Title: "test-a",
Limit: 1000,
SignedInUser: &user.SignedInUser{
OrgID: 1,
UserID: 1,
},
}
res, err := service.FindDashboardsZanzana(context.Background(), query)
require.NoError(t, err)
assert.Equal(t, 0, len(res))
})
}
func createDashboard(t *testing.T, service dashboards.DashboardService, uid, title string) {
dto := &dashboards.SaveDashboardDTO{
OrgID: 1,
// User: user,
User: &user.SignedInUser{
OrgID: 1,
UserID: 1,
},
}
dto.Dashboard = dashboards.NewDashboard(title)
dto.Dashboard.SetUID(uid)
_, err := service.SaveDashboard(context.Background(), dto, false)
require.NoError(t, err)
}
func createDashboards(t *testing.T, service dashboards.DashboardService, number int, prefix string) {
for i := 0; i < number; i++ {
title := fmt.Sprintf("%s-%d", prefix, i)
uid := fmt.Sprintf("dash-%s", title)
createDashboard(t, service, uid, title)
}
}

View File

@ -675,8 +675,9 @@ var (
Name: "externalServiceAccounts",
Description: "Automatic service account and token setup for plugins",
HideFromAdminPage: true,
Stage: FeatureStagePublicPreview,
Stage: FeatureStageGeneralAvailability,
Owner: identityAccessTeam,
Expression: "true", // enabled by default
},
{
Name: "panelMonitoring",

View File

@ -88,7 +88,7 @@ wargamesTesting,experimental,@grafana/hosted-grafana-team,false,false,false
alertingInsights,GA,@grafana/alerting-squad,false,false,true
externalCorePlugins,experimental,@grafana/plugins-platform-backend,false,false,false
pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,true
externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false
externalServiceAccounts,GA,@grafana/identity-access-team,false,false,false
panelMonitoring,GA,@grafana/dataviz-squad,false,false,true
enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false
disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
88 alertingInsights GA @grafana/alerting-squad false false true
89 externalCorePlugins experimental @grafana/plugins-platform-backend false false false
90 pluginsAPIMetrics experimental @grafana/plugins-platform-backend false false true
91 externalServiceAccounts preview GA @grafana/identity-access-team false false false
92 panelMonitoring GA @grafana/dataviz-squad false false true
93 enableNativeHTTPHistogram experimental @grafana/grafana-backend-services-squad false true false
94 disableClassicHTTPHistogram experimental @grafana/grafana-backend-services-squad false true false

View File

@ -1234,14 +1234,18 @@
{
"metadata": {
"name": "externalServiceAccounts",
"resourceVersion": "1718727528075",
"creationTimestamp": "2023-09-28T07:26:37Z"
"resourceVersion": "1726562284896",
"creationTimestamp": "2023-09-28T07:26:37Z",
"annotations": {
"grafana.app/updatedTimestamp": "2024-09-17 08:38:04.896869045 +0000 UTC"
}
},
"spec": {
"description": "Automatic service account and token setup for plugins",
"stage": "preview",
"stage": "GA",
"codeowner": "@grafana/identity-access-team",
"hideFromAdminPage": true
"hideFromAdminPage": true,
"expression": "true"
}
},
{

View File

@ -387,7 +387,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
})
}
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDashboardRestoreUI) && c.SignedInUser.GetOrgRole() == org.RoleAdmin {
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDashboardRestoreUI) && (c.SignedInUser.GetOrgRole() == org.RoleAdmin || c.IsGrafanaAdmin) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Recently deleted",
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",

View File

@ -36,4 +36,12 @@ func (a *recordingAccessControlFake) WithoutResolvers() accesscontrol.AccessCont
panic("unimplemented")
}
func (a *recordingAccessControlFake) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) {
return false, nil
}
func (a *recordingAccessControlFake) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) {
return nil, nil
}
var _ accesscontrol.AccessControl = &recordingAccessControlFake{}

View File

@ -8,7 +8,6 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/apimachinery/identity"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
@ -138,6 +137,14 @@ func (a *recordingAccessControlFake) IsDisabled() bool {
return a.Disabled
}
func (a *recordingAccessControlFake) Check(ctx context.Context, in ac.CheckRequest) (bool, error) {
return false, nil
}
func (a *recordingAccessControlFake) ListObjects(ctx context.Context, in ac.ListObjectsRequest) ([]string, error) {
return nil, nil
}
var _ ac.AccessControl = &recordingAccessControlFake{}
type fakeRuleAccessControlService struct {

View File

@ -98,6 +98,15 @@ func TestLoader_Load(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Category: "cloud",
Annotations: true,
@ -141,6 +150,15 @@ func TestLoader_Load(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Executable: "test",
Backend: true,
@ -195,6 +213,9 @@ func TestLoader_Load(t *testing.T) {
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Includes: []*plugins.Includes{
{
@ -228,6 +249,12 @@ func TestLoader_Load(t *testing.T) {
Slug: "nginx-datasource",
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
@ -266,6 +293,15 @@ func TestLoader_Load(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: true,
State: plugins.ReleaseStateAlpha,
@ -318,6 +354,15 @@ func TestLoader_Load(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: true,
State: plugins.ReleaseStateAlpha,
@ -423,6 +468,15 @@ func TestLoader_Load(t *testing.T) {
GrafanaDependency: ">=8.0.0",
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Includes: []*plugins.Includes{
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"},
@ -497,6 +551,15 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
IAM: &pfs.IAM{
Permissions: []pfs.Permission{
@ -599,6 +662,15 @@ func TestLoader_Load_CustomSource(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "3.x.x",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "cdn/plugin")),
@ -671,6 +743,15 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: true,
Executable: "test",
@ -767,6 +848,15 @@ func TestLoader_Load_RBACReady(t *testing.T) {
GrafanaVersion: "*",
GrafanaDependency: ">=8.0.0",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Includes: []*plugins.Includes{},
Roles: []plugins.RoleRegistration{
@ -840,10 +930,18 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
},
Version: "1.0.0",
},
State: plugins.ReleaseStateAlpha,
Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}},
Backend: true,
Executable: "test",
State: plugins.ReleaseStateAlpha,
Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}, Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
}},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: true,
Executable: "test",
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.ClassExternal,
@ -913,6 +1011,15 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Includes: []*plugins.Includes{
{Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-connections"},
@ -994,6 +1101,9 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Includes: []*plugins.Includes{
{Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-connections"},
@ -1001,6 +1111,12 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
{Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-panel"},
{Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource"},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, pluginDir1),
@ -1208,6 +1324,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: true,
},
@ -1241,6 +1366,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Module: "public/plugins/test-datasource/nested/module.js",
@ -1336,6 +1470,9 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
GrafanaVersion: "7.0.0",
GrafanaDependency: ">=7.0.0",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Includes: []*plugins.Includes{
{
@ -1382,6 +1519,12 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
Slug: "lots-of-stats",
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
Backend: false,
},
Module: "public/plugins/myorgid-simple-app/module.js",
@ -1421,6 +1564,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
GrafanaDependency: ">=7.0.0",
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
Extensions: plugins.ExtensionsDependencies{
ExposedComponents: []string{},
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Module: "public/plugins/myorgid-simple-app/child/module.js",

View File

@ -26,7 +26,9 @@ const (
)
type ServiceAccountsService struct {
acService accesscontrol.Service
acService accesscontrol.Service
permissions accesscontrol.ServiceAccountPermissionsService
store store
log log.Logger
backgroundLog log.Logger
@ -44,7 +46,8 @@ func ProvideServiceAccountsService(
kvStore kvstore.KVStore,
userService user.Service,
orgService org.Service,
accesscontrolService accesscontrol.Service,
acService accesscontrol.Service,
permissions accesscontrol.ServiceAccountPermissionsService,
) (*ServiceAccountsService, error) {
serviceAccountsStore := database.ProvideServiceAccountsStore(
cfg,
@ -55,13 +58,14 @@ func ProvideServiceAccountsService(
orgService,
)
s := &ServiceAccountsService{
acService: accesscontrolService,
acService: acService,
permissions: permissions,
store: serviceAccountsStore,
log: log.New("serviceaccounts"),
backgroundLog: log.New("serviceaccounts.background"),
}
if err := RegisterRoles(accesscontrolService); err != nil {
if err := RegisterRoles(acService); err != nil {
s.log.Error("Failed to register roles", "error", err)
}
@ -179,7 +183,10 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI
if err := sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID); err != nil {
return err
}
return sa.acService.DeleteUserPermissions(ctx, orgID, serviceAccountID)
if err := sa.acService.DeleteUserPermissions(ctx, orgID, serviceAccountID); err != nil {
return err
}
return sa.permissions.DeleteResourcePermissions(ctx, orgID, fmt.Sprintf("%d", serviceAccountID))
}
func (sa *ServiceAccountsService) EnableServiceAccount(ctx context.Context, orgID, serviceAccountID int64, enable bool) error {

View File

@ -119,7 +119,8 @@ func (f *SecretsCheckerFake) CheckTokens(ctx context.Context) error {
func TestProvideServiceAccount_DeleteServiceAccount(t *testing.T) {
storeMock := newServiceAccountStoreFake()
acSvc := actest.FakeService{}
svc := ServiceAccountsService{acSvc, storeMock, log.New("test"), log.New("background.test"), &SecretsCheckerFake{}, false, 0}
pSvc := &actest.FakePermissionsService{}
svc := ServiceAccountsService{acSvc, pSvc, storeMock, log.NewNopLogger(), log.NewNopLogger(), &SecretsCheckerFake{}, false, 0}
testOrgId := 1
t.Run("should create service account", func(t *testing.T) {

View File

@ -14,8 +14,9 @@ import (
func Test_UsageStats(t *testing.T) {
acSvc := actest.FakeService{}
pSvc := actest.FakePermissionsService{}
storeMock := newServiceAccountStoreFake()
svc := ServiceAccountsService{acSvc, storeMock, log.New("test"), log.New("background-test"), &SecretsCheckerFake{}, true, 5}
svc := ServiceAccountsService{acSvc, &pSvc, storeMock, log.NewNopLogger(), log.NewNopLogger(), &SecretsCheckerFake{}, true, 5}
err := svc.DeleteServiceAccount(context.Background(), 1, 1)
require.NoError(t, err)

View File

@ -0,0 +1,96 @@
package accesscontrol
import (
"fmt"
"strconv"
"strings"
"xorm.io/xorm"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
const (
orphanedServiceAccountsPermissions = "delete orphaned service account permissions"
)
func AddOrphanedMigrations(mg *migrator.Migrator) {
mg.AddMigration(orphanedServiceAccountsPermissions, &orphanedServiceAccountPermissions{})
}
var _ migrator.CodeMigration = new(alertingScopeRemovalMigrator)
type orphanedServiceAccountPermissions struct {
migrator.MigrationBase
}
func (m *orphanedServiceAccountPermissions) SQL(dialect migrator.Dialect) string {
return CodeMigrationSQL
}
func (m *orphanedServiceAccountPermissions) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
var idents []string
// find all permissions that are scopes directly to a service account
err := sess.SQL("SELECT DISTINCT p.identifier FROM permission AS p WHERE p.kind = 'serviceaccounts' AND NOT p.identifier = '*'").Find(&idents)
if err != nil {
return fmt.Errorf("failed to fetch permissinos scoped to service accounts: %w", err)
}
ids := make([]int64, 0, len(idents))
for _, id := range idents {
id, err := strconv.ParseInt(id, 10, 64)
if err == nil {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return nil
}
// Then find all existing service accounts
raw := "SELECT u.id FROM " + mg.Dialect.Quote("user") + " AS u WHERE u.is_service_account AND u.id IN(?" + strings.Repeat(",?", len(ids)-1) + ")"
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
var existingIDs []int64
err = sess.SQL(raw, args...).Find(&existingIDs)
if err != nil {
return fmt.Errorf("failed to fetch existing service accounts: %w", err)
}
existing := make(map[int64]struct{}, len(existingIDs))
for _, id := range existingIDs {
existing[id] = struct{}{}
}
// filter out orphaned permissions
var orphaned []string
for _, id := range ids {
if _, ok := existing[id]; !ok {
orphaned = append(orphaned, strconv.FormatInt(id, 10))
}
}
if len(orphaned) == 0 {
return nil
}
// delete all orphaned permissions
rawDelete := "DELETE FROM permission AS p WHERE p.kind = 'serviceaccounts' AND p.identifier IN(?" + strings.Repeat(",?", len(orphaned)-1) + ")"
deleteArgs := make([]any, 0, len(orphaned)+1)
deleteArgs = append(deleteArgs, rawDelete)
for _, id := range orphaned {
deleteArgs = append(deleteArgs, id)
}
_, err = sess.Exec(deleteArgs...)
if err != nil {
return fmt.Errorf("failed to delete orphaned service accounts: %w", err)
}
return nil
}

View File

@ -131,6 +131,8 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
ualert.AddReceiverActionScopesMigration(mg)
ualert.AddRuleMetadata(mg)
accesscontrol.AddOrphanedMigrations(mg)
}
func addStarMigrations(mg *Migrator) {

View File

@ -289,7 +289,7 @@ func (ss *SQLStore) initEngine(engine *xorm.Engine) error {
}
if engine == nil {
// Ensure that parseTime is enabled for MySQL
if ss.features.IsEnabledGlobally(featuremgmt.FlagMysqlParseTime) && ss.dbCfg.Type == migrator.MySQL && !strings.Contains(ss.dbCfg.ConnectionString, "parseTime=") {
if ss.features.IsEnabledGlobally(featuremgmt.FlagMysqlParseTime) && strings.Contains(ss.dbCfg.Type, migrator.MySQL) && !strings.Contains(ss.dbCfg.ConnectionString, "parseTime=") {
if strings.Contains(ss.dbCfg.ConnectionString, "?") {
ss.dbCfg.ConnectionString += "&parseTime=true"
} else {

View File

@ -93,6 +93,13 @@ func TestInitEngine_ParseTimeInConnectionString(t *testing.T) {
featureEnabled: true,
expectedConnection: "user:password@tcp(localhost:3306)/existingparams?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true&charset=utf8&parseTime=true",
},
{
name: "MySQL with feature enabled",
connectionString: "mysql://user:password@localhost:3306/existingparams?charset=utf8",
dbType: "mysqlWithHooks",
featureEnabled: true,
expectedConnection: "user:password@tcp(localhost:3306)/existingparams?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true&charset=utf8&parseTime=true",
},
{
name: "MySQL with feature disabled",
connectionString: "mysql://user:password@localhost:3306/disabled",

View File

@ -20,6 +20,11 @@ type ZanzanaSettings struct {
ListenHTTP bool
// OpenFGA http server address which allows to connect with fga cli
HttpAddr string
// Number of check requests running concurrently
ConcurrentChecks int64
// If enabled, authorization cheks will be only performed by zanzana.
// This bypasses the performance comparison with the legacy system.
ZanzanaOnlyEvaluation bool
}
func (cfg *Cfg) readZanzanaSettings() {
@ -38,6 +43,8 @@ func (cfg *Cfg) readZanzanaSettings() {
s.Addr = sec.Key("address").MustString("")
s.ListenHTTP = sec.Key("listen_http").MustBool(false)
s.HttpAddr = sec.Key("http_addr").MustString("127.0.0.1:8080")
s.ConcurrentChecks = sec.Key("concurrent_checks").MustInt64(10)
s.ZanzanaOnlyEvaluation = sec.Key("zanzana_only_evaluation").MustBool(false)
cfg.Zanzana = s
}

View File

@ -10,9 +10,9 @@ require (
github.com/stretchr/testify v1.9.0
gocloud.dev v0.39.0
google.golang.org/grpc v1.66.0
k8s.io/apimachinery v0.31.0
k8s.io/apiserver v0.31.0
k8s.io/client-go v0.31.0
k8s.io/apimachinery v0.31.1
k8s.io/apiserver v0.31.1
k8s.io/client-go v0.31.1
k8s.io/klog/v2 v2.130.1
)
@ -98,8 +98,8 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.31.0 // indirect
k8s.io/component-base v0.31.0 // indirect
k8s.io/api v0.31.1 // indirect
k8s.io/component-base v0.31.1 // indirect
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect

View File

@ -467,16 +467,16 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo=
k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY=
k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk=
k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8=
k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU=
k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs=
k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo=
k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c=
k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM=
k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8=
k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=

View File

@ -14,7 +14,7 @@ require (
gocloud.dev v0.39.0
google.golang.org/grpc v1.66.0
google.golang.org/protobuf v1.34.2
k8s.io/apimachinery v0.31.0
k8s.io/apimachinery v0.31.1
)
require (
@ -56,7 +56,7 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiserver v0.31.0 // indirect
k8s.io/apiserver v0.31.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect

View File

@ -18,13 +18,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -60,6 +64,7 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": [
"alerts",
@ -73,7 +78,10 @@
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -104,13 +112,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -163,6 +175,7 @@
"path": "public/app/plugins/datasource/azuremonitor/img/azure_monitor_cpu.png"
}
],
"version": "11.3.0-pre",
"updated": "",
"keywords": [
"azure",
@ -175,7 +188,10 @@
"dependencies": {
"grafanaDependency": "\u003e=10.3.0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -206,13 +222,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -243,13 +263,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -280,6 +304,7 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": [
"financial",
@ -291,7 +316,10 @@
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -322,13 +350,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -359,6 +391,7 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": [
"aws",
@ -368,7 +401,10 @@
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -399,13 +435,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -436,13 +476,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -478,15 +522,24 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": [
"elasticsearch"
"elasticsearch",
"datasource",
"database",
"logs",
"nosql",
"traces"
]
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -517,13 +570,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -554,13 +611,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -591,13 +652,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -628,13 +693,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -665,13 +734,17 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -707,6 +780,7 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": [
"grafana",
@ -721,7 +795,10 @@
"dependencies": {
"grafanaDependency": "\u003e=10.3.0-0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -752,13 +829,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -798,13 +879,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -835,13 +920,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -872,6 +961,7 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": [
"distribution",
@ -883,7 +973,10 @@
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -914,13 +1007,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -960,13 +1057,17 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "\u003e=10.3.0-0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -997,13 +1098,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1043,13 +1148,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1080,13 +1189,17 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "\u003e=10.4.0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1117,13 +1230,17 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": ">=10.4.0",
"grafanaDependency": "\u003e=10.4.0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1154,13 +1271,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1191,13 +1312,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1228,13 +1353,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1270,6 +1399,7 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": [
"grafana",
@ -1281,7 +1411,10 @@
"dependencies": {
"grafanaDependency": "\u003e=10.3.0-0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1312,13 +1445,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1349,13 +1486,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1391,13 +1532,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1428,13 +1573,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1465,13 +1614,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1502,13 +1655,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1539,13 +1696,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1576,13 +1737,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1618,13 +1783,17 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "\u003e=10.3.0-0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1655,13 +1824,17 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "\u003e=10.3.0-0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1692,13 +1865,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1729,13 +1906,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1766,13 +1947,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1803,13 +1988,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1840,13 +2029,17 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1877,6 +2070,7 @@
},
"build": {},
"screenshots": null,
"version": "",
"updated": "",
"keywords": [
"scatter",
@ -1886,7 +2080,10 @@
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,
@ -1922,13 +2119,17 @@
},
"build": {},
"screenshots": null,
"version": "11.3.0-pre",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "\u003e=10.3.0-0",
"grafanaVersion": "*",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": []
}
},
"latestVersion": "",
"hasUpdate": false,

View File

@ -3,7 +3,7 @@ package util
import (
"encoding/json"
"github.com/jmespath/go-jmespath"
"github.com/jmespath-community/go-jmespath"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
)
@ -86,7 +86,7 @@ func searchJSONForAttr(attributePath string, data any) (any, error) {
}
// Copy the data to a new variable
var jsonData = data
jsonData := data
// If the data is a byte slice, try to unmarshal it into a JSON object
if dataBytes, ok := data.([]byte); ok {

View File

@ -153,3 +153,34 @@ func TestSearchJSONForEmail(t *testing.T) {
})
}
}
func TestSearchJSONForStringAttr(t *testing.T) {
t.Parallel()
tests := []struct {
Name string
SearchObject any
AttributePath string
ExpectedResult string
}{
{
Name: "Case insensitive contains using lower function from works correctly",
SearchObject: map[string]any{
"groups": []string{
"fOO",
},
},
AttributePath: "contains(groups[*].lower(@) ,lower('FOO')) && 'success' || 'failure'",
ExpectedResult: "success",
},
}
for _, test := range tests {
test := test
t.Run(test.Name, func(t *testing.T) {
t.Parallel()
actualResult, err := util.SearchJSONForStringAttr(test.AttributePath, test.SearchObject)
require.NoError(t, err)
require.Equal(t, test.ExpectedResult, actualResult)
})
}
}

View File

@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import { Menu, Dropdown, useStyles2, useTheme2, ToolbarButton } from '@grafana/ui';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
import { useSelector } from 'app/types';
@ -22,6 +22,8 @@ export const QuickAdd = ({}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [isSmallScreen, setIsSmallScreen] = useState(!window.matchMedia(`(min-width: ${breakpoint}px)`).matches);
const createActions = useMemo(() => findCreateActions(navBarTree), [navBarTree]);
const isSingleTopNav = config.featureToggles.singleTopNav;
const showQuickAdd = createActions.length > 0 && (!isSingleTopNav || !isSmallScreen);
useMediaQueryChange({
breakpoint,
@ -45,7 +47,7 @@ export const QuickAdd = ({}: Props) => {
);
};
return createActions.length > 0 ? (
return showQuickAdd ? (
<>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={setIsOpen}>
<ToolbarButton

View File

@ -23,7 +23,6 @@ import { TOP_BAR_LEVEL_HEIGHT } from '../types';
import { SignInLink } from './SignInLink';
import { TopNavBarMenu } from './TopNavBarMenu';
import { TopSearchBarCommandPaletteTrigger } from './TopSearchBarCommandPaletteTrigger';
import { TopSearchBarSection } from './TopSearchBarSection';
interface Props {
sectionNav: NavModelItem;
@ -52,7 +51,7 @@ export const SingleTopBar = memo(function SingleTopBar({
return (
<div className={styles.layout}>
<TopSearchBarSection>
<Stack minWidth={0} gap={0.5} alignItems="center">
{!menuDockedAndOpen && (
<ToolbarButton narrow onClick={onToggleMegaMenu} tooltip={t('navigation.megamenu.open', 'Open menu')}>
<Stack gap={0} alignItems="center">
@ -63,9 +62,9 @@ export const SingleTopBar = memo(function SingleTopBar({
)}
<Breadcrumbs breadcrumbs={breadcrumbs} className={styles.breadcrumbsWrapper} />
<ScopesSelector />
</TopSearchBarSection>
</Stack>
<TopSearchBarSection align="right">
<Stack gap={0.5} alignItems="center">
<TopSearchBarCommandPaletteTrigger />
<QuickAdd />
{enrichedHelpNode && (
@ -88,7 +87,7 @@ export const SingleTopBar = memo(function SingleTopBar({
<ToolbarButton className={styles.kioskToggle} onClick={onToggleKioskMode} narrow aria-label="Enable kiosk mode">
<Icon name="angle-up" size="xl" />
</ToolbarButton>
</TopSearchBarSection>
</Stack>
</div>
);
});
@ -97,15 +96,15 @@ const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => ({
layout: css({
height: TOP_BAR_LEVEL_HEIGHT,
display: 'flex',
gap: theme.spacing(1),
gap: theme.spacing(2),
alignItems: 'center',
padding: theme.spacing(0, 1),
paddingLeft: menuDockedAndOpen ? theme.spacing(3.5) : theme.spacing(0.75),
borderBottom: `1px solid ${theme.colors.border.weak}`,
justifyContent: 'space-between',
[theme.breakpoints.up('sm')]: {
gridTemplateColumns: '2fr minmax(240px, 1fr)', // TODO probably change these values
[theme.breakpoints.up('lg')]: {
gridTemplateColumns: '2fr minmax(440px, 1fr)',
display: 'grid',
justifyContent: 'flex-start',
@ -115,7 +114,7 @@ const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => ({
display: 'flex',
overflow: 'hidden',
[theme.breakpoints.down('sm')]: {
minWidth: '50%',
minWidth: '40%',
},
}),
img: css({

View File

@ -3,6 +3,7 @@ import { useKBar, VisualState } from 'kbar';
import { useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getInputStyles, Icon, Text, ToolbarButton, useStyles2, useTheme2 } from '@grafana/ui';
import { focusCss } from '@grafana/ui/src/themes/mixins';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
@ -11,12 +12,13 @@ import { getModKey } from 'app/core/utils/browser';
export function TopSearchBarCommandPaletteTrigger() {
const theme = useTheme2();
const isSingleTopNav = config.featureToggles.singleTopNav;
const { query: kbar } = useKBar((kbarState) => ({
kbarSearchQuery: kbarState.searchQuery,
kbarIsOpen: kbarState.visualState === VisualState.showing,
}));
const breakpoint = theme.breakpoints.values.sm;
const breakpoint = isSingleTopNav ? theme.breakpoints.values.lg : theme.breakpoints.values.sm;
const [isSmallScreen, setIsSmallScreen] = useState(!window.matchMedia(`(min-width: ${breakpoint}px)`).matches);

View File

@ -127,7 +127,7 @@ export const LdapDrawerComponent = ({
<Drawer title={t('ldap-drawer.title', 'Advanced settings')} onClose={onClose}>
<CollapsableSection label={t('ldap-drawer.misc-section.label', 'Misc')} isOpen={true}>
<Field
label={t('ldap-drawer.misc-section.allow-sign-up-label', 'Allow sign up')}
label={t('ldap-drawer.misc-section.allow-sign-up-label', 'Allow sign-up')}
description={t(
'ldap-drawer.misc-section.allow-sign-up-descrition',
'If not enabled, only existing Grafana users can log in using LDAP'

View File

@ -19,6 +19,19 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => {
version: info.version,
angular: angular ?? { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [],
addedComponents: [],
extensionPoints: [],
exposedComponents: [],
},
dependencies: {
grafanaVersion: '',
plugins: [],
extensions: {
exposedComponents: [],
},
},
};
});

View File

@ -49,6 +49,19 @@ describe('getRuleOrigin', () => {
preload: true,
angular: { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [],
addedComponents: [],
extensionPoints: [],
exposedComponents: [],
},
dependencies: {
grafanaVersion: '',
plugins: [],
extensions: {
exposedComponents: [],
},
},
},
};
const rule = mockCombinedRule({

View File

@ -4,11 +4,14 @@ import { useLocation, useParams } from 'react-router-dom-v5-compat';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { FilterInput, useStyles2 } from '@grafana/ui';
import { config, reportInteraction } from '@grafana/runtime';
import { LinkButton, FilterInput, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { getConfig } from 'app/core/config';
import { Trans } from 'app/core/internationalization';
import { useDispatch } from 'app/types';
import { contextSrv } from '../../core/services/context_srv';
import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel';
import { useSearchStateManager } from '../search/state/SearchStateManager';
import { getSearchPlaceholder } from '../search/tempI18nPhrases';
@ -81,6 +84,7 @@ const BrowseDashboardsPage = memo(() => {
const { data: rootFolder } = useGetFolderQuery('general');
let folder = folderDTO ? folderDTO : rootFolder;
const { canEditFolders, canEditDashboards, canCreateDashboards, canCreateFolders } = getFolderPermissions(folder);
const hasAdminRights = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin;
const showEditTitle = canEditFolders && folderUID;
const canSelect = canEditFolders || canEditDashboards;
@ -104,6 +108,12 @@ const BrowseDashboardsPage = memo(() => {
}
};
const handleButtonClickToRecentlyDeleted = () => {
reportInteraction('grafana_browse_dashboards_page_button_to_recently_deleted', {
origin: window.location.pathname === getConfig().appSubUrl + '/dashboards' ? 'Dashboards' : 'Folder view',
});
};
return (
<Page
navId="dashboards/browse"
@ -111,6 +121,15 @@ const BrowseDashboardsPage = memo(() => {
onEditTitle={showEditTitle ? onEditTitle : undefined}
actions={
<>
{config.featureToggles.dashboardRestore && config.featureToggles.dashboardRestoreUI && hasAdminRights && (
<LinkButton
variant="secondary"
href={getConfig().appSubUrl + '/dashboard/recently-deleted'}
onClick={handleButtonClickToRecentlyDeleted}
>
<Trans i18nKey="browse-dashboards.actions.button-to-recently-deleted">Recently deleted</Trans>
</LinkButton>
)}
{folderDTO && <FolderActionsButton folder={folderDTO} />}
{(canCreateDashboards || canCreateFolders) && (
<CreateNewButton

View File

@ -30,6 +30,6 @@ function setup() {
describe('SupportSnapshot', () => {
it('Can render', async () => {
setup();
expect(await screen.findByRole('button', { name: /Dashboard \([\d\.]+ KiB\)/ })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: /Download snapshot \([\d\.]+ KiB\)/ })).toBeInTheDocument();
});
});

View File

@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { useMemo, useEffect } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { PanelPlugin, GrafanaTheme2, FeatureState } from '@grafana/data';
import { PanelPlugin, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
Drawer,
@ -11,17 +11,16 @@ import {
CodeEditor,
useStyles2,
Field,
HorizontalGroup,
InlineSwitch,
Button,
Spinner,
Alert,
FeatureBadge,
Select,
ClipboardButton,
Icon,
Stack,
} from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { PanelModel } from 'app/features/dashboard/state';
import { AccessControlAction } from 'app/types';
@ -76,7 +75,6 @@ export function HelpWizard({ panel, plugin, onClose }: Props) {
subtitle={
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1}>
<FeatureBadge featureState={FeatureState.beta} />
<a
href="https://grafana.com/docs/grafana/latest/troubleshooting/"
target="blank"
@ -87,13 +85,17 @@ export function HelpWizard({ panel, plugin, onClose }: Props) {
</a>
</Stack>
<span className="muted">
To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The
snapshot contains query response data and panel settings.
<Trans i18nKey="help-wizard.troubleshooting-help">
To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The
snapshot contains query response data and panel settings.
</Trans>
</span>
{hasSupportBundleAccess && (
<span className="muted">
You can also retrieve a support bundle containing information concerning your Grafana instance and
configured datasources in the <a href="/support-bundles">support bundles section</a>.
<Trans i18nKey="help-wizard.support-bundle">
You can also retrieve a support bundle containing information concerning your Grafana instance and
configured datasources in the <a href="/support-bundles">support bundles section</a>.
</Trans>
</span>
)}
</Stack>
@ -150,10 +152,10 @@ export function HelpWizard({ panel, plugin, onClose }: Props) {
{currentTab === SnapshotTab.Support && (
<>
<Field
label="Randomize data"
label="Obfuscate data"
description="Modify the original data to hide sensitve information. Note the lengths will stay the same, and duplicate values will be equal."
>
<HorizontalGroup>
<Stack direction="row" gap={1}>
<InlineSwitch
label="Labels"
id="randomize-labels"
@ -175,27 +177,27 @@ export function HelpWizard({ panel, plugin, onClose }: Props) {
value={Boolean(randomize.values)}
onChange={() => service.onToggleRandomize('values')}
/>
</HorizontalGroup>
</Stack>
</Field>
<Field label="Support snapshot" description={`Panel: ${panelTitle}`}>
<Stack>
<Button icon="download-alt" onClick={service.onDownloadDashboard}>
Dashboard ({snapshotSize})
<Trans i18nKey="help-wizard.download-snapshot">Download snapshot</Trans> ({snapshotSize})
</Button>
<ClipboardButton
icon="github"
getText={service.onGetMarkdownForClipboard}
title="Copy a complete GitHub comment to the clipboard"
>
Copy to clipboard
<Trans i18nKey="help-wizard.github-comment">Copy Github comment</Trans>
</ClipboardButton>
<Button
icon="eye"
onClick={service.onPreviewDashboard}
variant="secondary"
title="Open support snapshot dashboard in a new tab"
>
Preview
<Trans i18nKey="help-wizard.preview-snapshot">Preview snapshot</Trans>
</Button>
</Stack>
</Field>

View File

@ -43,6 +43,9 @@ export default {
grafanaDependency: '>=7.3.0',
grafanaVersion: '7.3',
plugins: [],
extensions: {
exposedComponents: [],
},
},
info: {
links: [],

View File

@ -1,15 +1,62 @@
import React from 'react';
import { firstValueFrom } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { isGrafanaDevMode } from '../utils';
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
jest.mock('../utils', () => ({
...jest.requireActual('../utils'),
// Manually set the dev mode to false
// (to make sure that by default we are testing a production scneario)
isGrafanaDevMode: jest.fn().mockReturnValue(false),
}));
describe('AddedComponentsRegistry', () => {
const consoleWarn = jest.fn();
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
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: [],
},
};
beforeEach(() => {
global.console.warn = consoleWarn;
consoleWarn.mockReset();
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return empty registry when no extensions registered', async () => {
@ -20,15 +67,14 @@ describe('AddedComponentsRegistry', () => {
});
it('should be possible to register added components in the registry', async () => {
const pluginId = 'grafana-basic-app';
const id = `${pluginId}/hello-world/v1`;
const extensionPointId = `${pluginId}/hello-world/v1`;
const reactiveRegistry = new AddedComponentsRegistry();
reactiveRegistry.register({
pluginId,
configs: [
{
targets: [id],
targets: [extensionPointId],
title: 'not important',
description: 'not important',
component: () => React.createElement('div', null, 'Hello World'),
@ -39,15 +85,17 @@ describe('AddedComponentsRegistry', () => {
const registry = await reactiveRegistry.getState();
expect(Object.keys(registry)).toHaveLength(1);
expect(registry[id][0]).toMatchObject({
expect(registry[extensionPointId][0]).toMatchObject({
pluginId,
title: 'not important',
description: 'not important',
});
});
it('should be possible to asynchronously register component extensions for the same extension point (different plugins)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const extensionPointId = 'grafana/alerting/home';
const reactiveRegistry = new AddedComponentsRegistry();
// Register extensions for the first plugin
@ -65,7 +113,7 @@ describe('AddedComponentsRegistry', () => {
const registry1 = await reactiveRegistry.getState();
expect(Object.keys(registry1)).toHaveLength(1);
expect(registry1['grafana/alerting/home'][0]).toMatchObject({
expect(registry1[extensionPointId][0]).toMatchObject({
pluginId: pluginId1,
title: 'Component 1 title',
description: 'Component 1 description',
@ -78,7 +126,7 @@ describe('AddedComponentsRegistry', () => {
{
title: 'Component 2 title',
description: 'Component 2 description',
targets: ['grafana/alerting/home'],
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
@ -86,7 +134,7 @@ describe('AddedComponentsRegistry', () => {
const registry2 = await reactiveRegistry.getState();
expect(Object.keys(registry2)).toHaveLength(1);
expect(registry2['grafana/alerting/home']).toEqual(
expect(registry2[extensionPointId]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId1,
@ -105,6 +153,8 @@ describe('AddedComponentsRegistry', () => {
it('should be possible to asynchronously register component extensions for a different extension points (different plugin)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const extensionPointId1 = 'grafana/alerting/home';
const extensionPointId2 = 'grafana/user/profile/tab';
const reactiveRegistry = new AddedComponentsRegistry();
// Register extensions for the first plugin
@ -114,7 +164,7 @@ describe('AddedComponentsRegistry', () => {
{
title: 'Component 1 title',
description: 'Component 1 description',
targets: ['grafana/alerting/home'],
targets: [extensionPointId1],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
@ -122,7 +172,7 @@ describe('AddedComponentsRegistry', () => {
const registry1 = await reactiveRegistry.getState();
expect(registry1).toEqual({
'grafana/alerting/home': expect.arrayContaining([
[extensionPointId1]: expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId1,
title: 'Component 1 title',
@ -138,7 +188,7 @@ describe('AddedComponentsRegistry', () => {
{
title: 'Component 2 title',
description: 'Component 2 description',
targets: ['grafana/user/profile/tab'],
targets: [extensionPointId2],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
@ -147,14 +197,14 @@ describe('AddedComponentsRegistry', () => {
const registry2 = await reactiveRegistry.getState();
expect(registry2).toEqual({
'grafana/alerting/home': expect.arrayContaining([
[extensionPointId1]: expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId1,
title: 'Component 1 title',
description: 'Component 1 description',
}),
]),
'grafana/user/profile/tab': expect.arrayContaining([
[extensionPointId2]: expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId2,
title: 'Component 2 title',
@ -165,8 +215,8 @@ describe('AddedComponentsRegistry', () => {
});
it('should be possible to asynchronously register component extensions for the same extension point (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
// Register extensions for the first extension point
reactiveRegistry.register({
@ -175,20 +225,20 @@ describe('AddedComponentsRegistry', () => {
{
title: 'Component 1 title',
description: 'Component 1 description',
targets: ['grafana/alerting/home'],
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Hello World1'),
},
{
title: 'Component 2 title',
description: 'Component 2 description',
targets: ['grafana/alerting/home'],
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Hello World2'),
},
],
});
const registry1 = await reactiveRegistry.getState();
expect(registry1).toEqual({
'grafana/alerting/home': expect.arrayContaining([
[extensionPointId]: expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId,
title: 'Component 1 title',
@ -204,8 +254,9 @@ describe('AddedComponentsRegistry', () => {
});
it('should be possible to register one extension component targeting multiple extension points', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedComponentsRegistry();
const extensionPointId1 = 'grafana/alerting/home';
const extensionPointId2 = 'grafana/user/profile/tab';
reactiveRegistry.register({
pluginId: pluginId,
@ -213,21 +264,21 @@ describe('AddedComponentsRegistry', () => {
{
title: 'Component 1 title',
description: 'Component 1 description',
targets: ['grafana/alerting/home', 'grafana/user/profile/tab'],
targets: [extensionPointId1, extensionPointId2],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
const registry1 = await reactiveRegistry.getState();
expect(registry1).toEqual({
'grafana/alerting/home': expect.arrayContaining([
[extensionPointId1]: expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId,
title: 'Component 1 title',
description: 'Component 1 description',
}),
]),
'grafana/user/profile/tab': expect.arrayContaining([
[extensionPointId2]: expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId,
title: 'Component 1 title',
@ -239,7 +290,9 @@ describe('AddedComponentsRegistry', () => {
it('should notify subscribers when the registry changes', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'another-plugin';
const pluginId2 = 'myorg-extensions-app';
const extensionPointId1 = 'grafana/alerting/home';
const extensionPointId2 = 'grafana/user/profile/tab';
const reactiveRegistry = new AddedComponentsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -252,7 +305,7 @@ describe('AddedComponentsRegistry', () => {
{
title: 'Component 1 title',
description: 'Component 1 description',
targets: ['grafana/alerting/home'],
targets: [extensionPointId1],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
@ -266,7 +319,7 @@ describe('AddedComponentsRegistry', () => {
{
title: 'Component 2 title',
description: 'Component 2 description',
targets: ['grafana/user/profile/tab'],
targets: [extensionPointId2],
component: () => React.createElement('div', null, 'Hello World2'),
},
],
@ -277,14 +330,14 @@ describe('AddedComponentsRegistry', () => {
const registry = subscribeCallback.mock.calls[2][0];
expect(registry).toEqual({
'grafana/alerting/home': expect.arrayContaining([
[extensionPointId1]: expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId1,
title: 'Component 1 title',
description: 'Component 1 description',
}),
]),
'grafana/user/profile/tab': expect.arrayContaining([
[extensionPointId2]: expect.arrayContaining([
expect.objectContaining({
pluginId: pluginId2,
title: 'Component 2 title',
@ -294,43 +347,24 @@ describe('AddedComponentsRegistry', () => {
});
});
it('should skip registering component and log a warning when id is not prefixed with plugin id or grafana', async () => {
const registry = new AddedComponentsRegistry();
registry.register({
pluginId: 'grafana-basic-app',
configs: [
{
title: 'Component 1 title',
description: 'Component 1 description',
targets: ['alerting/home'],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
expect(consoleWarn).toHaveBeenCalledWith(
"[Plugin Extensions] Could not register added component with id 'alerting/home'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id or grafana. e.g '<grafana|myorg-basic-app>/my-component-id/v1'."
);
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should log a warning when added component id is not suffixed with component version', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/test/home';
registry.register({
pluginId: 'grafana-basic-app',
pluginId,
configs: [
{
title: 'Component 1 title',
description: 'Component 1 description',
targets: ['grafana/test/home'],
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
expect(consoleWarn).toHaveBeenCalledWith(
"[Plugin Extensions] Added component with id 'grafana/test/home' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'."
`[Plugin Extensions] Added component "Component 1 title": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
);
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
@ -338,13 +372,15 @@ describe('AddedComponentsRegistry', () => {
it('should not register component when description is missing', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
pluginId: 'grafana-basic-app',
pluginId,
configs: [
{
title: 'Component 1 title',
description: '',
targets: ['grafana/alerting/home'],
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
@ -359,13 +395,15 @@ describe('AddedComponentsRegistry', () => {
it('should not register component when title is missing', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
pluginId: 'grafana-basic-app',
pluginId,
configs: [
{
title: 'Component 1 title',
description: '',
targets: ['grafana/alerting/home'],
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
@ -382,15 +420,16 @@ describe('AddedComponentsRegistry', () => {
it('should not be possible to register a component on a read-only registry', async () => {
const registry = new AddedComponentsRegistry();
const readOnlyRegistry = registry.readOnly();
const extensionPointId = 'grafana/alerting/home';
expect(() => {
readOnlyRegistry.register({
pluginId: 'grafana-basic-app',
pluginId,
configs: [
{
title: 'Component 1 title',
description: '',
targets: ['grafana/alerting/home'],
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
@ -434,4 +473,105 @@ describe('AddedComponentsRegistry', () => {
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['grafana/alerting/home']);
});
it('should not register a component added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedComponentsRegistry();
const componentConfig = {
title: 'Component title',
description: 'Component description',
targets: ['grafana/alerting/home'],
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [];
registry.register({
pluginId,
configs: [componentConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
expect(consoleWarn).toHaveBeenCalled();
});
it('should register a component added by a core Grafana in dev-mode even if the meta-info is missing', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedComponentsRegistry();
const componentConfig = {
title: 'Component title',
description: 'Component description',
targets: ['grafana/alerting/home'],
component: () => React.createElement('div', null, 'Hello World1'),
};
registry.register({
pluginId: 'grafana',
configs: [componentConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(consoleWarn).not.toHaveBeenCalled();
});
it('should register a component added by a plugin in production mode even if the meta-info is missing', async () => {
// Production mode
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
const registry = new AddedComponentsRegistry();
const componentConfig = {
title: 'Component title',
description: 'Component description',
targets: ['grafana/alerting/home'],
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [];
registry.register({
pluginId,
configs: [componentConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(consoleWarn).not.toHaveBeenCalled();
});
it('should register a component added by a plugin in dev-mode if the meta-info is present', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedComponentsRegistry();
const componentConfig = {
title: 'Component title',
description: 'Component description',
targets: ['grafana/alerting/home'],
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [componentConfig];
registry.register({
pluginId,
configs: [componentConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(consoleWarn).not.toHaveBeenCalled();
});
});

View File

@ -2,13 +2,8 @@ import { ReplaySubject } from 'rxjs';
import { PluginExtensionAddedComponentConfig } from '@grafana/data';
import { logWarning, wrapWithPluginContext } from '../utils';
import {
extensionPointEndsWithVersion,
isExtensionPointIdValid,
isGrafanaCoreExtensionPoint,
isReactComponent,
} from '../validators';
import { isAddedComponentMetaInfoMissing, isGrafanaDevMode, logWarning, wrapWithPluginContext } from '../utils';
import { extensionPointEndsWithVersion, isGrafanaCoreExtensionPoint, isReactComponent } from '../validators';
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
@ -56,18 +51,15 @@ export class AddedComponentsRegistry extends Registry<
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedComponentMetaInfoMissing(pluginId, config)) {
continue;
}
const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets];
for (const extensionPointId of extensionPointIds) {
if (!isExtensionPointIdValid(pluginId, extensionPointId)) {
logWarning(
`Could not register added component with id '${extensionPointId}'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id or grafana. e.g '<grafana|myorg-basic-app>/my-component-id/v1'.`
);
continue;
}
if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) {
logWarning(
`Added component with id '${extensionPointId}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.`
`Added component "${config.title}": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
);
}

View File

@ -1,14 +1,61 @@
import { firstValueFrom } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { isGrafanaDevMode } from '../utils';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
jest.mock('../utils', () => ({
...jest.requireActual('../utils'),
// Manually set the dev mode to false
// (to make sure that by default we are testing a production scneario)
isGrafanaDevMode: jest.fn().mockReturnValue(false),
}));
describe('AddedLinksRegistry', () => {
const originalApps = config.apps;
const consoleWarn = jest.fn();
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
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: [],
},
};
beforeEach(() => {
global.console.warn = consoleWarn;
consoleWarn.mockReset();
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return empty registry when no extensions registered', async () => {
@ -19,7 +66,6 @@ describe('AddedLinksRegistry', () => {
});
it('should be possible to register link extensions in the registry', async () => {
const pluginId = 'grafana-basic-app';
const addedLinksRegistry = new AddedLinksRegistry();
addedLinksRegistry.register({
@ -580,4 +626,109 @@ describe('AddedLinksRegistry', () => {
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']);
});
it('should not register a link added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedLinksRegistry();
const linkConfig = {
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
targets: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [];
registry.register({
pluginId,
configs: [linkConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
expect(consoleWarn).toHaveBeenCalled();
});
it('should register a link added by core Grafana in dev-mode even if the meta-info is missing', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedLinksRegistry();
const linkConfig = {
title: 'Link 1',
description: 'Link 1 description',
path: `/a/grafana/declare-incident`,
targets: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
};
registry.register({
pluginId: 'grafana',
configs: [linkConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(consoleWarn).not.toHaveBeenCalled();
});
it('should register a link added by a plugin in production mode even if the meta-info is missing', async () => {
// Production mode
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
const registry = new AddedLinksRegistry();
const linkConfig = {
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
targets: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [];
registry.register({
pluginId,
configs: [linkConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(consoleWarn).not.toHaveBeenCalled();
});
it('should register a link added by a plugin in dev-mode if the meta-info is present', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedLinksRegistry();
const linkConfig = {
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
targets: ['grafana/dashboard/panel/menu'],
configure: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [linkConfig];
registry.register({
pluginId,
configs: [linkConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(consoleWarn).not.toHaveBeenCalled();
});
});

View File

@ -3,11 +3,10 @@ import { ReplaySubject } from 'rxjs';
import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data';
import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions';
import { logWarning } from '../utils';
import { isAddedLinkMetaInfoMissing, isGrafanaDevMode, logWarning } from '../utils';
import {
extensionPointEndsWithVersion,
isConfigureFnValid,
isExtensionPointIdValid,
isGrafanaCoreExtensionPoint,
isLinkPathValid,
} from '../validators';
@ -73,18 +72,15 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedLinkMetaInfoMissing(pluginId, config)) {
continue;
}
const extensionPointIds = Array.isArray(targets) ? targets : [targets];
for (const extensionPointId of extensionPointIds) {
if (!isExtensionPointIdValid(pluginId, extensionPointId)) {
logWarning(
`Could not register added link with id '${extensionPointId}'. Reason: Target extension point id must start with grafana, plugins or plugin id.`
);
continue;
}
if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) {
logWarning(
`Added component with id '${extensionPointId}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.`
`Added link "${config.title}: it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
);
}

Some files were not shown because too many files have changed in this diff Show More