diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index f46f131eedf..3beaeca97ff 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -134,7 +134,7 @@ To integrate your OAuth2 provider with Grafana using our Generic OAuth authentic ### Configure login -Grafana can resolve a user's login from the OAuth2 ID token or user information retrieved from the OAuth2 UserInfo endpoint. +Grafana can resolve a user's login from the OAuth2 ID token, user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 access token. Grafana looks at these sources in the order listed until it finds a login. If no login is found, then the user's login is set to user's email address. @@ -146,10 +146,12 @@ Refer to the following table for information on what to configure based on how y | Another field of the OAuth2 ID token. | Set `login_attribute_path` configuration option. | | `login` or `username` field of the user information from the UserInfo endpoint. | N/A | | Another field of the user information from the UserInfo endpoint. | Set `login_attribute_path` configuration option. | +| `login` or `username` field of the OAuth2 access token. | N/A | +| Another field of the OAuth2 access token. | Set `login_attribute_path` configuration option. | ### Configure display name -Grafana can resolve a user's display name from the OAuth2 ID token or user information retrieved from the OAuth2 UserInfo endpoint. +Grafana can resolve a user's display name from the OAuth2 ID token, user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 access token. Grafana looks at these sources in the order listed until it finds a display name. If no display name is found, then user's login is displayed instead. @@ -161,10 +163,12 @@ Refer to the following table for information on what you need to configure depen | Another field of the OAuth2 ID token. | Set `name_attribute_path` configuration option. | | `name` or `display_name` field of the user information from the UserInfo endpoint. | N/A | | Another field of the user information from the UserInfo endpoint. | Set `name_attribute_path` configuration option. | +| `name` or `display_name` field of the OAuth2 access token. | N/A | +| Another field of the OAuth2 access token. | Set `name_attribute_path` configuration option. | ### Configure email address -Grafana can resolve the user's email address from the OAuth2 ID token, the user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 `/emails` endpoint. +Grafana can resolve the user's email address from the OAuth2 ID token, the user information retrieved from the OAuth2 UserInfo endpoint, the OAuth2 access token, or the OAuth2 `/emails` endpoint. Grafana looks at these sources in the order listed until an email address is found. If no email is found, then the email address of the user is set to an empty string. @@ -177,6 +181,10 @@ Refer to the following table for information on what to configure based on how t | `upn` field of the OAuth2 ID token. | N/A | | `email` field of the user information from the UserInfo endpoint. | N/A | | Another field of the user information from the UserInfo endpoint. | Set `email_attribute_path` configuration option. | +| `email` field of the OAuth2 access token. | N/A | +| `attributes` map of the OAuth2 access token. | Set `email_attribute_name` configuration option. By default, Grafana searches for email under `email:primary` key. | +| `upn` field of the OAuth2 access token. | N/A | +| Another field of the OAuth2 access token. | Set `email_attribute_path` configuration option. | | Email address marked as primary from the `/emails` endpoint of
the OAuth2 provider (obtained by appending `/emails` to the URL
configured with `api_url`) | N/A | ### Configure a refresh token @@ -199,6 +207,7 @@ The `accessTokenExpirationCheck` feature toggle has been removed in Grafana v10. Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from the auth provider upon user login. The user's role is retrieved using a [JMESPath](http://jmespath.org/examples.html) expression from the `role_attribute_path` configuration option. +Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no role is found, the expression will be evaluated using the OAuth2 access token. To map the server administrator role, use the `allow_assign_grafana_admin` configuration option. Refer to [configuration options](#configuration-options) for more information. @@ -326,6 +335,7 @@ By using Team Sync, you can link your OAuth2 groups to teams within Grafana. Thi Teams for each user are synchronized when the user logs in. Generic OAuth groups can be referenced by group ID, such as `8bab1c86-8fba-33e5-2089-1d1c80ec267d` or `myteam`. +Group information can be extracted from the OAuth2 ID token, user information from the UserInfo endpoint, or the OAuth2 access token. For information on configuring OAuth2 groups with Grafana using the `groups_attribute_path` configuration option, refer to [configuration options](#configuration-options). To learn more about Team Sync, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync/). @@ -359,46 +369,46 @@ The following table outlines the various Generic OAuth configuration options. Yo If the configuration option requires a JMESPath expression that includes a colon, enclose the entire expression in quotes to prevent parsing errors. For example `role_attribute_path: "role:view"` {{< /admonition >}} -| Setting | Required | Supported on Cloud | Description | Default | -| ---------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `enabled` | No | Yes | Enables Generic OAuth authentication. | `false` | -| `name` | No | Yes | Name that refers to the Generic OAuth authentication from the Grafana user interface. | `OAuth` | -| `icon` | No | Yes | Icon used for the Generic OAuth authentication in the Grafana user interface. | `signin` | -| `client_id` | Yes | Yes | Client ID provided by your OAuth2 app. | | -| `client_secret` | Yes | Yes | Client secret provided by your OAuth2 app. | | -| `auth_url` | Yes | Yes | Authorization endpoint of your OAuth2 provider. | | -| `token_url` | Yes | Yes | Endpoint used to obtain the OAuth2 access token. | | -| `api_url` | Yes | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | | -| `auth_style` | No | Yes | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` | -| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `user:email` | -| `empty_scopes` | No | Yes | Set to `true` to use an empty scope during authentication. | `false` | -| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Generic OAuth login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` | -| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | -| `id_token_attribute_name` | No | Yes | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` | -| `login_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login](#configure-login). | | -| `name_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name](#configure-display-name). | | -| `email_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | | -| `email_attribute_name` | No | Yes | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | `email:primary` | -| `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | | -| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | -| `skip_org_role_sync` | No | Yes | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` | -| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | -| `org_mapping` | No | No | List of comma- or space-separated `::` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | -| `allow_assign_grafana_admin` | No | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | -| `groups_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a string array of groups. | | -| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | | -| `allowed_organizations` | No | Yes | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | | -| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | | -| `team_ids` | No | Yes | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | | -| `team_ids_attribute_path` | No | Yes | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | | -| `teams_url` | No | Yes | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | | -| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | -| `tls_client_cert` | No | No | The path to the certificate. | | -| `tls_client_key` | No | No | The path to the key. | | -| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | | -| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` | -| `use_refresh_token` | No | Yes | Set to `true` to use refresh token and check access token expiration. | `false` | -| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | | +| Setting | Required | Supported on Cloud | Description | Default | +| ---------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| `enabled` | No | Yes | Enables Generic OAuth authentication. | `false` | +| `name` | No | Yes | Name that refers to the Generic OAuth authentication from the Grafana user interface. | `OAuth` | +| `icon` | No | Yes | Icon used for the Generic OAuth authentication in the Grafana user interface. | `signin` | +| `client_id` | Yes | Yes | Client ID provided by your OAuth2 app. | | +| `client_secret` | Yes | Yes | Client secret provided by your OAuth2 app. | | +| `auth_url` | Yes | Yes | Authorization endpoint of your OAuth2 provider. | | +| `token_url` | Yes | Yes | Endpoint used to obtain the OAuth2 access token. | | +| `api_url` | Yes | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | | +| `auth_style` | No | Yes | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` | +| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `user:email` | +| `empty_scopes` | No | Yes | Set to `true` to use an empty scope during authentication. | `false` | +| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Generic OAuth login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` | +| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | +| `id_token_attribute_name` | No | Yes | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` | +| `login_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login](#configure-login). | | +| `name_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name](#configure-display-name). | | +| `email_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | | +| `email_attribute_name` | No | Yes | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | `email:primary` | +| `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no role is found, the expression will be evaluated using the OAuth2 access token. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | | +| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | +| `skip_org_role_sync` | No | Yes | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` | +| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no value is returned, the expression will be evaluated using the OAuth2 access token. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | +| `org_mapping` | No | No | List of comma- or space-separated `::` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | +| `allow_assign_grafana_admin` | No | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | +| `groups_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no groups are found, the expression will be evaluated using the OAuth2 access token. The result of the evaluation should be a string array of groups. | | +| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | | +| `allowed_organizations` | No | Yes | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | | +| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | | +| `team_ids` | No | Yes | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | | +| `team_ids_attribute_path` | No | Yes | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | | +| `teams_url` | No | Yes | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | | +| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | +| `tls_client_cert` | No | No | The path to the certificate. | | +| `tls_client_key` | No | No | The path to the key. | | +| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | | +| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` | +| `use_refresh_token` | No | Yes | Set to `true` to use refresh token and check access token expiration. | `false` | +| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | | ## Examples of setting up Generic OAuth diff --git a/package.json b/package.json index c9d80186f8f..515bc414b97 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "@testing-library/user-event": "14.6.1", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", - "@types/chance": "^1.1.3", + "@types/chance": "^1.1.7", "@types/common-tags": "^1.8.0", "@types/confusing-browser-globals": "^1", "@types/d3": "7.4.3", @@ -164,19 +164,19 @@ "@types/yargs": "17.0.33", "@typescript-eslint/eslint-plugin": "8.35.1", "@typescript-eslint/parser": "8.35.1", - "autoprefixer": "10.4.20", + "autoprefixer": "10.4.21", "babel-loader": "9.2.1", "blob-polyfill": "9.0.20240710", "browserslist": "^4.21.4", - "chance": "^1.0.10", - "chrome-remote-interface": "0.33.2", + "chance": "^1.1.13", + "chrome-remote-interface": "0.33.3", "codeowners": "^5.1.1", "confusing-browser-globals": "^1.0.11", "copy-webpack-plugin": "12.0.2", "core-js": "3.40.0", "crashme": "0.0.15", "css-loader": "7.1.2", - "css-minimizer-webpack-plugin": "7.0.0", + "css-minimizer-webpack-plugin": "7.0.2", "cypress": "14.3.2", "cypress-file-upload": "5.0.8", "cypress-recurse": "^1.35.3", @@ -281,7 +281,7 @@ "@grafana/flamegraph": "workspace:*", "@grafana/google-sdk": "0.3.2", "@grafana/i18n": "workspace:*", - "@grafana/lezer-logql": "0.2.7", + "@grafana/lezer-logql": "0.2.8", "@grafana/llm": "0.22.1", "@grafana/monaco-logql": "^0.0.8", "@grafana/o11y-ds-frontend": "workspace:*", diff --git a/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx b/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx index 1464f1a6f6c..db5b5a5b610 100644 --- a/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx +++ b/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren, ReactElement, useMemo } from 'react'; import { DataSourceInstanceSettings } from '../../types/datasource'; -import { Context, DataSourcePluginContextType } from './PluginContext'; +import { PluginContext, DataSourcePluginContextType } from './PluginContext'; export type DataSourcePluginContextProviderProps = { instanceSettings: DataSourceInstanceSettings; @@ -16,5 +16,5 @@ export function DataSourcePluginContextProvider( return { instanceSettings, meta: instanceSettings.meta }; }, [instanceSettings]); - return {children}; + return {children}; } diff --git a/packages/grafana-data/src/context/plugins/PluginContext.tsx b/packages/grafana-data/src/context/plugins/PluginContext.tsx index f8748617541..7471109d93d 100644 --- a/packages/grafana-data/src/context/plugins/PluginContext.tsx +++ b/packages/grafana-data/src/context/plugins/PluginContext.tsx @@ -1,14 +1,15 @@ import { createContext } from 'react'; +import { KeyValue } from '../../types/data'; import { DataSourceInstanceSettings } from '../../types/datasource'; import { PluginMeta } from '../../types/plugin'; -export interface PluginContextType { - meta: PluginMeta; +export interface PluginContextType { + meta: PluginMeta; } -export interface DataSourcePluginContextType extends PluginContextType { +export interface DataSourcePluginContextType extends PluginContextType { instanceSettings: DataSourceInstanceSettings; } -export const Context = createContext(undefined); +export const PluginContext = createContext(undefined); diff --git a/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx b/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx index f60381c5aaa..c7698b18a34 100644 --- a/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx +++ b/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren, ReactElement } from 'react'; import { PluginMeta } from '../../types/plugin'; -import { Context } from './PluginContext'; +import { PluginContext } from './PluginContext'; export type PluginContextProviderProps = { meta: PluginMeta; @@ -10,5 +10,5 @@ export type PluginContextProviderProps = { export function PluginContextProvider(props: PropsWithChildren): ReactElement { const { children, ...rest } = props; - return {children}; + return {children}; } diff --git a/packages/grafana-data/src/context/plugins/guards.ts b/packages/grafana-data/src/context/plugins/guards.ts index 3a93c862f09..3b89b24dfed 100644 --- a/packages/grafana-data/src/context/plugins/guards.ts +++ b/packages/grafana-data/src/context/plugins/guards.ts @@ -1,5 +1,9 @@ +import { KeyValue } from '../../types/data'; + import { type DataSourcePluginContextType, type PluginContextType } from './PluginContext'; -export function isDataSourcePluginContext(context: PluginContextType): context is DataSourcePluginContextType { +export function isDataSourcePluginContext( + context: PluginContextType +): context is DataSourcePluginContextType { return 'instanceSettings' in context && 'meta' in context; } diff --git a/packages/grafana-data/src/context/plugins/usePluginContext.tsx b/packages/grafana-data/src/context/plugins/usePluginContext.tsx index 58190a7e4e1..b00875909d7 100644 --- a/packages/grafana-data/src/context/plugins/usePluginContext.tsx +++ b/packages/grafana-data/src/context/plugins/usePluginContext.tsx @@ -1,9 +1,11 @@ import { useContext } from 'react'; -import { Context, PluginContextType } from './PluginContext'; +import { KeyValue } from '../../types/data'; -export function usePluginContext(): PluginContextType | null { - const context = useContext(Context); +import { PluginContext, PluginContextType } from './PluginContext'; + +export function usePluginContext(): PluginContextType | null { + const context = useContext(PluginContext); // 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`). @@ -11,5 +13,6 @@ export function usePluginContext(): PluginContextType | null { return null; } - return context; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return context as PluginContextType; } diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 80351af8bc2..bc9e4d245db 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -435,7 +435,7 @@ export { type GroupingToMatrixTransformerOptions } from './transformations/trans export { type PluginContextType, type DataSourcePluginContextType, - Context as PluginContext, + PluginContext, } from './context/plugins/PluginContext'; export { type PluginContextProviderProps, PluginContextProvider } from './context/plugins/PluginContextProvider'; export { diff --git a/packages/grafana-data/src/transformations/transformers/limit.test.ts b/packages/grafana-data/src/transformations/transformers/limit.test.ts index 9fde97b0283..a042dc5f34e 100644 --- a/packages/grafana-data/src/transformations/transformers/limit.test.ts +++ b/packages/grafana-data/src/transformations/transformers/limit.test.ts @@ -35,18 +35,21 @@ describe('Limit transformer', () => { { name: 'time', type: FieldType.time, + state: { calcs: undefined }, values: [3000, 4000, 5000], config: {}, }, { name: 'message', type: FieldType.string, + state: { calcs: undefined }, values: ['one', 'two', 'two'], config: {}, }, { name: 'values', type: FieldType.number, + state: { calcs: undefined }, values: [1, 2, 2], config: {}, }, @@ -79,18 +82,21 @@ describe('Limit transformer', () => { { name: 'time', type: FieldType.time, + state: { calcs: undefined }, values: [6000, 7000, 8000], config: {}, }, { name: 'message', type: FieldType.string, + state: { calcs: undefined }, values: ['three', 'three', 'three'], config: {}, }, { name: 'values', type: FieldType.number, + state: { calcs: undefined }, values: [3, 3, 3], config: {}, }, diff --git a/packages/grafana-data/src/transformations/transformers/limit.ts b/packages/grafana-data/src/transformations/transformers/limit.ts index 230e66d9dd3..4361e93c385 100644 --- a/packages/grafana-data/src/transformations/transformers/limit.ts +++ b/packages/grafana-data/src/transformations/transformers/limit.ts @@ -37,6 +37,12 @@ export const limitTransformer: DataTransformerInfo = { fields: frame.fields.map((f) => { return { ...f, + // Clear cached field calculations since applying a limit changes the dataset + // and previously computed stats (min, max, mean, etc.) are no longer valid + state: { + ...f.state, + calcs: undefined, + }, values: limit >= 0 ? f.values.slice(0, limit) : f.values.slice(f.values.length + limit, f.values.length), }; diff --git a/packages/grafana-data/src/utils/fuzzySearch.test.ts b/packages/grafana-data/src/utils/fuzzySearch.test.ts index 71d75b27558..b53c26e98aa 100644 --- a/packages/grafana-data/src/utils/fuzzySearch.test.ts +++ b/packages/grafana-data/src/utils/fuzzySearch.test.ts @@ -48,6 +48,14 @@ describe('fuzzySearch', () => { expect(result.map((idx) => haystack[idx])).toEqual(['A水']); }); + it('should do case-insensitive substring match when needle contains non-ascii characters', () => { + const haystack = ['Über']; + const needle = 'ü'; + const result = fuzzySearch(haystack, needle); + + expect(result.map((idx) => haystack[idx])).toEqual(['Über']); + }); + it('should handle multiple non-latin characters', () => { const haystack = ['台灣省', '台中市', '台北市', '台南市', '南投縣', '高雄市', '台中第一高級中學']; const needle = '南'; diff --git a/packages/grafana-data/src/utils/fuzzySearch.ts b/packages/grafana-data/src/utils/fuzzySearch.ts index 72d1540f7b9..09ca02b7bc8 100644 --- a/packages/grafana-data/src/utils/fuzzySearch.ts +++ b/packages/grafana-data/src/utils/fuzzySearch.ts @@ -1,5 +1,7 @@ import uFuzzy from '@leeoniya/ufuzzy'; +import { escapeRegex } from '../text/string'; + // https://catonmat.net/my-favorite-regex :) const REGEXP_NON_ASCII = /[^ -~]/m; // https://www.asciitable.com/ @@ -36,11 +38,13 @@ export function fuzzySearch(haystack: string[], needle: string): number[] { needle.length > maxNeedleLength || uf.split(needle).length > maxFuzzyTerms ) { + const needleRegex = new RegExp(escapeRegex(needle), 'i'); const indices: number[] = []; + for (let i = 0; i < haystack.length; i++) { let item = haystack[i]; - if (item.includes(needle)) { + if (needleRegex.test(item)) { indices.push(i); } } diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 0ff43b6807e..088caf6dfa1 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -1179,11 +1179,6 @@ export const versionedComponents = { '12.1.0': 'data-testid Data links actions tooltip wrapper', }, }, - TablePanel: { - autoCell: { - '12.1.0': 'data-testid Table panel auto cell', - }, - }, CodeEditor: { container: { '10.2.3': 'data-testid Code editor container', diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 4c1fd34f48e..ff937656095 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -708,6 +708,7 @@ export enum TableCellDisplayMode { Image = 'image', JSONView = 'json-view', LcdGauge = 'lcd-gauge', + Pill = 'pill', Sparkline = 'sparkline', } @@ -838,7 +839,38 @@ export enum TableCellHeight { * Table cell options. Each cell has a display mode * and other potential options for that display. */ -export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions); +export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions); + +/** + * Field options for each field within a table (e.g 10, "The String", 64.20, etc.) + * Generally defines alignment, filtering capabilties, display options, etc. + */ +export interface TableFieldOptions { + align: FieldTextAlignment; + cellOptions: TableCellOptions; + /** + * This field is deprecated in favor of using cellOptions + */ + displayMode?: TableCellDisplayMode; + filterable?: boolean; + hidden?: boolean; // ?? default is missing or false ?? + /** + * Hides any header for a column, useful for columns that show some static content or buttons. + */ + hideHeader?: boolean; + inspect: boolean; + minWidth?: number; + width?: number; + /** + * Enables text wrapping for column headers + */ + wrapHeaderText?: boolean; +} + +export const defaultTableFieldOptions: Partial = { + align: 'auto', + inspect: false, +}; /** * Use UTC/GMT timezone @@ -944,37 +976,12 @@ export enum ComparisonOperation { NEQ = 'neq', } -/** - * Field options for each field within a table (e.g 10, "The String", 64.20, etc.) - * Generally defines alignment, filtering capabilties, display options, etc. - */ -export interface TableFieldOptions { - align: FieldTextAlignment; - cellOptions: TableCellOptions; - /** - * This field is deprecated in favor of using cellOptions - */ - displayMode?: TableCellDisplayMode; - filterable?: boolean; - hidden?: boolean; // ?? default is missing or false ?? - /** - * Hides any header for a column, useful for columns that show some static content or buttons. - */ - hideHeader?: boolean; - inspect: boolean; - minWidth?: number; - width?: number; - /** - * Enables text wrapping for column headers - */ - wrapHeaderText?: boolean; +export interface TablePillCellOptions { + color?: string; + colorMode?: ('auto' | 'fixed' | 'mapped'); + type: TableCellDisplayMode.Pill; } -export const defaultTableFieldOptions: Partial = { - align: 'auto', - inspect: false, -}; - /** * A specific timezone from https://en.wikipedia.org/wiki/Tz_database */ diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index ca61617aedb..0858150ae4c 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -4,7 +4,7 @@ package common // in the table such as colored text, JSON, gauge, etc. // The color-background-solid, gradient-gauge, and lcd-gauge // modes are deprecated in favor of new cell subOptions -TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" | "actions" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions") +TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" | "actions" | "pill" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions|Pill") // Display mode to the "Colored Background" display // mode for table cells. Either displays a solid color (basic mode) @@ -89,7 +89,7 @@ TableCellHeight: "sm" | "md" | "lg" | "auto" @cuetsy(kind="enum") // Table cell options. Each cell has a display mode // and other potential options for that display. -TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions @cuetsy(kind="type") +TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions @cuetsy(kind="type") // Field options for each field within a table (e.g 10, "The String", 64.20, etc.) // Generally defines alignment, filtering capabilties, display options, etc. @@ -109,3 +109,9 @@ TableFieldOptions: { wrapHeaderText?: bool } @cuetsy(kind="interface") +TablePillCellOptions: { + type: TableCellDisplayMode & "pill" + color?: string + colorMode?: "auto" | "fixed" | "mapped" +} @cuetsy(kind="interface") + diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index f0e1b8692d1..e26cf46e5be 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -156,7 +156,7 @@ "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.2.0", "@testing-library/user-event": "14.6.1", - "@types/chance": "1.1.6", + "@types/chance": "^1.1.7", "@types/common-tags": "^1.8.0", "@types/d3": "7.4.3", "@types/hoist-non-react-statics": "3.3.6", @@ -176,7 +176,7 @@ "@types/slate-react": "0.22.9", "@types/tinycolor2": "1.4.6", "@types/uuid": "10.0.0", - "chance": "1.1.12", + "chance": "^1.1.13", "common-tags": "1.8.2", "core-js": "3.40.0", "css-loader": "7.1.2", diff --git a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx index 1bd8774479b..5b2e28c646f 100644 --- a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx +++ b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx @@ -74,8 +74,6 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt const dismiss = useDismiss(context); - const hasMultipleLinksOrActions = links.length > 1 || Boolean(actions?.length); - const { getFloatingProps, getReferenceProps } = useInteractions([dismiss]); if (links.length === 0 && !Boolean(actions?.length)) { @@ -84,23 +82,22 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt return ( <> + {/* TODO: we can remove `value` from this component when tableNextGen is fully rolled out */} {value} - {hasMultipleLinksOrActions && ( - -
- - - -
-
- )} + +
+ + + +
+
); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx index 9616baa5a76..087efa47ecc 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -6,12 +7,16 @@ import { useStyles2 } from '../../../../themes/ThemeContext'; import { ActionButton } from '../../../Actions/ActionButton'; import { ActionCellProps } from '../types'; -export const ActionsCell = ({ actions }: ActionCellProps) => { +export const ActionsCell = ({ field, rowIdx, getActions }: ActionCellProps) => { const styles = useStyles2(getStyles); + const actions = useMemo(() => getActions(field, rowIdx), [getActions, field, rowIdx]); + return (
- {actions && actions.map((action, i) => )} + {actions.map((action, i) => ( + + ))}
); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx deleted file mode 100644 index b65fa0e0297..00000000000 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { Field, FieldType, LinkModel } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; - -import { TableCellDisplayMode } from '../../types'; - -import AutoCell from './AutoCell'; - -describe('AutoCell', () => { - describe('Displays data Links', () => { - const getFieldWithLinks = (links: LinkModel[]): Field => { - return { - name: 'Category', - type: FieldType.string, - values: ['A', 'B', 'A', 'B', 'A'], - config: { - custom: { - cellOptions: { - type: TableCellDisplayMode.Auto, - wrapText: false, - }, - }, - }, - display: (value: unknown) => ({ - text: String(value), - numeric: 0, - color: undefined, - prefix: undefined, - suffix: undefined, - }), - state: {}, - getLinks: () => links, - }; - }; - - it('shows multiple datalinks in the tooltip', async () => { - const linksForField = [ - { href: 'http://asdasd.com', title: 'Test Title' } as LinkModel, - { href: 'http://asdasd2.com', title: 'Test Title2' } as LinkModel, - ]; - - jest.mock('../utils', () => ({ - getCellLinks: () => linksForField, - })); - - const field = getFieldWithLinks(linksForField); - - render( - - ); - - const cell = screen.getByTestId(selectors.components.TablePanel.autoCell); - await userEvent.click(cell); - - const tooltip = screen.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper); - expect(tooltip).toBeInTheDocument(); - expect(screen.getByText('Test Title')).toBeInTheDocument(); - expect(screen.getByText('Test Title2')).toBeInTheDocument(); - }); - - it('does not show tooltip for multiple links if one is invalid', async () => { - const linksForField = [ - { href: 'http://asdasd.com', title: 'Test Title' } as LinkModel, - { title: 'Test Title2' } as LinkModel, - ]; - - jest.mock('../utils', () => ({ - getCellLinks: () => linksForField, - })); - - const field = getFieldWithLinks(linksForField); - - render( - - ); - - const cell = screen.getByTestId(selectors.components.TablePanel.autoCell); - await userEvent.click(cell); - - expect(screen.queryByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx index 3fdcfc85491..71293bbc06e 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx @@ -1,49 +1,24 @@ import { css } from '@emotion/css'; import { Property } from 'csstype'; -import { useState } from 'react'; import { GrafanaTheme2, formattedValueToString } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; import { useStyles2 } from '../../../../themes/ThemeContext'; -import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; import { TableCellOptions, TableCellDisplayMode } from '../../types'; -import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils, tooltipOnClickHandler } from '../../utils'; +import { useSingleLink } from '../hooks'; import { AutoCellProps } from '../types'; -import { getCellLinks } from '../utils'; -export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions, actions }: AutoCellProps) { +export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions }: AutoCellProps) { const styles = useStyles2(getStyles, justifyContent); const displayValue = field.display!(value); const formattedValue = formattedValueToString(displayValue); - const links = getCellLinks(field, rowIdx) || []; - - const [tooltipCoords, setTooltipCoords] = useState(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; + const link = useSingleLink(field, rowIdx); return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], formattedValue, getLinkStyle(styles, cellOptions)) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - formattedValue - )} +
+ {link == null ? formattedValue : renderSingleLink(link, formattedValue, getLinkStyle(styles, cellOptions))}
); } diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx index 710a8c65726..69ebfac671c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx @@ -1,13 +1,11 @@ -import { useState } from 'react'; - import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax } from '@grafana/data'; import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@grafana/schema'; import { BarGauge } from '../../../BarGauge/BarGauge'; -import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; -import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; +import { useSingleLink } from '../hooks'; import { BarGaugeCellProps } from '../types'; -import { extractPixelValue, getCellOptions, getAlignmentFactor, getCellLinks } from '../utils'; +import { extractPixelValue, getCellOptions, getAlignmentFactor } from '../utils'; const defaultScale: ThresholdsConfig = { mode: ThresholdsMode.Absolute, @@ -23,7 +21,7 @@ const defaultScale: ThresholdsConfig = { ], }; -export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx, actions }: BarGaugeCellProps) => { +export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: BarGaugeCellProps) => { const displayValue = field.display!(value); const cellOptions = getCellOptions(field); const heightOffset = extractPixelValue(theme.spacing(1)); @@ -48,51 +46,26 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx, actio } const alignmentFactors = getAlignmentFactor(field, displayValue, rowIdx!); - const links = getCellLinks(field, rowIdx) || []; - const [tooltipCoords, setTooltipCoords] = useState(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; - - const renderComponent = () => { - return ( - - ); - }; - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], renderComponent()) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - renderComponent() - )} -
+ const barGaugeComponent = ( + ); + + const link = useSingleLink(field, rowIdx); + + return link == null ? barGaugeComponent : renderSingleLink(link, barGaugeComponent); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index bc62e266b12..e0c1050396a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -1,55 +1,28 @@ import { css } from '@emotion/css'; import { Property } from 'csstype'; -import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../../../themes/ThemeContext'; -import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; import { TableCellDisplayMode } from '../../types'; -import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils, tooltipOnClickHandler } from '../../utils'; +import { useSingleLink } from '../hooks'; import { ImageCellProps } from '../types'; -import { getCellLinks } from '../utils'; const DATALINKS_HEIGHT_OFFSET = 10; -export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx, actions }: ImageCellProps) => { +export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => { const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET; const styles = useStyles2(getStyles, calculatedHeight, justifyContent); - const links = getCellLinks(field, rowIdx) || []; - - const [tooltipCoords, setTooltipCoords] = useState(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; const { text } = field.display!(value); const { alt, title } = cellOptions.type === TableCellDisplayMode.Image ? cellOptions : { alt: undefined, title: undefined }; const img = {alt}; + const link = useSingleLink(field, rowIdx); - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], img) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - img - )} -
- ); + return
{link == null ? img : renderSingleLink(link, img)}
; }; const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({ diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx index 2506255cb03..dc195cbcd67 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx @@ -1,16 +1,14 @@ import { css } from '@emotion/css'; import { Property } from 'csstype'; -import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../../../themes/ThemeContext'; -import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; -import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; +import { useSingleLink } from '../hooks'; import { JSONCellProps } from '../types'; -import { getCellLinks } from '../utils'; -export const JSONCell = ({ value, justifyContent, field, rowIdx, actions }: JSONCellProps) => { +export const JSONCell = ({ value, justifyContent, field, rowIdx }: JSONCellProps) => { const styles = useStyles2(getStyles, justifyContent); let displayValue = value; @@ -33,34 +31,9 @@ export const JSONCell = ({ value, justifyContent, field, rowIdx, actions }: JSON } } - const links = getCellLinks(field, rowIdx) || []; + const link = useSingleLink(field, rowIdx); - const [tooltipCoords, setTooltipCoords] = useState(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], displayValue) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - displayValue - )} -
- ); + return
{link == null ? displayValue : renderSingleLink(link, displayValue)}
; }; const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({ diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx new file mode 100644 index 00000000000..a216a9221d1 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx @@ -0,0 +1,219 @@ +import { render, screen } from '@testing-library/react'; + +import { DataFrame, Field, FieldType, GrafanaTheme2, MappingType, createTheme } from '@grafana/data'; +import { TableCellDisplayMode, TablePillCellOptions } from '@grafana/schema'; + +import { mockThemeContext } from '../../../../themes/ThemeContext'; + +import { PillCell, inferPills } from './PillCell'; + +describe('PillCell', () => { + let restoreThemeContext: () => void; + + beforeEach(() => { + restoreThemeContext = mockThemeContext(createTheme()); + }); + + afterEach(() => { + restoreThemeContext(); + }); + + const mockCellOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'auto', + }; + + const mockField: Field = { + name: 'test', + type: FieldType.string, + values: [], + config: {}, + }; + + const mockFrame: DataFrame = { + name: 'test', + fields: [mockField], + length: 1, + }; + + const defaultProps = { + value: 'test-value', + field: mockField, + justifyContent: 'flex-start' as const, + cellOptions: mockCellOptions, + rowIdx: 0, + frame: mockFrame, + height: 30, + width: 100, + theme: {} as GrafanaTheme2, + cellInspect: false, + showFilters: false, + }; + + describe('pill parsing', () => { + it('should render pills for single values', () => { + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + + it('should render pills for CSV values', () => { + render(); + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('value2')).toBeInTheDocument(); + expect(screen.getByText('value3')).toBeInTheDocument(); + }); + + it('should render pills for JSON array values', () => { + render(); + expect(screen.getByText('item1')).toBeInTheDocument(); + expect(screen.getByText('item2')).toBeInTheDocument(); + expect(screen.getByText('item3')).toBeInTheDocument(); + }); + + it('should show dash for empty values', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('should show dash for null values', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + }); + + describe('color mapping', () => { + // These tests primarily ensure the color logic executes without throwing. + // For true color verification, visual regression tests would be needed. + + it('should use mapped colors when colorMode is mapped', () => { + const mappedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'mapped', + }; + + render(); + + const successPill = screen.getByText('success'); + const errorPill = screen.getByText('error'); + const warningPill = screen.getByText('warning'); + const unknownPill = screen.getByText('unknown'); + + expect(successPill).toBeInTheDocument(); + expect(errorPill).toBeInTheDocument(); + expect(warningPill).toBeInTheDocument(); + expect(unknownPill).toBeInTheDocument(); + }); + + it('should use field-level value mappings when available', () => { + const mappedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'mapped', + }; + + // Mock field with value mappings + const fieldWithMappings: Field = { + ...mockField, + config: { + ...mockField.config, + mappings: [ + { + type: MappingType.ValueToText, + options: { + success: { color: '#00FF00' }, + error: { color: '#FF0000' }, + warning: { color: '#FFFF00' }, + }, + }, + ], + }, + display: (value: unknown) => ({ + text: String(value), + color: + String(value) === 'success' + ? '#00FF00' + : String(value) === 'error' + ? '#FF0000' + : String(value) === 'warning' + ? '#FFFF00' + : '#FF780A', + numeric: 0, + }), + }; + + render( + + ); + + const successPill = screen.getByText('success'); + const errorPill = screen.getByText('error'); + const warningPill = screen.getByText('warning'); + const unknownPill = screen.getByText('unknown'); + + expect(successPill).toBeInTheDocument(); + expect(errorPill).toBeInTheDocument(); + expect(warningPill).toBeInTheDocument(); + expect(unknownPill).toBeInTheDocument(); + }); + + it('should use fixed color when colorMode is fixed', () => { + const fixedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'fixed', + color: '#FF00FF', + }; + + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + + it('should use auto color when colorMode is auto', () => { + const autoOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'auto', + }; + + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + }); +}); + +describe('inferPills', () => { + // These tests verify the pill parsing logic handles various input formats correctly. + // They ensure the function can extract pill values from different data structures. + + it('should return empty array for null/undefined values', () => { + expect(inferPills(null)).toEqual([]); + expect(inferPills(undefined)).toEqual([]); + expect(inferPills('')).toEqual([]); + }); + + it('should parse single values', () => { + expect(inferPills('test')).toEqual(['test']); + expect(inferPills('"quoted"')).toEqual(['quoted']); + expect(inferPills("'quoted'")).toEqual(['quoted']); + }); + + it('should parse CSV strings', () => { + expect(inferPills('value1,value2,value3')).toEqual(['value1', 'value2', 'value3']); + expect(inferPills(' value1 , value2 , value3 ')).toEqual(['value1', 'value2', 'value3']); + expect(inferPills('value1, ,value3')).toEqual(['value1', 'value3']); + }); + + it('should parse JSON arrays', () => { + expect(inferPills('["item1","item2","item3"]')).toEqual(['item1', 'item2', 'item3']); + expect(inferPills('["item1", "item2", "item3"]')).toEqual(['item1', 'item2', 'item3']); + expect(inferPills('["item1", null, "item3"]')).toEqual(['item1', 'item3']); + }); + + it('should handle mixed content', () => { + // When JSON parsing fails, it falls back to CSV parsing + expect(inferPills('["item1", "item2"],extra')).toEqual(['["item1"', '"item2"]', 'extra']); + expect(inferPills('not-json,value')).toEqual(['not-json', 'value']); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx new file mode 100644 index 00000000000..aaa103c3dc7 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx @@ -0,0 +1,185 @@ +import { css } from '@emotion/css'; +import { Property } from 'csstype'; +import { useMemo } from 'react'; + +import { GrafanaTheme2, isDataFrame, classicColors, colorManipulator, Field } from '@grafana/data'; +import { TablePillCellOptions } from '@grafana/schema'; + +import { useStyles2 } from '../../../../themes/ThemeContext'; +import { TableCellRendererProps } from '../types'; + +const DEFAULT_PILL_BG_COLOR = '#FF780A'; + +interface Pill { + value: string; + key: string; + bgColor: string; + color: string; +} + +function createPills(pillValues: string[], cellOptions: TableCellRendererProps['cellOptions'], field: Field): Pill[] { + return pillValues.map((pill, index) => { + const bgColor = getPillColor(pill, cellOptions, field); + const textColor = colorManipulator.getContrastRatio('#FFFFFF', bgColor) >= 4.5 ? '#FFFFFF' : '#000000'; + return { + value: pill, + key: `${pill}-${index}`, + bgColor, + color: textColor, + }; + }); +} + +export function PillCell({ value, field, justifyContent, cellOptions }: TableCellRendererProps) { + const styles = useStyles2(getStyles, justifyContent); + + const pills: Pill[] = useMemo(() => { + const pillValues = inferPills(value); + return createPills(pillValues, cellOptions, field); + }, [value, cellOptions, field]); + + if (pills.length === 0) { + return
-
; + } + + return ( +
+
+ {pills.map((pill) => ( + + {pill.value} + + ))} +
+
+ ); +} + +export function inferPills(value: unknown): string[] { + if (!value) { + return []; + } + + // Handle DataFrame - not supported for pills + if (isDataFrame(value)) { + return []; + } + + // Handle different value types + const stringValue = String(value); + + // Try to parse as JSON first + try { + const parsed = JSON.parse(stringValue); + if (Array.isArray(parsed)) { + // JSON array of strings + return parsed + .filter((item) => item != null && item !== '') + .map(String) + .map((text) => text.trim()) + .filter((item) => item !== ''); + } + } catch { + // Not valid JSON, continue with other parsing + } + + // Handle CSV string + if (stringValue.includes(',')) { + return stringValue + .split(',') + .map((text) => text.trim()) + .filter((item) => item !== ''); + } + + // Single value - strip quotes + return [stringValue.replace(/["'`]/g, '').trim()]; +} + +function isPillCellOptions(cellOptions: TableCellRendererProps['cellOptions']): cellOptions is TablePillCellOptions { + return cellOptions?.type === 'pill'; +} + +function getPillColor(pill: string, cellOptions: TableCellRendererProps['cellOptions'], field: Field): string { + if (!isPillCellOptions(cellOptions)) { + return getDeterministicColor(pill); + } + + const colorMode = cellOptions.colorMode || 'auto'; + + // Fixed color mode (highest priority) + if (colorMode === 'fixed' && cellOptions.color) { + return cellOptions.color; + } + + // Mapped color mode - use field's value mappings + if (colorMode === 'mapped') { + // Check if field has value mappings + if (field.config.mappings && field.config.mappings.length > 0) { + // Use the field's display processor to get the mapped value + const displayValue = field.display!(pill); + if (displayValue.color) { + return displayValue.color; + } + } + // Fallback to default color for unmapped values + return cellOptions.color || DEFAULT_PILL_BG_COLOR; + } + + // Auto mode - deterministic color assignment based on string hash + if (colorMode === 'auto') { + return getDeterministicColor(pill); + } + + // Default color for unknown values or fallback + return DEFAULT_PILL_BG_COLOR; +} + +function getDeterministicColor(text: string): string { + // Create a simple hash of the string to get consistent colors + let hash = 0; + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + + // Use absolute value and modulo to get a consistent index + const colorValues = Object.values(classicColors); + const index = Math.abs(hash) % colorValues.length; + + return colorValues[index]; +} + +const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({ + cell: css({ + display: 'flex', + justifyContent: justifyContent || 'flex-start', + alignItems: 'center', + height: '100%', + padding: theme.spacing(0.5), + }), + pillsContainer: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + maxWidth: '100%', + }), + pill: css({ + display: 'inline-block', + padding: theme.spacing(0.25, 0.75), + borderRadius: theme.shape.radius.default, + fontSize: theme.typography.bodySmall.fontSize, + lineHeight: theme.typography.bodySmall.lineHeight, + fontWeight: theme.typography.fontWeightMedium, + whiteSpace: 'nowrap', + textAlign: 'center', + minWidth: 'fit-content', + }), +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx index eb67a369cb2..558d1c7978d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx @@ -24,7 +24,10 @@ export function TableCellActions(props: TableCellActionsProps) { } = props; return ( -
+ // stopping propagation to prevent clicks within the actions menu from triggering the cell click events + // for things like the data links tooltip. + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
ev.stopPropagation()}> {cellInspect && ( ReactNode; @@ -24,7 +25,6 @@ const GAUGE_RENDERER: TableCellRenderer = (props) => ( height={props.height} width={props.width} rowIdx={props.rowIdx} - actions={props.actions} /> ); @@ -35,7 +35,6 @@ const AUTO_RENDERER: TableCellRenderer = (props) => ( justifyContent={props.justifyContent} rowIdx={props.rowIdx} cellOptions={props.cellOptions} - actions={props.actions} /> ); @@ -52,13 +51,7 @@ const SPARKLINE_RENDERER: TableCellRenderer = (props) => ( ); const JSON_RENDERER: TableCellRenderer = (props) => ( - + ); const GEO_RENDERER: TableCellRenderer = (props) => ( @@ -73,13 +66,16 @@ const IMAGE_RENDERER: TableCellRenderer = (props) => ( justifyContent={props.justifyContent} value={props.value} rowIdx={props.rowIdx} - actions={props.actions} /> ); const DATA_LINKS_RENDERER: TableCellRenderer = (props) => ; -const ACTIONS_RENDERER: TableCellRenderer = (props) => ; +const ACTIONS_RENDERER: TableCellRenderer = ({ field, rowIdx, getActions = () => [] }) => ( + +); + +const PILL_RENDERER: TableCellRenderer = (props) => ; function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions { return options.type === TableCellDisplayMode.Custom; @@ -104,6 +100,7 @@ const CELL_RENDERERS: Record = { [TableCellDisplayMode.ColorText]: AUTO_RENDERER, [TableCellDisplayMode.ColorBackground]: AUTO_RENDERER, [TableCellDisplayMode.Auto]: AUTO_RENDERER, + [TableCellDisplayMode.Pill]: PILL_RENDERER, }; /** @internal */ diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx index dd1d4364128..9dfe1a2719b 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx @@ -1,7 +1,17 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { applyFieldOverrides, createTheme, DataFrame, EventBus, FieldType, toDataFrame } from '@grafana/data'; +import { + applyFieldOverrides, + createTheme, + DataFrame, + DataLink, + EventBus, + FieldType, + LinkModel, + toDataFrame, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { TableCellBackgroundDisplayMode } from '@grafana/schema'; import { PanelContext, PanelContextProvider } from '../../../components/PanelChrome'; @@ -1682,4 +1692,55 @@ describe('TableNG', () => { expect(mockEventBus.publish).not.toHaveBeenCalled(); }); }); + + describe('Displays data Links', () => { + function toLinkModel(link: DataLink): LinkModel { + return { + href: link.url, + title: link.title, + target: link.targetBlank ? '_blank' : '_self', + origin: link.origin || 'panel', + }; + } + + it('shows multiple datalinks in the tooltip', async () => { + const dataFrame = createBasicDataFrame(); + const links: DataLink[] = [ + { url: 'http://asdasd.com', title: 'Test Title' }, + { url: 'http://asdasd2.com', title: 'Test Title2' }, + ]; + + dataFrame.fields[0].config.links = links; + dataFrame.fields[0].getLinks = () => links.map(toLinkModel); + + render(); + + const cell = screen.getByText('A1'); + await userEvent.click(cell); + + const tooltip = screen.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper); + expect(tooltip).toBeInTheDocument(); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test Title2')).toBeInTheDocument(); + }); + + it('does not show tooltip for a single link', async () => { + const dataFrame = createBasicDataFrame(); + + const links: DataLink[] = [{ url: 'http://asdasd.com', title: 'Test Title' }]; + + dataFrame.fields[0].config.links = links; + dataFrame.fields[0].getLinks = () => links.map(toLinkModel); + + render(); + + const cell = screen.getByText('A1'); + + // we need to click the parent since the cell itself is a link. + await userEvent.click(cell.parentElement!); + + expect(screen.queryByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 5a0a2fdc036..aa35b99ae9c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -1,7 +1,7 @@ import 'react-data-grid/lib/styles.css'; import { css, cx } from '@emotion/css'; import { Property } from 'csstype'; -import { Key, ReactNode, useLayoutEffect, useMemo, useState } from 'react'; +import { Key, ReactNode, useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { Cell, CellRendererProps, @@ -22,8 +22,10 @@ import { ContextMenu } from '../../ContextMenu/ContextMenu'; import { MenuItem } from '../../Menu/MenuItem'; import { Pagination } from '../../Pagination/Pagination'; import { PanelContext, usePanelContext } from '../../PanelChrome'; +import { DataLinksActionsTooltip } from '../DataLinksActionsTooltip'; import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector'; import { CellColors, TableCellDisplayMode } from '../types'; +import { DataLinksActionsTooltipState } from '../utils'; import { HeaderCell } from './Cells/HeaderCell'; import { RowExpander } from './Cells/RowExpander'; @@ -56,6 +58,8 @@ import { getCellOptions, shouldTextWrap, isCellInspectEnabled, + getCellLinks, + withDataLinksActionsTooltip, } from './utils'; type CellRootRenderer = (key: React.Key, props: CellRendererProps) => React.ReactNode; @@ -68,14 +72,13 @@ export function TableNG(props: TableNGProps) { enableSharedCrosshair = false, enableVirtualization, footerOptions, - getActions, + getActions = () => [], height, initialSortBy, noHeader, onCellFilterAdded, onColumnResize, onSortByChange, - replaceVariables, showTypeIcons, structureRev, width, @@ -88,6 +91,11 @@ export function TableNG(props: TableNGProps) { }); const panelContext = usePanelContext(); + const getCellActions = useCallback( + (field: Field, rowIdx: number) => getActions(data, field, rowIdx), + [getActions, data] + ); + const hasHeader = !noHeader; const hasFooter = Boolean(footerOptions?.show && footerOptions.reducer?.length); const isCountRowsSet = Boolean( @@ -256,13 +264,15 @@ export function TableNG(props: TableNGProps) { interface Schema { columns: TableColumn[]; cellRootRenderers: Record; + colsWithTooltip: Record; } - const { columns, cellRootRenderers } = useMemo(() => { + const { columns, cellRootRenderers, colsWithTooltip } = useMemo(() => { const fromFields = (f: Field[], widths: number[]) => { const result: Schema = { columns: [], cellRootRenderers: {}, + colsWithTooltip: {}, }; let lastRowIdx = -1; @@ -280,7 +290,6 @@ export function TableNG(props: TableNGProps) { const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null); const showActions = cellInspect || showFilters; const width = widths[i]; - const frame = data; // helps us avoid string cx and emotion per-cell const cellActionClassName = showActions @@ -294,6 +303,9 @@ export function TableNG(props: TableNGProps) { const cellType = cellOptions.type; const shouldOverflow = shouldTextOverflow(field); const shouldWrap = shouldTextWrap(field); + const withTooltip = withDataLinksActionsTooltip(field, cellType); + + result.colsWithTooltip[displayName] = withTooltip; // this fires first const renderCellRoot = (key: Key, props: CellRendererProps): ReactNode => { @@ -317,7 +329,7 @@ export function TableNG(props: TableNGProps) { colors = {}; } - const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, colors); + const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, withTooltip, colors); return ( ): JSX.Element => { const rowIdx = props.row.__index; const value = props.row[props.column.key]; - - // TODO: defer until click? - const actions = getActions?.(frame, field, props.row.__index, replaceVariables); + const frame = data; return ( <> {renderFieldCell({ - actions, cellOptions, frame, field, @@ -354,6 +363,7 @@ export function TableNG(props: TableNGProps) { width, cellInspect, showFilters, + getActions: getCellActions, })} {showActions && ( (); + return ( <> @@ -541,6 +552,24 @@ export function TableNG(props: TableNGProps) { className={styles.grid} columns={structureRevColumns} rows={paginatedRows} + onCellClick={({ column, row }, { clientX, clientY, preventGridDefault }) => { + // Note: could be column.field; JS says yes, but TS says no! + const field = columns[column.idx].field; + + if (colsWithTooltip[getDisplayName(field)]) { + const rowIdx = row.__index; + setTooltipState({ + coords: { + clientX, + clientY, + }, + links: getCellLinks(field, rowIdx), + actions: getCellActions(field, rowIdx), + }); + + preventGridDefault(); + } + }} onCellKeyDown={ hasNestedFrames ? (_, event) => { @@ -577,6 +606,15 @@ export function TableNG(props: TableNGProps) {
)} + {tooltipState && ( + setTooltipState(undefined)} + /> + )} + {isContextMenuOpen && ( ({ - cell: css({ - textOverflow: 'initial', - background: colors.bgColor ?? 'inherit', - alignContent: 'center', - justifyContent: getTextAlign(field), - paddingInline: TABLE.CELL_PADDING, - height: '100%', - minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container - ...(shouldWrap && { whiteSpace: 'pre-line' }), - '&:last-child': { - borderInlineEnd: 'none', - }, - '&:hover': { - background: colors.bgHoverColor, - '.table-cell-actions': { - display: 'flex', +) => { + return { + cell: css({ + textOverflow: 'initial', + background: colors.bgColor ?? 'inherit', + alignContent: 'center', + justifyContent: getTextAlign(field), + paddingInline: TABLE.CELL_PADDING, + height: '100%', + minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container + ...(shouldWrap && { whiteSpace: 'pre-line' }), + ...(hasTooltip && { cursor: 'pointer' }), + '&:last-child': { + borderInlineEnd: 'none', }, - ...(shouldOverflow && { - zIndex: theme.zIndex.tooltip - 2, - whiteSpace: 'pre-line', - height: 'fit-content', - minWidth: 'fit-content', - }), - }, - }), -}); + '&:hover': { + background: colors.bgHoverColor, + '.table-cell-actions': { + display: 'flex', + }, + ...(shouldOverflow && { + zIndex: theme.zIndex.tooltip - 2, + whiteSpace: 'pre-line', + height: 'fit-content', + minWidth: 'fit-content', + }), + }, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index 1540d1e0383..33c89162939 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } fr import { Column, DataGridProps, SortColumn } from 'react-data-grid'; import { varPreLine } from 'uwrap'; -import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data'; +import { Field, fieldReducers, FieldType, formattedValueToString, LinkModel, reduceField } from '@grafana/data'; import { useTheme2 } from '../../../themes/ThemeContext'; import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types'; @@ -17,6 +17,7 @@ import { getColumnTypes, GetMaxWrapCellOptions, getMaxWrapCell, + getCellLinks, } from './utils'; // Helper function to get displayed value @@ -597,3 +598,10 @@ export function useColumnResize( return dataGridResizeHandler; } + +export function useSingleLink(field: Field, rowIdx: number): LinkModel | undefined { + const linksCount = field.config.links?.length ?? 0; + const actionsCount = field.config.actions?.length ?? 0; + const shouldShowLink = linksCount === 1 && actionsCount === 0; + return useMemo(() => (shouldShowLink ? (getCellLinks(field, rowIdx) ?? []) : [])[0], [field, shouldShowLink, rowIdx]); +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 2946923db8f..04caa62c85c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -10,7 +10,6 @@ import { TimeRange, FieldConfigSource, ActionModel, - InterpolateFunction, FieldType, DataFrameWithValue, SelectableValue, @@ -30,12 +29,9 @@ export type TableColumnResizeActionCallback = (fieldDisplayName: string, width: export type TableSortByActionCallback = (state: TableSortByFieldState[]) => void; export type FooterItem = Array> | string | undefined; -export type GetActionsFunction = ( - frame: DataFrame, - field: Field, - rowIndex: number, - replaceVariables?: InterpolateFunction -) => ActionModel[]; +export type GetActionsFunction = (frame: DataFrame, field: Field, rowIndex: number) => ActionModel[]; + +export type GetActionsFunctionLocal = (field: Field, rowIndex: number) => ActionModel[]; export type TableFieldOptionsType = Omit & { cellOptions: TableCellOptions; @@ -142,7 +138,6 @@ export interface BaseTableProps { initialRowIndex?: number; fieldConfig?: FieldConfigSource; getActions?: GetActionsFunction; - replaceVariables?: InterpolateFunction; // Used solely for testing as RTL can't correctly render the table otherwise enableVirtualization?: boolean; } @@ -151,7 +146,6 @@ export interface BaseTableProps { export interface TableNGProps extends BaseTableProps {} export interface TableCellRendererProps { - actions?: ActionModel[]; rowIdx: number; frame: DataFrame; timeRange?: TimeRange; @@ -165,6 +159,7 @@ export interface TableCellRendererProps { cellInspect: boolean; showFilters: boolean; justifyContent: Property.JustifyContent; + getActions?: GetActionsFunctionLocal; } export type ContextMenuProps = { @@ -205,7 +200,7 @@ export interface SparklineCellProps { width: number; } -export interface BarGaugeCellProps extends ActionCellProps { +export interface BarGaugeCellProps { field: Field; height: number; rowIdx: number; @@ -214,7 +209,7 @@ export interface BarGaugeCellProps extends ActionCellProps { width: number; } -export interface ImageCellProps extends ActionCellProps { +export interface ImageCellProps { cellOptions: TableCellOptions; field: Field; height: number; @@ -223,7 +218,7 @@ export interface ImageCellProps extends ActionCellProps { rowIdx: number; } -export interface JSONCellProps extends ActionCellProps { +export interface JSONCellProps { justifyContent: Property.JustifyContent; value: TableCellValue; field: Field; @@ -241,24 +236,26 @@ export interface GeoCellProps { height: number; } -export interface ActionCellProps { - actions?: ActionModel[]; -} - export interface CellColors { textColor?: string; bgColor?: string; bgHoverColor?: string; } -export interface AutoCellProps extends ActionCellProps { - value: TableCellValue; +export interface AutoCellProps { field: Field; + value: TableCellValue; justifyContent: Property.JustifyContent; rowIdx: number; cellOptions: TableCellOptions; } +export interface ActionCellProps { + field: Field; + rowIdx: number; + getActions: GetActionsFunctionLocal; +} + // Comparator for sorting table values export type Comparator = (a: TableCellValue, b: TableCellValue) => number; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 78f5442f234..90004837874 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -172,6 +172,7 @@ const DEFAULT_CELL_OPTIONS = { type: TableCellDisplayMode.Auto } as const; /** * @internal * Returns the cell options for a field, migrating from legacy displayMode if necessary. + * TODO: remove live migration in favor of doing it in dashboard or panel migrator */ export function getCellOptions(field: Field): TableCellOptions { if (field.config.custom?.displayMode) { @@ -613,3 +614,12 @@ export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowI } } } + +/** @internal */ +export function withDataLinksActionsTooltip(field: Field, cellType: TableCellDisplayMode) { + return ( + cellType !== TableCellDisplayMode.DataLinks && + cellType !== TableCellDisplayMode.Actions && + (field.config.links?.length ?? 0) + (field.config.actions?.length ?? 0) > 1 + ); +} diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index e2c5b5b8099..1d029ae7099 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -57,7 +57,7 @@ export interface TableCellProps extends CellProps { onCellFilterAdded?: TableFilterActionCallback; innerWidth: number; frame: DataFrame; - actions?: ActionModel[]; + actions?: ActionModel[]; // unused in NG setInspectCell?: TableInspectCellCallback; } diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index f4c9b072b16..77dc0cec078 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -196,6 +196,8 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field return DataLinksCell; case TableCellDisplayMode.Actions: return ActionsCell; + case TableCellDisplayMode.Pill: + return DefaultCell; // Legacy table doesn't support pill cells, fallback to default } if (field.type === FieldType.geo) { @@ -764,10 +766,16 @@ export function guessLongestField(fieldConfig: FieldConfigSource, data: DataFram return longestField; } -export type DataLinksActionsTooltipCoords = { +export interface DataLinksActionsTooltipState { + coords: DataLinksActionsTooltipCoords; + links?: LinkModel[]; + actions?: ActionModel[]; +} + +export interface DataLinksActionsTooltipCoords { clientX: number; clientY: number; -}; +} export const getDataLinksActionsTooltipUtils = (links: LinkModel[], actions?: ActionModel[]) => { const hasMultipleLinksOrActions = links.length > 1 || Boolean(actions?.length); diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go index 7d1d5842a47..02b93285e93 100644 --- a/pkg/login/social/connectors/generic_oauth.go +++ b/pkg/login/social/connectors/generic_oauth.go @@ -245,78 +245,136 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, defer s.reloadMutex.RUnlock() s.log.Debug("Getting user info") - toCheck := make([]*UserInfoJson, 0, 2) - if tokenData := s.extractFromToken(token); tokenData != nil { - toCheck = append(toCheck, tokenData) + // 1. Collect user info data from various sources + dataSources := s.collectUserInfoData(ctx, client, token) + + // 2. Build user info from collected data + userInfo, externalOrgs, err := s.buildUserInfo(dataSources) + if err != nil { + return nil, err + } + + // 3. Post-process user info + err = s.postProcessUserInfo(ctx, client, userInfo, externalOrgs) + if err != nil { + return nil, err + } + + // 4. Validate user access + err = s.validateUserAccess(ctx, client, userInfo) + if err != nil { + return nil, err + } + + s.log.Debug("User info result", "result", userInfo) + return userInfo, nil +} + +// collectUserInfoData gathers user information from ID token, API, and access token +func (s *SocialGenericOAuth) collectUserInfoData(ctx context.Context, client *http.Client, token *oauth2.Token) []*UserInfoJson { + dataSources := make([]*UserInfoJson, 0, 3) + + if idTokenData := s.extractFromIDToken(token); idTokenData != nil { + dataSources = append(dataSources, idTokenData) } if apiData := s.extractFromAPI(ctx, client); apiData != nil { - toCheck = append(toCheck, apiData) + dataSources = append(dataSources, apiData) + } + if accessTokenData := s.extractFromAccessToken(token); accessTokenData != nil { + dataSources = append(dataSources, accessTokenData) } + return dataSources +} + +// buildUserInfo constructs BasicUserInfo from collected data sources +func (s *SocialGenericOAuth) buildUserInfo(dataSources []*UserInfoJson) (*social.BasicUserInfo, []string, error) { userInfo := &social.BasicUserInfo{} var externalOrgs []string - for _, data := range toCheck { + + for _, data := range dataSources { s.log.Debug("Processing external user info", "source", data.source, "data", data) - if userInfo.Id == "" { - userInfo.Id = data.Sub + s.extractBasicUserFields(userInfo, data) + + if err := s.extractRoleAndOrgs(userInfo, &externalOrgs, data); err != nil { + return nil, nil, err } - if userInfo.Name == "" { - userInfo.Name = s.extractUserName(data) - } + s.extractUserGroups(userInfo, data) + } - if userInfo.Login == "" { - userInfo.Login = s.extractLogin(data) - } + return userInfo, externalOrgs, nil +} - if userInfo.Email == "" { - userInfo.Email = s.extractEmail(data) - if userInfo.Email != "" { - s.log.Debug("Set user info email from extracted email", "email", userInfo.Email) - } - } +// extractBasicUserFields extracts basic user fields (ID, Name, Login, Email) from data +func (s *SocialGenericOAuth) extractBasicUserFields(userInfo *social.BasicUserInfo, data *UserInfoJson) { + if userInfo.Id == "" { + userInfo.Id = data.Sub + } - if userInfo.Role == "" && !s.info.SkipOrgRoleSync { - role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{}) - if err != nil { - s.log.Warn("Failed to extract role", "err", err) - } else { - userInfo.Role = role - if s.info.AllowAssignGrafanaAdmin { - userInfo.IsGrafanaAdmin = &grafanaAdmin - } - } - } + if userInfo.Name == "" { + userInfo.Name = s.extractUserName(data) + } - if len(externalOrgs) == 0 && !s.info.SkipOrgRoleSync { - var err error - externalOrgs, err = s.extractOrgs(data.rawJSON) - if err != nil { - s.log.Warn("Failed to extract orgs", "err", err) - return nil, err - } - } + if userInfo.Login == "" { + userInfo.Login = s.extractLogin(data) + } - if len(userInfo.Groups) == 0 { - groups, err := s.extractGroups(data) - if err != nil { - s.log.Warn("Failed to extract groups", "err", err) - } else if len(groups) > 0 { - s.log.Debug("Setting user info groups from extracted groups") - userInfo.Groups = groups + if userInfo.Email == "" { + userInfo.Email = s.extractEmail(data) + if userInfo.Email != "" { + s.log.Debug("Set user info email from extracted email", "email", userInfo.Email) + } + } +} + +// extractRoleAndOrgs extracts role and organization information from data +func (s *SocialGenericOAuth) extractRoleAndOrgs(userInfo *social.BasicUserInfo, externalOrgs *[]string, data *UserInfoJson) error { + if userInfo.Role == "" && !s.info.SkipOrgRoleSync { + role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{}) + if err != nil { + s.log.Warn("Failed to extract role", "err", err) + } else { + userInfo.Role = role + if s.info.AllowAssignGrafanaAdmin { + userInfo.IsGrafanaAdmin = &grafanaAdmin } } } + if len(*externalOrgs) == 0 && !s.info.SkipOrgRoleSync { + orgs, err := s.extractOrgs(data.rawJSON) + if err != nil { + s.log.Warn("Failed to extract orgs", "err", err) + return err + } + *externalOrgs = orgs + } + + return nil +} + +// extractUserGroups extracts group information from data +func (s *SocialGenericOAuth) extractUserGroups(userInfo *social.BasicUserInfo, data *UserInfoJson) { + if len(userInfo.Groups) == 0 { + groups, err := s.extractGroups(data) + if err != nil { + s.log.Warn("Failed to extract groups", "err", err) + } else if len(groups) > 0 { + s.log.Debug("Setting user info groups from extracted groups") + userInfo.Groups = groups + } + } +} + +// postProcessUserInfo handles post-processing of user info (org roles, private email, etc.) +func (s *SocialGenericOAuth) postProcessUserInfo(ctx context.Context, client *http.Client, userInfo *social.BasicUserInfo, externalOrgs []string) error { if !s.info.SkipOrgRoleSync { userInfo.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, externalOrgs, userInfo.Role) if s.info.RoleAttributeStrict && len(userInfo.OrgRoles) == 0 { - // If no roles are found and role_attribute_strict is set, return an error. - // The s.info.RoleAttributeStrict is necessary, because there is a case when len(userInfo.OrgRoles) == 0, - // but strict role mapping is not enabled (when getAllOrgs fails). - return nil, errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data") + return errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data") } } @@ -325,11 +383,11 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, } if s.canFetchPrivateEmail(userInfo) { - var err error - userInfo.Email, err = s.fetchPrivateEmail(ctx, client) + email, err := s.fetchPrivateEmail(ctx, client) if err != nil { - return nil, err + return err } + userInfo.Email = email s.log.Debug("Setting email from fetched private email", "email", userInfo.Email) } @@ -338,28 +396,32 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, userInfo.Login = userInfo.Email } + return nil +} + +// validateUserAccess validates user access based on team, organization, and group membership +func (s *SocialGenericOAuth) validateUserAccess(ctx context.Context, client *http.Client, userInfo *social.BasicUserInfo) error { if !s.isTeamMember(ctx, client) { - return nil, &SocialError{"User not a member of one of the required teams"} + return &SocialError{"User not a member of one of the required teams"} } if !s.isOrganizationMember(ctx, client) { - return nil, &SocialError{"User not a member of one of the required organizations"} + return &SocialError{"User not a member of one of the required organizations"} } if !s.isGroupMember(userInfo.Groups) { - return nil, errMissingGroupMembership + return errMissingGroupMembership } - s.log.Debug("User info result", "result", userInfo) - return userInfo, nil + return nil } func (s *SocialGenericOAuth) canFetchPrivateEmail(userinfo *social.BasicUserInfo) bool { return s.info.ApiUrl != "" && userinfo.Email == "" } -func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson { - s.log.Debug("Extracting user info from OAuth token") +func (s *SocialGenericOAuth) extractFromIDToken(token *oauth2.Token) *UserInfoJson { + s.log.Debug("Extracting user info from OAuth ID token") idTokenAttribute := "id_token" if s.idTokenAttributeName != "" { @@ -373,21 +435,44 @@ func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson return nil } - rawJSON, err := s.retrieveRawIDToken(idToken) + rawJSON, err := s.retrieveRawJWTPayload(idToken) if err != nil { - s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", token)) + s.log.Warn("Error retrieving id_token payload", "error", err, "token", fmt.Sprintf("%+v", token)) return nil } + return s.parseUserInfoFromJSON(rawJSON, "id_token") +} + +func (s *SocialGenericOAuth) extractFromAccessToken(token *oauth2.Token) *UserInfoJson { + s.log.Debug("Extracting user info from OAuth access token") + + accessToken := token.AccessToken + if accessToken == "" { + s.log.Debug("No access token found") + return nil + } + + rawJSON, err := s.retrieveRawJWTPayload(accessToken) + if err != nil { + s.log.Warn("Error retrieving access token payload", "error", err) + return nil + } + + return s.parseUserInfoFromJSON(rawJSON, "access_token") +} + +// parseUserInfoFromJSON is a helper method to parse UserInfoJson from raw JSON and source +func (s *SocialGenericOAuth) parseUserInfoFromJSON(rawJSON []byte, source string) *UserInfoJson { var data UserInfoJson if err := json.Unmarshal(rawJSON, &data); err != nil { - s.log.Error("Error decoding id_token JSON", "raw_json", string(rawJSON), "error", err) + s.log.Error("Error decoding user info JSON", "raw_json", string(rawJSON), "error", err, "source", source) return nil } data.rawJSON = rawJSON - data.source = "token" - s.log.Debug("Received id_token", "raw_json", string(data.rawJSON), "data", data.String()) + data.source = source + s.log.Debug("Parsed user info from JSON", "raw_json", string(rawJSON), "data", data.String(), "source", source) return &data } @@ -404,18 +489,7 @@ func (s *SocialGenericOAuth) extractFromAPI(ctx context.Context, client *http.Cl return nil } - rawJSON := rawUserInfoResponse.Body - - var data UserInfoJson - if err := json.Unmarshal(rawJSON, &data); err != nil { - s.log.Error("Error decoding user info response", "raw_json", rawJSON, "error", err) - return nil - } - - data.rawJSON = rawJSON - data.source = "API" - s.log.Debug("Received user info response from API", "raw_json", string(rawJSON), "data", data.String()) - return &data + return s.parseUserInfoFromJSON(rawUserInfoResponse.Body, "API") } func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { diff --git a/pkg/login/social/connectors/generic_oauth_test.go b/pkg/login/social/connectors/generic_oauth_test.go index 881ed9fd541..b4b7469cc05 100644 --- a/pkg/login/social/connectors/generic_oauth_test.go +++ b/pkg/login/social/connectors/generic_oauth_test.go @@ -31,6 +31,7 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) { AllowAssignGrafanaAdmin bool ResponseBody any OAuth2Extra any + AccessToken string Setup func(*orgtest.FakeOrgService) RoleAttributePath string RoleAttributeStrict bool @@ -440,6 +441,62 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) { ExpectedEmail: "john.doe@example.com", ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer}, }, + // Access Token Test Cases + { + Name: "Given a valid access token with role, no ID token, no API response, use access token", + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{}, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.oVEMSJVqBwrGXOcwGgXL_8J-CZhgFVPjXXSqzPJQ5JU", // { "role": "Editor", "email": "access.token@example.com" } + RoleAttributePath: "role", + ExpectedEmail: "access.token@example.com", + ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor}, + }, + { + Name: "Given a valid access token with org roles, no ID token, no API response, use access token", + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{}, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiVmlld2VyIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20iLCJpbmZvIjp7InJvbGVzIjpbImFjY2Vzcy1kZXYiLCJhY2Nlc3Mtb3BzIl19fQ.g8-mNJQDL9CJWgRTFdKBRRKbsHZfFhJrzPYQGXfxGIE", // { "role": "Viewer", "email": "access.token@example.com", "info": { "roles": [ "access-dev", "access-ops" ] }} + RoleAttributePath: "role", + OrgAttributePath: "info.roles", + OrgMapping: []string{"access-dev:org_dev:Admin", "access-ops:org_engineering:Editor"}, + ExpectedEmail: "access.token@example.com", + ExpectedOrgRoles: map[int64]org.RoleType{4: org.RoleAdmin, 5: org.RoleEditor}, + }, + { + Name: "Given a valid access token and ID token, prefer ID token", + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{ + // { "role": "Admin", "email": "id.token@example.com" } + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4iLCJlbWFpbCI6ImlkLnRva2VuQGV4YW1wbGUuY29tIn0.T8wcoOOPQ_av9VsOFoYJZGNFGJgG0d3LPDvtxvgODkU", + }, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.oVEMSJVqBwrGXOcwGgXL_8J-CZhgFVPjXXSqzPJQ5JU", // { "role": "Editor", "email": "access.token@example.com" } + RoleAttributePath: "role", + ExpectedEmail: "id.token@example.com", + ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin}, + }, + { + Name: "Given a valid access token with no email, ID token with no role, API response with no data, merge", + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{ + // { "email": "id.token@example.com" } + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImlkLnRva2VuQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4", + }, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIn0.gfnKWZKNFNqrILhHFzabBVEWnJJIZBmQSBwLPCHhLUY", // { "role": "Editor" } + RoleAttributePath: "role", + ExpectedEmail: "id.token@example.com", + ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor}, + }, + { + Name: "Given a valid access token with GrafanaAdmin role and AssignGrafanaAdmin enabled", + AllowAssignGrafanaAdmin: true, + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{}, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiR3JhZmFuYUFkbWluIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.fJPjMgZW9bOYXOLgOUekNQmNrVbUNhU1iqQJwqFWzUY", // { "role": "GrafanaAdmin", "email": "access.token@example.com" } + RoleAttributePath: "role", + ExpectedEmail: "access.token@example.com", + ExpectedGrafanaAdmin: trueBoolPtr(), + ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin}, + }, } cfg := &setting.Cfg{ @@ -479,8 +536,9 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) { require.NoError(t, err) })) provider.info.ApiUrl = ts.URL + staticToken := oauth2.Token{ - AccessToken: "", + AccessToken: tc.AccessToken, TokenType: "", RefreshToken: "", Expiry: time.Now(), @@ -853,7 +911,7 @@ func TestPayloadCompression(t *testing.T) { } token := staticToken.WithExtra(test.OAuth2Extra) - userInfo := provider.extractFromToken(token) + userInfo := provider.extractFromIDToken(token) if test.ExpectedEmail == "" { require.Nil(t, userInfo, "Testing case %q", test.Name) diff --git a/pkg/login/social/connectors/gitlab_oauth.go b/pkg/login/social/connectors/gitlab_oauth.go index 917a4503218..2a2d2b7100b 100644 --- a/pkg/login/social/connectors/gitlab_oauth.go +++ b/pkg/login/social/connectors/gitlab_oauth.go @@ -275,7 +275,7 @@ func (s *SocialGitlab) extractFromToken(ctx context.Context, client *http.Client return nil, nil } - rawJSON, err := s.retrieveRawIDToken(idToken) + rawJSON, err := s.retrieveRawJWTPayload(idToken) if err != nil { s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken)) return nil, nil diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go index 2191a2c01e8..4e5d7a3f8f8 100644 --- a/pkg/login/social/connectors/google_oauth.go +++ b/pkg/login/social/connectors/google_oauth.go @@ -236,7 +236,7 @@ func (s *SocialGoogle) extractFromToken(_ context.Context, _ *http.Client, token return nil, nil } - rawJSON, err := s.retrieveRawIDToken(idToken) + rawJSON, err := s.retrieveRawJWTPayload(idToken) if err != nil { s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken)) return nil, nil diff --git a/pkg/login/social/connectors/social_base.go b/pkg/login/social/connectors/social_base.go index 35bc67004c5..e6bda1c4f81 100644 --- a/pkg/login/social/connectors/social_base.go +++ b/pkg/login/social/connectors/social_base.go @@ -196,21 +196,21 @@ func (s *SocialBase) isGroupMember(groups []string) bool { return false } -func (s *SocialBase) retrieveRawIDToken(idToken any) ([]byte, error) { - tokenString, ok := idToken.(string) +func (s *SocialBase) retrieveRawJWTPayload(token any) ([]byte, error) { + tokenString, ok := token.(string) if !ok { - return nil, fmt.Errorf("id_token is not a string: %v", idToken) + return nil, fmt.Errorf("token is not a string: %v", token) } jwtRegexp := regexp.MustCompile("^([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)$") matched := jwtRegexp.FindStringSubmatch(tokenString) if matched == nil { - return nil, fmt.Errorf("id_token is not in JWT format: %s", tokenString) + return nil, fmt.Errorf("token is not in JWT format: %s", tokenString) } rawJSON, err := base64.RawURLEncoding.DecodeString(matched[2]) if err != nil { - return nil, fmt.Errorf("error base64 decoding id_token: %w", err) + return nil, fmt.Errorf("error base64 decoding token payload: %w", err) } headerBytes, err := base64.RawURLEncoding.DecodeString(matched[1]) diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index 82323e21bfe..93036b87915 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -114,9 +114,6 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi if !s.cfg.JWTAuth.SkipOrgRoleSync { role, grafanaAdmin := s.extractRoleAndAdmin(claims) - if err != nil { - s.log.Warn("Failed to extract role", "err", err) - } if s.cfg.JWTAuth.AllowAssignGrafanaAdmin { id.IsGrafanaAdmin = &grafanaAdmin diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 35722cbc964..24459c08963 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -861,7 +861,7 @@ func TestIntegrationFolderGetPermissions(t *testing.T) { } // TestFoldersCreateAPIEndpointK8S is the counterpart of pkg/api/folder_test.go TestFoldersCreateAPIEndpoint -func TestFoldersCreateAPIEndpointK8S(t *testing.T) { +func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -902,7 +902,7 @@ func TestFoldersCreateAPIEndpointK8S(t *testing.T) { description: "folder creation fails without permissions to create a folder", input: folderWithoutParentInput, expectedCode: http.StatusForbidden, - expectedMessage: dashboards.ErrFolderAccessDenied.Error(), + expectedMessage: fmt.Sprintf("You'll need additional permissions to perform this action. Permissions needed: %s", "folders:create"), permissions: []resourcepermissions.SetResourcePermissionCommand{}, }, { @@ -1022,7 +1022,7 @@ func testDescription(description string, expectedErr error) string { } // There are no counterpart of TestFoldersGetAPIEndpointK8S in pkg/api/folder_test.go -func TestFoldersGetAPIEndpointK8S(t *testing.T) { +func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -1062,6 +1062,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) { expectedOutput: []dtos.FolderSearchHit{ {UID: "foo", Title: "Folder 1"}, {UID: "qux", Title: "Folder 3"}, + {UID: folder.SharedWithMeFolder.UID, Title: folder.SharedWithMeFolder.Title}, }, permissions: folderReadAndCreatePermission, }, @@ -1107,7 +1108,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) { } // test on all dualwriter modes - for mode := 1; mode <= 4; mode++ { + for mode := 0; mode <= 4; mode++ { for _, tc := range tcs { t.Run(fmt.Sprintf("Mode: %d, %s", mode, tc.description), func(t *testing.T) { modeDw := grafanarest.DualWriterMode(mode) @@ -1123,6 +1124,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) { }, EnableFeatureToggles: []string{ featuremgmt.FlagNestedFolders, + featuremgmt.FlagUnifiedStorageSearch, featuremgmt.FlagKubernetesClientDashboardsFolders, }, }) diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx index 10a9e429099..291696baeeb 100644 --- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx @@ -210,7 +210,7 @@ export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) { const { workflowOptions, isGitHub, repository, folder, initialValues } = useProvisionedFolderFormData({ folderUid: parentFolder?.uid, action: 'create', - title: parentFolder?.title, + title: '', // Empty title for new folders }); if (!initialValues) { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index f1660b56772..19a1e3af6c5 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -18,6 +18,9 @@ import { addQuery } from 'app/core/utils/query'; import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; +import { ExpressionTypeDropdown } from 'app/features/expressions/components/ExpressionTypeDropdown'; +import { ExpressionQueryType } from 'app/features/expressions/types'; +import { getDefaults } from 'app/features/expressions/utils/expressionTypes'; import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent'; import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows'; import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup'; @@ -286,9 +289,15 @@ export class PanelDataQueriesTab extends SceneObjectBase { + public onAddExpressionOfType = (type: ExpressionQueryType) => { const queries = this.getQueries(); - this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery())); + // Create base expression query with the specified type + const baseQuery = expressionDatasource.newQuery(); + const queryWithType = { ...baseQuery, type }; + // Apply defaults specific to the expression type + const queryWithDefaults = getDefaults(queryWithType); + + this.onQueriesChange(addQuery(queries, queryWithDefaults)); }; public renderExtraActions() { @@ -316,6 +325,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps { // ensure all queries explicitly define a datasource @@ -394,16 +404,11 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps )} {config.expressionsEnabled && model.isExpressionsSupported(dsSettings) && ( - + + )} {model.renderExtraActions()} diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index 75261904c60..b1f07c253bb 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -1,10 +1,12 @@ +import { css } from '@emotion/css'; import { useCallback, useEffect, useRef } from 'react'; -import { DataSourceApi, QueryEditorProps, SelectableValue } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { InlineField, Select } from '@grafana/ui'; +import { DataSourceApi, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; +import { Button, IconButton, InlineField, PopoverContent, useStyles2 } from '@grafana/ui'; import { ClassicConditions } from './components/ClassicConditions'; +import { ExpressionTypeDropdown } from './components/ExpressionTypeDropdown'; import { Math } from './components/Math'; import { Reduce } from './components/Reduce'; import { Resample } from './components/Resample'; @@ -20,6 +22,24 @@ const labelWidth = 15; type NonClassicExpressionType = Exclude; type ExpressionTypeConfigStorage = Partial>; +// Help text for each expression type - can be expanded with more detailed content +const getExpressionHelpText = (type: ExpressionQueryType): PopoverContent | string => { + const description = expressionTypes.find(({ value }) => value === type)?.description; + + switch (type) { + case ExpressionQueryType.sql: + return ( + + Run MySQL-dialect SQL against the tables returned from your data sources. Data source queries (ie "A", "B") + are available as tables and referenced by query-name. Fields are available as columns, as returned from the + data source. + + ); + default: + return description ?? ''; + } +}; + function useExpressionsCache() { const expressionCache = useRef({}); @@ -62,14 +82,16 @@ export function ExpressionQueryEditor(props: Props) { const { query, queries, onRunQuery, onChange, app } = props; const { getCachedExpression, setCachedExpression } = useExpressionsCache(); + const styles = useStyles2(getStyles); + useEffect(() => { setCachedExpression(query.type, query.expression); }, [query.expression, query.type, setCachedExpression]); const onSelectExpressionType = useCallback( - (item: SelectableValue) => { - const cachedExpression = getCachedExpression(item.value!); - const defaults = getDefaults({ ...query, type: item.value! }); + (value: ExpressionQueryType) => { + const cachedExpression = getCachedExpression(value!); + const defaults = getDefaults({ ...query, type: value! }); onChange({ ...defaults, expression: cachedExpression ?? defaults.expression }); }, @@ -100,17 +122,35 @@ export function ExpressionQueryEditor(props: Props) { } }; - const selected = expressionTypes.find((o) => o.value === query.type); + const helperText = getExpressionHelpText(query.type); return (
- -